From d89c4ec4a6519620e3b60829e3459428abbdc3d9 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 20 Oct 2026 12:59:58 -0700 Subject: [PATCH 1/8] feat: share trigger registration between client and server apps --- src/ansys/visor/viewer/app/trame/local_app.py | 587 +----------------- .../viewer/app/trame/scene_trigger_app.py | 575 +++++++++++++++++ tests/unit/app/test_local_app.py | 25 +- .../unit/app/test_local_app_set_projection.py | 7 +- tests/unit/app/test_local_app_sync_camera.py | 11 +- ...test_local_app_sync_cross_section_plane.py | 1 + .../app/test_local_app_ui_panel_triggers.py | 3 +- .../app/test_local_app_widget_triggers.py | 15 +- tests/unit/app/test_scene_trigger_app.py | 111 ++++ tests/unit/app/test_visor_trame_app.py | 17 +- 10 files changed, 748 insertions(+), 604 deletions(-) create mode 100644 src/ansys/visor/viewer/app/trame/scene_trigger_app.py create mode 100644 tests/unit/app/test_scene_trigger_app.py diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index 5dd27b96..34fa86ea 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -1,30 +1,13 @@ -import functools -import urllib.parse from logging import Logger -from typing import List, Optional, Protocol -from pydantic import BaseModel, Field, ValidationError from trame.app.core import Server from trame.decorators import TrameApp, trigger from vtk import vtkObject +from ansys.visor.viewer.app.trame.scene_trigger_app import SceneMutationApi, SceneTriggerApp from ansys.visor.viewer.config import settings -from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.core.visor_enums import RenderingMode 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 -from ansys.visor.viewer.models.runtime.requests.variable_range_payload import SetVariableRangePayload -from ansys.visor.viewer.models.runtime.requests.widget_state_payloads import ( - SetBoundingBoxVisibilityPayload, - SetCrossSectionVisibilityPayload, - SetEdgesVisiblePayload, - SetPanelTopLeftPanelCollapsedPayload, - SetPanelTopRightLegendCollapsedPayload, - SetPanelTopRightPanelCollapsedPayload, - SetPanelTopRightTabIndexPayload, - SetProjectionPayload, - SyncCrossSectionPlanePayload, -) logger = VisorDefaultLogger(__name__) @@ -32,203 +15,20 @@ vtkObject.GlobalWarningDisplayOff() -class SceneMutationApi(Protocol): - """Structural type of the coordinator surface LocalApp calls. - - Typing only: there is no ``runtime_checkable`` decoration and no - ``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. - - Covers both per-part mutations (visibility, opacity, colour, selection) - and scene-wide ones (camera sync, the widget-state toggles, projection, - cross-section plane). The one production injection site passes the - whole scene coordinator, so this protocol describes that whole surface. - """ - - def set_part_visibility(self, node_id: int, visible: bool) -> None: ... - - def set_part_opacity(self, node_id: int, opacity: float) -> None: ... - - def set_part_diffuse_color(self, node_id: int, diffuse_rgb: Optional[List[float]]) -> None: ... - - def set_part_selected(self, node_id: int, selected: bool) -> None: ... - - def set_part_color_variable( - self, - node_id: int, - variable_id: str, - association: VisorVtkVariableType, - array_name: str, - component: int, - ) -> bool: ... - - def set_variable_range( - self, variable_id: str, component: int, min_val: float, max_val: float - ) -> bool: ... - - def clear_part_color_variable(self, node_id: int) -> None: ... - - def sync_camera(self, camera_state: VisorCameraState) -> None: ... - - def set_cross_section_visibility(self, visible: bool) -> None: ... - - def set_edges_visible(self, visible: bool) -> None: ... - - def set_bounding_box_visibility(self, visible: bool) -> None: ... - - def set_projection(self, parallel: bool) -> None: ... - - def sync_cross_section_plane( - self, origin: List[float], normal: List[float] - ) -> None: ... - - def set_panel_top_left_panel_collapsed(self, collapsed: bool) -> None: ... - - def set_panel_top_right_panel_collapsed(self, collapsed: bool) -> None: ... - - def set_panel_top_right_legend_collapsed(self, collapsed: bool) -> None: ... - - def set_panel_top_right_tab_index(self, tab_index: int) -> None: ... - - -# ---------------------------------------------------------------------- -# Trigger payload models -# -# One model per per-part trigger. Field names are snake_case; the -# camelCase wire keys the client sends are carried as pydantic aliases. -## ---------------------------------------------------------------------- - - -class SetPartVisibilityPayload(BaseModel): - """Payload of the ``set_part_visibility`` trigger.""" - - node_id: int = Field(alias="nodeId") - visible: bool - - -class SetPartOpacityPayload(BaseModel): - """Payload of the ``set_part_opacity`` trigger.""" - - node_id: int = Field(alias="nodeId") - opacity: float = Field(ge=0.0, le=1.0) - - -class SetPartDiffuseColorPayload(BaseModel): - """Payload of the ``set_part_diffuse_color`` trigger.""" - - node_id: int = Field(alias="nodeId") - diffuse_rgb: Optional[List[float]] = Field(min_length=3, max_length=3, alias="diffuseRgb") - - -class SetPartSelectedPayload(BaseModel): - """Payload of the ``set_part_selected`` trigger. - - No colour crosses this trigger: the server reads the part's stored - diffuse colour from its own record. - """ - - node_id: int = Field(alias="nodeId") - selected: bool - - -class SetPartColorVariablePayload(BaseModel): - """Payload of the ``set_part_color_variable`` trigger.""" - - node_id: int = Field(alias="nodeId") - variable_id: str = Field(alias="variableId") - association: VisorVtkVariableType - array_name: str = Field(alias="arrayName") - component: int - - -class ClearPartColorVariablePayload(BaseModel): - """Payload of the ``clear_part_color_variable`` trigger.""" - - node_id: int = Field(alias="nodeId") - - -def parse_payload(model: type[BaseModel]): - """Validate a trigger payload into *model*, or make the call a logged no-op. - - The wrapped handler receives the parsed model in place of the raw - ``dict``. A payload that does not validate never reaches the handler - body: it is logged at warning and the trigger returns ``None``. - - Posture. This applies the same logged-no-op posture the whole per-part - path uses for an unresolvable node id, extended to a malformed payload. - The reasoning transfers because it is about the *thread*, not about the - kind of badness: trigger handlers run on trame's daemon event-loop - thread, where a raise surfaces to no caller who can act on it. Before - this decorator the handlers indexed their payloads directly and a - missing key raised ``KeyError`` there. - - ``model_validate``, never ``model(**payload)``. A payload that is not - a mapping at all -- a bare string, a number, a list -- raises - ``TypeError`` from ``**`` but a well-formed ``ValidationError`` from - ``model_validate``. Only the latter lets one guard catch every - malformed shape instead of most of them. - - Decorator order. ``@trigger(...)`` goes **outermost**, above this one. - Both orders happen to work: trame's ``@trigger`` stamps - ``_trame_trigger_names`` on whatever function it is handed, and - ``functools.wraps`` copies ``__dict__`` outward, so an inner - ``@trigger`` is still found by ``TrameApp``'s registration loop. - Outermost is the order whose correctness does not depend on that - copying behaviour, so it is the one that is correct by design rather - than by accident. - """ - - def decorate(handler): - @functools.wraps(handler) - def wrapper(self, payload): - try: - parsed = model.model_validate(payload) - except ValidationError as exc: - logger.warning( - "%s: invalid payload; ignoring. %s", handler.__name__, exc - ) - return None - return handler(self, parsed) - - return wrapper - - return decorate - - @TrameApp() -class LocalApp: +class LocalApp(SceneTriggerApp): """ - LocalApp is a class that provides an interface for initializing and managing a visualization application - using the VTK rendering engine. - It supports server-based rendering and interaction with a visualization pipeline. + LocalApp serves the scene triggers shared by every rendering mode, plus + the local-only triggers for sub-geometry picking and client performance + reporting, for client-side rendering via VTK-WASM in the browser. + Attributes: server (Server): the Trame server instance Methods: - get_scene_details_json: returns the scene details in JSON format pick_geometry: picks the geometry for rendering perf_report_wasm: reports the performance of the wasm update cycle perf_report_server_update: reports the performance of the server update cycle - set_part_visibility: sets whether one part is visible - set_part_opacity: sets one part's opacity - set_part_diffuse_color: sets or clears one part's custom diffuse colour - 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 - set_variable_range: stores one variable slot's effective range - sync_camera: records a settled camera reported by the frontend - set_cross_section_visibility: shows or hides the cross-section plane - set_edges_visible: shows or hides edges on every part - set_bounding_box_visibility: shows or hides the bounding-box outline - set_projection: sets parallel or perspective projection on the camera record - sync_cross_section_plane: records a settled cross-section plane reported by the frontend - set_panel_top_left_panel_collapsed: records whether the top-left panel is collapsed - set_panel_top_right_panel_collapsed: records whether the top-right panel is collapsed - set_panel_top_right_legend_collapsed: records whether the top-right legend is collapsed - set_panel_top_right_tab_index: records which top-right tab is active 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. """ def __init__( self, @@ -239,72 +39,20 @@ def __init__( pick_geometry=None, scene_mutation_api: SceneMutationApi | None = None, ): - self.server = server - # Callable to get the scene details in JSON format - self._get_scene_details_json = get_scene_details_json - # Callable for sub-geometry picking (optional) + # Callable for sub-geometry picking (optional), local-only. self._pick_geometry = pick_geometry - # Per-part visual state coordinator (see SceneMutationApi). The one - # production construction site always supplies it; it is optional so - # that the class stays constructible without a scene. - self._scene_mutation_api = scene_mutation_api - # logger for logging trame server lifecycle info - self.__trame_logger = trame_logger - - """ Set up lifecycle hooks. """ - self._register_lifecycle_hooks() - self._set_headers() - self._set_www_path(standalone) - - def _register_lifecycle_hooks(self): - """ Register server lifecycle event handlers for extra trame logging. """ - - @self.server.controller.add("on_server_start") - def server_start(_): - if self.__trame_logger: - self.__trame_logger.debug("Starting server") - - @self.server.controller.add("on_server_ready") - def server_ready(**state): - print(f"Server started and ready on {self.server.name}") - if self.__trame_logger: - self.__trame_logger.debug("Server is ready.") - - @self.server.controller.add("on_client_connected") - def client_connected(): - if self.__trame_logger: - self.__trame_logger.debug("Client connected.") - - @self.server.controller.add("on_client_exited") - def client_exited(): - if self.__trame_logger: - self.__trame_logger.debug("Client exited.") - - @self.server.controller.add("on_server_exited") - def server_exited(**state): - print("Server stopped") - if self.__trame_logger: - self.__trame_logger.debug("Server is exiting.") - - def _set_headers(self): - """ Set HTTP headers for the server.""" - # If a Dash client is connecting to this Trame server, - # the Dash client will be running on a different port, so - # we need to allow cross-origin access to it. - self.server.http_headers.set_header("Access-Control-Allow-Origin", "*") - # Always serve assets fresh (not from cache). This - # will save us some headaches in the future. - self.server.http_headers.set_header("Cache-Control", "max-age=0") - - def _set_www_path(self, standalone: bool): - """ Set the www path for the server based on the standalone flag.""" - self.server._www = settings.get_client_bundle(standalone) - logger.debug(f"Standalone client path: {self.server._www}") + super().__init__( + server=server, + get_scene_details_json=get_scene_details_json, + standalone=standalone, + trame_logger=trame_logger, + scene_mutation_api=scene_mutation_api, + ) - @trigger("get_visor_scene_details_json") - def get_visor_scene_details_json(self): - """ returns the scene details in JSON format. """ - return self._get_scene_details_json() + @property + def rendering_mode(self) -> RenderingMode: + """This app serves client-side rendering via VTK-WASM in the browser.""" + return RenderingMode.LOCAL @trigger("pick_geometry") def pick_geometry(self, actor_wasm_id, cell_id, mode: str, world_x: float, world_y: float, world_z: float): @@ -348,300 +96,3 @@ def perf_report_server_update(self, payload: dict): f" | handler={payload.get('handlerMs', 0):.2f}ms" ) - # ------------------------------------------------------------------ - # Per-part visual state triggers - # - # Frontend -> Backend. Each takes a single ``payload: dict`` argument - # and returns ``None``; each delegates to the identically-named method - # on the injected coordinator. Every payload carries the absolute - # target value, never a toggle or a delta, so a message the client - # suppresses as redundant is indistinguishable from one that set a - # value a part already had. - # - # Payloads are validated at this boundary by ``@parse_payload``, which - # hands the handler a parsed model instead of the raw dict. A payload - # that does not validate -- a missing key, a wrong-typed value, an - # association that is not an enum member, a diffuse colour that is not - # three components, an opacity outside [0, 1], or a payload that is not - # a mapping at all -- is a logged no-op and never reaches a handler - # body. - # ------------------------------------------------------------------ - - def _mutation_api(self, trigger_name: str, payload: BaseModel) -> SceneMutationApi | None: - """Return the injected coordinator, or ``None`` after logging.""" - logger.debug("[trigger] %s arrived: %s.", trigger_name, payload) - if self._scene_mutation_api is None: - logger.debug("%s: no scene part-state API injected; ignoring.", trigger_name) - return None - return self._scene_mutation_api - - @trigger("set_part_visibility") - @parse_payload(SetPartVisibilityPayload) - def set_part_visibility(self, payload) -> None: - """Frontend -> Backend: set whether one part is visible.""" - api = self._mutation_api("set_part_visibility", payload) - if api is None: - return - api.set_part_visibility(payload.node_id, payload.visible) - - @trigger("set_part_opacity") - @parse_payload(SetPartOpacityPayload) - def set_part_opacity(self, payload) -> None: - """Frontend -> Backend: set one part's opacity. - - An opacity outside ``[0.0, 1.0]`` fails validation and is a logged - no-op; it does not reach VTK to be clamped. - """ - api = self._mutation_api("set_part_opacity", payload) - if api is None: - return - api.set_part_opacity(payload.node_id, payload.opacity) - - @trigger("set_part_diffuse_color") - @parse_payload(SetPartDiffuseColorPayload) - def set_part_diffuse_color(self, payload) -> None: - """Frontend -> Backend: set one part's custom diffuse colour. - - ``diffuseRgb`` of ``None`` clears the custom colour and is forwarded - as ``None``; no default colour is substituted here. An absent key, - or a colour that is not exactly three components, is a logged - no-op -- nothing is delegated, so nothing is written to the store. - """ - api = self._mutation_api("set_part_diffuse_color", payload) - if api is None: - return - api.set_part_diffuse_color(payload.node_id, payload.diffuse_rgb) - - @trigger("set_part_selected") - @parse_payload(SetPartSelectedPayload) - def set_part_selected(self, payload) -> None: - """Frontend -> Backend: select or deselect one part. - - No colour crosses this trigger: the server reads the part's stored - diffuse colour from its own record. - """ - api = self._mutation_api("set_part_selected", payload) - if api is None: - return - api.set_part_selected(payload.node_id, payload.selected) - - @trigger("set_part_color_variable") - @parse_payload(SetPartColorVariablePayload) - def set_part_color_variable(self, payload) -> None: - """Frontend -> Backend: colour one part by a scalar variable. - - ``association`` is resolved at this boundary into a - :class:`VisorVtkVariableType` by the payload model, which matches by - exact value -- never upper-cased, never passed on as a bare string. - A value that is not a member fails validation and is a logged - no-op, matching the posture the pipeline takes on an unknown array - name. ``variableId`` is forwarded verbatim and is never parsed by - the server. - - The range is not carried: the server applies its own record's - effective range for the referenced slot. - """ - api = self._mutation_api("set_part_color_variable", payload) - if api is None: - return - api.set_part_color_variable( - payload.node_id, - payload.variable_id, - payload.association, - payload.array_name, - payload.component, - ) - - @trigger("clear_part_color_variable") - @parse_payload(ClearPartColorVariablePayload) - def clear_part_color_variable(self, payload) -> None: - """Frontend -> Backend: stop colouring one part by a scalar variable.""" - api = self._mutation_api("clear_part_color_variable", payload) - if api is None: - return - api.clear_part_color_variable(payload.node_id) - - @trigger("set_variable_range") - @parse_payload(SetVariableRangePayload) - def set_variable_range(self, payload) -> None: - """Frontend -> Backend: store one variable slot's effective range. - - Scene-wide, not per-part: the coordinator applies the range to every - part whose reference names that slot. - """ - api = self._mutation_api("set_variable_range", payload) - if api is None: - return - api.set_variable_range( - payload.variable_id, payload.component, payload.min_val, payload.max_val - ) - - # ------------------------------------------------------------------ - # 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._mutation_api("sync_camera", payload) - if api is None: - return - logger.debug( - "sync_camera: origin=%s; applying position=%s.", - payload.origin, - payload.camera.position, - ) - api.sync_camera(payload.camera) - - # ------------------------------------------------------------------ - # Widget-state triggers - # - # Frontend -> Backend. One trigger per server-tracked toggle. Each - # carries the absolute target value, not a delta, so a redundant - # message is indistinguishable from a no-op one, and both are fine. - # No ``origin`` field: unlike the camera, a toggle echo is idempotent. - # - # ``set_projection`` lives here too: it is delivered the same way, but - # it writes the camera record's projection field rather than a toggle - # of its own. - # - # The last four carry UI panel layout, not widget state: no renderer - # depends on them, so each coordinator just writes one store field. - # Echoes are idempotent for the same reason as the toggles'. - # ------------------------------------------------------------------ - - @trigger("set_cross_section_visibility") - @parse_payload(SetCrossSectionVisibilityPayload) - def set_cross_section_visibility(self, payload) -> None: - """Frontend -> Backend: show or hide the cross-section plane.""" - api = self._mutation_api("set_cross_section_visibility", payload) - if api is None: - return - api.set_cross_section_visibility(payload.visible) - - @trigger("set_edges_visible") - @parse_payload(SetEdgesVisiblePayload) - def set_edges_visible(self, payload) -> None: - """Frontend -> Backend: show or hide edges on every part.""" - api = self._mutation_api("set_edges_visible", payload) - if api is None: - return - api.set_edges_visible(payload.visible) - - @trigger("set_bounding_box_visibility") - @parse_payload(SetBoundingBoxVisibilityPayload) - def set_bounding_box_visibility(self, payload) -> None: - """Frontend -> Backend: show or hide the bounding-box outline.""" - api = self._mutation_api("set_bounding_box_visibility", payload) - if api is None: - return - api.set_bounding_box_visibility(payload.visible) - - @trigger("set_projection") - @parse_payload(SetProjectionPayload) - def set_projection(self, payload) -> None: - """Frontend -> Backend: set parallel or perspective projection. - - The projection is the camera record's field, not a toggle of its - own: the coordinator writes the record and re-serialises the - camera in one critical section. - """ - api = self._mutation_api("set_projection", payload) - if api is None: - return - api.set_projection(payload.parallel) - - @trigger("sync_cross_section_plane") - @parse_payload(SyncCrossSectionPlanePayload) - def sync_cross_section_plane(self, payload) -> None: - """Frontend -> Backend: a settled drag reports the cross-section plane. - - Not a toggle: it carries the origin and the normal the widget settled - on, both required and both exactly three components. A half-plane -- - a missing vector, or one of the wrong length -- fails validation and - is a logged no-op at this boundary rather than a half-applied plane, - which is ``sync_camera``'s stated posture for the same reason. - - The coordinator writes the record and both server VTK objects and - re-serialises them in one critical section; nothing is pushed from - here. - """ - api = self._mutation_api("sync_cross_section_plane", payload) - if api is None: - return - api.sync_cross_section_plane(payload.origin, payload.normal) - - @trigger("set_panel_top_left_panel_collapsed") - @parse_payload(SetPanelTopLeftPanelCollapsedPayload) - def set_panel_top_left_panel_collapsed(self, payload) -> None: - """Frontend -> Backend: the top-left panel reports its collapsed state.""" - api = self._mutation_api("set_panel_top_left_panel_collapsed", payload) - if api is None: - return - api.set_panel_top_left_panel_collapsed(payload.collapsed) - - @trigger("set_panel_top_right_panel_collapsed") - @parse_payload(SetPanelTopRightPanelCollapsedPayload) - def set_panel_top_right_panel_collapsed(self, payload) -> None: - """Frontend -> Backend: the top-right panel reports its collapsed state.""" - api = self._mutation_api("set_panel_top_right_panel_collapsed", payload) - if api is None: - return - api.set_panel_top_right_panel_collapsed(payload.collapsed) - - @trigger("set_panel_top_right_legend_collapsed") - @parse_payload(SetPanelTopRightLegendCollapsedPayload) - def set_panel_top_right_legend_collapsed(self, payload) -> None: - """Frontend -> Backend: the top-right legend reports its collapsed state.""" - api = self._mutation_api("set_panel_top_right_legend_collapsed", payload) - if api is None: - return - api.set_panel_top_right_legend_collapsed(payload.collapsed) - - @trigger("set_panel_top_right_tab_index") - @parse_payload(SetPanelTopRightTabIndexPayload) - def set_panel_top_right_tab_index(self, payload) -> None: - """Frontend -> Backend: the top-right panel reports its active tab. - - The index is forwarded verbatim and is never range-checked here; the - client's ``applyTabIndex`` is the guard, so a future third tab is a UI - change rather than a validation failure at this boundary. - """ - api = self._mutation_api("set_panel_top_right_tab_index", payload) - if api is None: - return - api.set_panel_top_right_tab_index(payload.tab_index) - - def set_only_cookie(self, key: str, value: str): - """ - Sets a cookie on the server. NOTE: there is a limitation - with Trame server whereby it only allows you to set a single cookie header. - """ - key = urllib.parse.quote(key) - value = urllib.parse.quote(value) - self.server.http_headers.set_header("Set-Cookie", f"{key}={value};Path=/;") diff --git a/src/ansys/visor/viewer/app/trame/scene_trigger_app.py b/src/ansys/visor/viewer/app/trame/scene_trigger_app.py new file mode 100644 index 00000000..13f510a4 --- /dev/null +++ b/src/ansys/visor/viewer/app/trame/scene_trigger_app.py @@ -0,0 +1,575 @@ +import functools +import urllib.parse +from abc import ABC, abstractmethod +from logging import Logger +from typing import List, Optional, Protocol + +from pydantic import BaseModel, Field, ValidationError +from trame.app.core import Server +from trame.decorators import trigger + +from ansys.visor.viewer.config import settings +from ansys.visor.viewer.core.visor_enums import RenderingMode, 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 +from ansys.visor.viewer.models.runtime.requests.variable_range_payload import SetVariableRangePayload +from ansys.visor.viewer.models.runtime.requests.widget_state_payloads import ( + SetBoundingBoxVisibilityPayload, + SetCrossSectionVisibilityPayload, + SetEdgesVisiblePayload, + SetPanelTopLeftPanelCollapsedPayload, + SetPanelTopRightLegendCollapsedPayload, + SetPanelTopRightPanelCollapsedPayload, + SetPanelTopRightTabIndexPayload, + SetProjectionPayload, + SyncCrossSectionPlanePayload, +) + +logger = VisorDefaultLogger(__name__) + + +class SceneMutationApi(Protocol): + """Structural type of the coordinator surface a scene-trigger app calls. + + Typing only: there is no ``runtime_checkable`` decoration and no + ``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 an app's only route to the scene. + + Covers both per-part mutations (visibility, opacity, colour, selection) + and scene-wide ones (camera sync, the widget-state toggles, projection, + cross-section plane). The one production injection site passes the + whole scene coordinator, so this protocol describes that whole surface. + """ + + def set_part_visibility(self, node_id: int, visible: bool) -> None: ... + + def set_part_opacity(self, node_id: int, opacity: float) -> None: ... + + def set_part_diffuse_color(self, node_id: int, diffuse_rgb: Optional[List[float]]) -> None: ... + + def set_part_selected(self, node_id: int, selected: bool) -> None: ... + + def set_part_color_variable( + self, + node_id: int, + variable_id: str, + association: VisorVtkVariableType, + array_name: str, + component: int, + ) -> bool: ... + + def set_variable_range( + self, variable_id: str, component: int, min_val: float, max_val: float + ) -> bool: ... + + def clear_part_color_variable(self, node_id: int) -> None: ... + + def sync_camera(self, camera_state: VisorCameraState) -> None: ... + + def set_cross_section_visibility(self, visible: bool) -> None: ... + + def set_edges_visible(self, visible: bool) -> None: ... + + def set_bounding_box_visibility(self, visible: bool) -> None: ... + + def set_projection(self, parallel: bool) -> None: ... + + def sync_cross_section_plane( + self, origin: List[float], normal: List[float] + ) -> None: ... + + def set_panel_top_left_panel_collapsed(self, collapsed: bool) -> None: ... + + def set_panel_top_right_panel_collapsed(self, collapsed: bool) -> None: ... + + def set_panel_top_right_legend_collapsed(self, collapsed: bool) -> None: ... + + def set_panel_top_right_tab_index(self, tab_index: int) -> None: ... + + +# ---------------------------------------------------------------------- +# Trigger payload models +# +# One model per per-part trigger. Field names are snake_case; the +# camelCase wire keys the client sends are carried as pydantic aliases. +## ---------------------------------------------------------------------- + + +class SetPartVisibilityPayload(BaseModel): + """Payload of the ``set_part_visibility`` trigger.""" + + node_id: int = Field(alias="nodeId") + visible: bool + + +class SetPartOpacityPayload(BaseModel): + """Payload of the ``set_part_opacity`` trigger.""" + + node_id: int = Field(alias="nodeId") + opacity: float = Field(ge=0.0, le=1.0) + + +class SetPartDiffuseColorPayload(BaseModel): + """Payload of the ``set_part_diffuse_color`` trigger.""" + + node_id: int = Field(alias="nodeId") + diffuse_rgb: Optional[List[float]] = Field(min_length=3, max_length=3, alias="diffuseRgb") + + +class SetPartSelectedPayload(BaseModel): + """Payload of the ``set_part_selected`` trigger. + + No colour crosses this trigger: the server reads the part's stored + diffuse colour from its own record. + """ + + node_id: int = Field(alias="nodeId") + selected: bool + + +class SetPartColorVariablePayload(BaseModel): + """Payload of the ``set_part_color_variable`` trigger.""" + + node_id: int = Field(alias="nodeId") + variable_id: str = Field(alias="variableId") + association: VisorVtkVariableType + array_name: str = Field(alias="arrayName") + component: int + + +class ClearPartColorVariablePayload(BaseModel): + """Payload of the ``clear_part_color_variable`` trigger.""" + + node_id: int = Field(alias="nodeId") + + +def parse_payload(model: type[BaseModel]): + """Validate a trigger payload into *model*, or make the call a logged no-op. + + The wrapped handler receives the parsed model in place of the raw + ``dict``. A payload that does not validate never reaches the handler + body: it is logged at warning and the trigger returns ``None``. + + Posture. This applies the same logged-no-op posture the whole per-part + path uses for an unresolvable node id, extended to a malformed payload. + The reasoning transfers because it is about the *thread*, not about the + kind of badness: trigger handlers run on trame's daemon event-loop + thread, where a raise surfaces to no caller who can act on it. + + ``model_validate``, never ``model(**payload)``. A payload that is not + a mapping at all -- a bare string, a number, a list -- raises + ``TypeError`` from ``**`` but a well-formed ``ValidationError`` from + ``model_validate``. Only the latter lets one guard catch every + malformed shape instead of most of them. + + Decorator order. ``@trigger(...)`` goes **outermost**, above this one. + Both orders happen to work: trame's ``@trigger`` stamps + ``_trame_trigger_names`` on whatever function it is handed, and + ``functools.wraps`` copies ``__dict__`` outward, so an inner + ``@trigger`` is still found by ``TrameApp``'s registration loop. + Outermost is the order whose correctness does not depend on that + copying behaviour, so it is the one that is correct by design rather + than by accident. + """ + + def decorate(handler): + @functools.wraps(handler) + def wrapper(self, payload): + try: + parsed = model.model_validate(payload) + except ValidationError as exc: + logger.warning( + "%s: invalid payload; ignoring. %s", handler.__name__, exc + ) + return None + return handler(self, parsed) + + return wrapper + + return decorate + + +class SceneTriggerApp(ABC): + """Registers the scene-coordinator triggers every rendering mode serves, and the shared HTTP and lifecycle setup.""" + + def __init__( + self, + server: Server, + get_scene_details_json: callable, + standalone: bool, + trame_logger: Logger | None, + scene_mutation_api: SceneMutationApi | None, + ): + self.server = server + # Callable to get the scene details in JSON format + self._get_scene_details_json = get_scene_details_json + # Per-part visual state coordinator (see SceneMutationApi). The one + # production construction site always supplies it; it is optional so + # that the class stays constructible without a scene. + self._scene_mutation_api = scene_mutation_api + # logger for logging trame server lifecycle info + self.__trame_logger = trame_logger + + self.server.state["rendering_mode"] = self.rendering_mode.value + + """ Set up lifecycle hooks. """ + self._register_lifecycle_hooks() + self._set_headers() + self._set_www_path(standalone) + + @property + @abstractmethod + def rendering_mode(self) -> RenderingMode: + """The mode this app serves; published as state key rendering_mode, by value.""" + ... + + def _register_lifecycle_hooks(self): + """ Register server lifecycle event handlers for extra trame logging. """ + + @self.server.controller.add("on_server_start") + def server_start(_): + if self.__trame_logger: + self.__trame_logger.debug("Starting server") + + @self.server.controller.add("on_server_ready") + def server_ready(**state): + print(f"Server started and ready on {self.server.name}") + if self.__trame_logger: + self.__trame_logger.debug("Server is ready.") + + @self.server.controller.add("on_client_connected") + def client_connected(): + if self.__trame_logger: + self.__trame_logger.debug("Client connected.") + + @self.server.controller.add("on_client_exited") + def client_exited(): + if self.__trame_logger: + self.__trame_logger.debug("Client exited.") + + @self.server.controller.add("on_server_exited") + def server_exited(**state): + print("Server stopped") + if self.__trame_logger: + self.__trame_logger.debug("Server is exiting.") + + def _set_headers(self): + """ Set HTTP headers for the server.""" + # If a Dash client is connecting to this Trame server, + # the Dash client will be running on a different port, so + # we need to allow cross-origin access to it. + self.server.http_headers.set_header("Access-Control-Allow-Origin", "*") + # Always serve assets fresh (not from cache). This + # will save us some headaches in the future. + self.server.http_headers.set_header("Cache-Control", "max-age=0") + + def _set_www_path(self, standalone: bool): + """ Set the www path for the server based on the standalone flag.""" + self.server._www = settings.get_client_bundle(standalone) + logger.debug(f"Standalone client path: {self.server._www}") + + @trigger("get_visor_scene_details_json") + def get_visor_scene_details_json(self): + """ returns the scene details in JSON format. """ + return self._get_scene_details_json() + + # ------------------------------------------------------------------ + # Per-part visual state triggers + # + # Frontend -> Backend. Each takes a single ``payload: dict`` argument + # and returns ``None``; each delegates to the identically-named method + # on the injected coordinator. Every payload carries the absolute + # target value, never a toggle or a delta, so a message the client + # suppresses as redundant is indistinguishable from one that set a + # value a part already had. + # + # Payloads are validated at this boundary by ``@parse_payload``, which + # hands the handler a parsed model instead of the raw dict. A payload + # that does not validate -- a missing key, a wrong-typed value, an + # association that is not an enum member, a diffuse colour that is not + # three components, an opacity outside [0, 1], or a payload that is not + # a mapping at all -- is a logged no-op and never reaches a handler + # body. + # ------------------------------------------------------------------ + + def _mutation_api(self, trigger_name: str, payload: BaseModel) -> SceneMutationApi | None: + """Return the injected coordinator, or ``None`` after logging.""" + logger.debug("[trigger] %s arrived: %s.", trigger_name, payload) + if self._scene_mutation_api is None: + logger.debug("%s: no scene part-state API injected; ignoring.", trigger_name) + return None + return self._scene_mutation_api + + @trigger("set_part_visibility") + @parse_payload(SetPartVisibilityPayload) + def set_part_visibility(self, payload) -> None: + """Frontend -> Backend: set whether one part is visible.""" + api = self._mutation_api("set_part_visibility", payload) + if api is None: + return + api.set_part_visibility(payload.node_id, payload.visible) + + @trigger("set_part_opacity") + @parse_payload(SetPartOpacityPayload) + def set_part_opacity(self, payload) -> None: + """Frontend -> Backend: set one part's opacity. + + An opacity outside ``[0.0, 1.0]`` fails validation and is a logged + no-op; it does not reach VTK to be clamped. + """ + api = self._mutation_api("set_part_opacity", payload) + if api is None: + return + api.set_part_opacity(payload.node_id, payload.opacity) + + @trigger("set_part_diffuse_color") + @parse_payload(SetPartDiffuseColorPayload) + def set_part_diffuse_color(self, payload) -> None: + """Frontend -> Backend: set one part's custom diffuse colour. + + ``diffuseRgb`` of ``None`` clears the custom colour and is forwarded + as ``None``; no default colour is substituted here. An absent key, + or a colour that is not exactly three components, is a logged + no-op -- nothing is delegated, so nothing is written to the store. + """ + api = self._mutation_api("set_part_diffuse_color", payload) + if api is None: + return + api.set_part_diffuse_color(payload.node_id, payload.diffuse_rgb) + + @trigger("set_part_selected") + @parse_payload(SetPartSelectedPayload) + def set_part_selected(self, payload) -> None: + """Frontend -> Backend: select or deselect one part. + + No colour crosses this trigger: the server reads the part's stored + diffuse colour from its own record. + """ + api = self._mutation_api("set_part_selected", payload) + if api is None: + return + api.set_part_selected(payload.node_id, payload.selected) + + @trigger("set_part_color_variable") + @parse_payload(SetPartColorVariablePayload) + def set_part_color_variable(self, payload) -> None: + """Frontend -> Backend: colour one part by a scalar variable. + + ``association`` is resolved at this boundary into a + :class:`VisorVtkVariableType` by the payload model, which matches by + exact value -- never upper-cased, never passed on as a bare string. + A value that is not a member fails validation and is a logged + no-op, matching the posture the pipeline takes on an unknown array + name. ``variableId`` is forwarded verbatim and is never parsed by + the server. + + The range is not carried: the server applies its own record's + effective range for the referenced slot. + """ + api = self._mutation_api("set_part_color_variable", payload) + if api is None: + return + api.set_part_color_variable( + payload.node_id, + payload.variable_id, + payload.association, + payload.array_name, + payload.component, + ) + + @trigger("clear_part_color_variable") + @parse_payload(ClearPartColorVariablePayload) + def clear_part_color_variable(self, payload) -> None: + """Frontend -> Backend: stop colouring one part by a scalar variable.""" + api = self._mutation_api("clear_part_color_variable", payload) + if api is None: + return + api.clear_part_color_variable(payload.node_id) + + @trigger("set_variable_range") + @parse_payload(SetVariableRangePayload) + def set_variable_range(self, payload) -> None: + """Frontend -> Backend: store one variable slot's effective range. + + Scene-wide, not per-part: the coordinator applies the range to every + part whose reference names that slot. + """ + api = self._mutation_api("set_variable_range", payload) + if api is None: + return + api.set_variable_range( + payload.variable_id, payload.component, payload.min_val, payload.max_val + ) + + # ------------------------------------------------------------------ + # 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._mutation_api("sync_camera", payload) + if api is None: + return + logger.debug( + "sync_camera: origin=%s; applying position=%s.", + payload.origin, + payload.camera.position, + ) + api.sync_camera(payload.camera) + + # ------------------------------------------------------------------ + # Widget-state triggers + # + # Frontend -> Backend. One trigger per server-tracked toggle. Each + # carries the absolute target value, not a delta, so a redundant + # message is indistinguishable from a no-op one, and both are fine. + # No ``origin`` field: unlike the camera, a toggle echo is idempotent. + # + # ``set_projection`` lives here too: it is delivered the same way, but + # it writes the camera record's projection field rather than a toggle + # of its own. + # + # The last four carry UI panel layout, not widget state: no renderer + # depends on them, so each coordinator just writes one store field. + # Echoes are idempotent for the same reason as the toggles'. + # ------------------------------------------------------------------ + + @trigger("set_cross_section_visibility") + @parse_payload(SetCrossSectionVisibilityPayload) + def set_cross_section_visibility(self, payload) -> None: + """Frontend -> Backend: show or hide the cross-section plane.""" + api = self._mutation_api("set_cross_section_visibility", payload) + if api is None: + return + api.set_cross_section_visibility(payload.visible) + + @trigger("set_edges_visible") + @parse_payload(SetEdgesVisiblePayload) + def set_edges_visible(self, payload) -> None: + """Frontend -> Backend: show or hide edges on every part.""" + api = self._mutation_api("set_edges_visible", payload) + if api is None: + return + api.set_edges_visible(payload.visible) + + @trigger("set_bounding_box_visibility") + @parse_payload(SetBoundingBoxVisibilityPayload) + def set_bounding_box_visibility(self, payload) -> None: + """Frontend -> Backend: show or hide the bounding-box outline.""" + api = self._mutation_api("set_bounding_box_visibility", payload) + if api is None: + return + api.set_bounding_box_visibility(payload.visible) + + @trigger("set_projection") + @parse_payload(SetProjectionPayload) + def set_projection(self, payload) -> None: + """Frontend -> Backend: set parallel or perspective projection. + + The projection is the camera record's field, not a toggle of its + own: the coordinator writes the record and re-serialises the + camera in one critical section. + """ + api = self._mutation_api("set_projection", payload) + if api is None: + return + api.set_projection(payload.parallel) + + @trigger("sync_cross_section_plane") + @parse_payload(SyncCrossSectionPlanePayload) + def sync_cross_section_plane(self, payload) -> None: + """Frontend -> Backend: a settled drag reports the cross-section plane. + + Not a toggle: it carries the origin and the normal the widget settled + on, both required and both exactly three components. A half-plane -- + a missing vector, or one of the wrong length -- fails validation and + is a logged no-op at this boundary rather than a half-applied plane, + which is ``sync_camera``'s stated posture for the same reason. + + The coordinator writes the record and both server VTK objects and + re-serialises them in one critical section; nothing is pushed from + here. + """ + api = self._mutation_api("sync_cross_section_plane", payload) + if api is None: + return + api.sync_cross_section_plane(payload.origin, payload.normal) + + @trigger("set_panel_top_left_panel_collapsed") + @parse_payload(SetPanelTopLeftPanelCollapsedPayload) + def set_panel_top_left_panel_collapsed(self, payload) -> None: + """Frontend -> Backend: the top-left panel reports its collapsed state.""" + api = self._mutation_api("set_panel_top_left_panel_collapsed", payload) + if api is None: + return + api.set_panel_top_left_panel_collapsed(payload.collapsed) + + @trigger("set_panel_top_right_panel_collapsed") + @parse_payload(SetPanelTopRightPanelCollapsedPayload) + def set_panel_top_right_panel_collapsed(self, payload) -> None: + """Frontend -> Backend: the top-right panel reports its collapsed state.""" + api = self._mutation_api("set_panel_top_right_panel_collapsed", payload) + if api is None: + return + api.set_panel_top_right_panel_collapsed(payload.collapsed) + + @trigger("set_panel_top_right_legend_collapsed") + @parse_payload(SetPanelTopRightLegendCollapsedPayload) + def set_panel_top_right_legend_collapsed(self, payload) -> None: + """Frontend -> Backend: the top-right legend reports its collapsed state.""" + api = self._mutation_api("set_panel_top_right_legend_collapsed", payload) + if api is None: + return + api.set_panel_top_right_legend_collapsed(payload.collapsed) + + @trigger("set_panel_top_right_tab_index") + @parse_payload(SetPanelTopRightTabIndexPayload) + def set_panel_top_right_tab_index(self, payload) -> None: + """Frontend -> Backend: the top-right panel reports its active tab. + + The index is forwarded verbatim and is never range-checked here; the + client's ``applyTabIndex`` is the guard, so a future third tab is a UI + change rather than a validation failure at this boundary. + """ + api = self._mutation_api("set_panel_top_right_tab_index", payload) + if api is None: + return + api.set_panel_top_right_tab_index(payload.tab_index) + + def set_only_cookie(self, key: str, value: str): + """ + Sets a cookie on the server. NOTE: there is a limitation + with Trame server whereby it only allows you to set a single cookie header. + """ + key = urllib.parse.quote(key) + value = urllib.parse.quote(value) + self.server.http_headers.set_header("Set-Cookie", f"{key}={value};Path=/;") + diff --git a/tests/unit/app/test_local_app.py b/tests/unit/app/test_local_app.py index 7001f26e..27eefc02 100644 --- a/tests/unit/app/test_local_app.py +++ b/tests/unit/app/test_local_app.py @@ -92,6 +92,7 @@ def mock_server(): server.http_headers.set_header = MagicMock() server.name = "TestServer" server._www = None + server.state = {} return server @@ -250,7 +251,7 @@ def test_set_part_color_variable_non_member_association_is_a_logged_no_op( app, api, association ): """A non-member association warns and delegates nothing -- no upper-casing.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: app.set_part_color_variable( { "nodeId": 7, @@ -305,7 +306,7 @@ def test_trigger_returns_none(app, name): @pytest.mark.parametrize("name", TRIGGER_NAMES) def test_trigger_is_a_logged_no_op_when_no_coordinator_injected(app_without_api, name): """With nothing injected, each trigger logs and returns without raising.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert getattr(app_without_api, name)(PAYLOADS[name]) is None assert mock_logger.debug.call_count == 2 @@ -332,7 +333,7 @@ def test_missing_required_key_is_a_logged_no_op(app, api, name): """A payload short one required key delegates nothing and warns once.""" payload = {k: v for k, v in PAYLOADS[name].items() if k != MISSING_KEY[name]} - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert getattr(app, name)(payload) is None getattr(api, name).assert_not_called() @@ -341,7 +342,7 @@ def test_missing_required_key_is_a_logged_no_op(app, api, name): def test_wrong_typed_bool_value_is_a_logged_no_op(app, api): """2 is not a bool: pydantic accepts 0 and 1 only, so this fails.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert app.set_part_visibility({"nodeId": 7, "visible": 2}) is None api.set_part_visibility.assert_not_called() @@ -350,7 +351,7 @@ def test_wrong_typed_bool_value_is_a_logged_no_op(app, api): def test_wrong_typed_int_value_is_a_logged_no_op(app, api): """A non-numeric string is not coerced to int: 'x' fails nodeId.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert app.set_part_visibility({"nodeId": "x", "visible": False}) is None api.set_part_visibility.assert_not_called() @@ -365,7 +366,7 @@ def test_non_dict_payload_is_a_logged_no_op_not_a_type_error(app, api, payload): ``Model(**payload)``: the latter raises TypeError on a non-mapping, which would escape the guard and land on the trame daemon thread. """ - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert app.set_part_visibility(payload) is None api.set_part_visibility.assert_not_called() @@ -381,7 +382,7 @@ def test_unknown_extra_key_is_forwarded_not_rejected(app, api): def test_set_part_diffuse_color_absent_key_is_a_validation_failure(app, api): """An absent diffuseRgb is malformed -- distinct from an explicit null.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert app.set_part_diffuse_color({"nodeId": 7}) is None api.set_part_diffuse_color.assert_not_called() @@ -397,7 +398,7 @@ def test_set_part_diffuse_color_two_element_colour_is_a_logged_no_op(app, api): IndexError on the trame daemon thread. Rejecting it here means nothing is delegated, so nothing is written. """ - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert app.set_part_diffuse_color({"nodeId": 7, "diffuseRgb": [1.0, 0.0]}) is None api.set_part_diffuse_color.assert_not_called() @@ -406,7 +407,7 @@ def test_set_part_diffuse_color_two_element_colour_is_a_logged_no_op(app, api): def test_set_part_opacity_out_of_range_is_a_logged_no_op(app, api): """An opacity outside [0.0, 1.0] never reaches VTK to be clamped.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert app.set_part_opacity({"nodeId": 7, "opacity": 5.0}) is None api.set_part_opacity.assert_not_called() @@ -455,7 +456,7 @@ def test_the_registered_callable_still_accepts_a_raw_dict(app, api, mock_server) @pytest.mark.parametrize("name", TRIGGER_NAMES) def test_invalid_payload_logs_warning_and_not_debug(app, api, name): """The validation no-op is a warning and is not confused with the other.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert getattr(app, name)({"junk": True}) is None getattr(api, name).assert_not_called() @@ -466,7 +467,7 @@ def test_invalid_payload_logs_warning_and_not_debug(app, api, name): @pytest.mark.parametrize("name", TRIGGER_NAMES) def test_missing_coordinator_logs_debug_and_not_warning(app_without_api, name): """The not-injected no-op is a debug and is not confused with the other.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert getattr(app_without_api, name)(PAYLOADS[name]) is None assert mock_logger.debug.call_count == 2 @@ -502,7 +503,7 @@ def test_set_variable_range_registered_trigger_delegates_payload_values(app, api def test_set_variable_range_invalid_payload_is_a_logged_no_op(app, api): """A payload with no max delegates nothing and warns once.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as mock_logger: assert app.set_variable_range( {"variableId": "POINT::pressure::1", "component": 0, "min": 2.5} ) is None diff --git a/tests/unit/app/test_local_app_set_projection.py b/tests/unit/app/test_local_app_set_projection.py index 60df18b6..bf2af6b2 100644 --- a/tests/unit/app/test_local_app_set_projection.py +++ b/tests/unit/app/test_local_app_set_projection.py @@ -66,6 +66,7 @@ def mock_server(): server.http_headers.set_header = MagicMock() server.name = "TestServer" server._www = None + server.state = {} return server @@ -135,7 +136,7 @@ def test_set_projection_missing_parallel_is_a_logged_no_op(app, api): Asserted against the whole mock rather than one method name, so a leak under any other name still fails. """ - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app.set_projection({}) assert result is None @@ -150,7 +151,7 @@ def test_set_projection_non_mapping_payload_is_a_logged_no_op(app, api): ``model(**payload)`` precisely so that a payload which is not a mapping at all is caught by the same guard as a payload with a missing key. """ - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app.set_projection("parallel") assert result is None @@ -176,7 +177,7 @@ def test_set_projection_is_a_logged_no_op_when_no_coordinator_injected(app_witho copied from the neighbouring trigger modules, which assert ``1`` and are red at this increment's base commit for exactly that reason. """ - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app_without_api.set_projection({"parallel": True}) assert result is None diff --git a/tests/unit/app/test_local_app_sync_camera.py b/tests/unit/app/test_local_app_sync_camera.py index f5ca8eb2..61c8868d 100644 --- a/tests/unit/app/test_local_app_sync_camera.py +++ b/tests/unit/app/test_local_app_sync_camera.py @@ -85,6 +85,7 @@ def mock_server(): server.http_headers.set_header = MagicMock() server.name = "TestServer" server._www = None + server.state = {} return server @@ -158,7 +159,7 @@ def test_sync_camera_programmatic_delegates_nothing(app, api): def test_sync_camera_gesture_logs_one_debug_line_with_origin_and_position(app): """An applied arrival is one line, naming the origin and the position.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: app.sync_camera({"origin": "gesture", "camera": _camera()}) assert log.debug.call_count == 2 @@ -170,7 +171,7 @@ def test_sync_camera_gesture_logs_one_debug_line_with_origin_and_position(app): def test_sync_camera_programmatic_logs_one_debug_line_with_the_origin(app): """A dropped arrival is one line too, so drops are countable.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: app.sync_camera({"origin": "programmatic", "camera": _camera()}) assert log.debug.call_count == 1 @@ -187,7 +188,7 @@ def test_sync_camera_with_a_six_field_camera_is_a_logged_no_op(app, api): camera = _camera() del camera["parallelScale"] - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app.sync_camera({"origin": "gesture", "camera": camera}) assert result is None @@ -203,7 +204,7 @@ def test_sync_camera_with_an_unrecognised_origin_is_a_logged_no_op(app, api): programmatic report -- and a typo in the *other* direction would be indistinguishable from a genuine gesture. """ - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app.sync_camera({"origin": "Gesture", "camera": _camera()}) assert result is None @@ -250,7 +251,7 @@ def test_sync_camera_accepts_the_clients_twelve_field_snapshot(app, api): def test_sync_camera_is_a_logged_no_op_when_no_coordinator_injected(app_without_api): """With nothing injected the trigger logs and returns without raising.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app_without_api.sync_camera({"origin": "gesture", "camera": _camera()}) assert result is None diff --git a/tests/unit/app/test_local_app_sync_cross_section_plane.py b/tests/unit/app/test_local_app_sync_cross_section_plane.py index b977026b..38cfd2bc 100644 --- a/tests/unit/app/test_local_app_sync_cross_section_plane.py +++ b/tests/unit/app/test_local_app_sync_cross_section_plane.py @@ -62,6 +62,7 @@ def mock_server(): server.http_headers.set_header = MagicMock() server.name = "TestServer" server._www = None + server.state = {} return server diff --git a/tests/unit/app/test_local_app_ui_panel_triggers.py b/tests/unit/app/test_local_app_ui_panel_triggers.py index 1e5a800f..55b5e0e9 100644 --- a/tests/unit/app/test_local_app_ui_panel_triggers.py +++ b/tests/unit/app/test_local_app_ui_panel_triggers.py @@ -67,6 +67,7 @@ def mock_server(): server.http_headers.set_header = MagicMock() server.name = "TestServer" server._www = None + server.state = {} return server @@ -139,7 +140,7 @@ def test_set_panel_top_right_tab_index_missing_tab_index_is_a_logged_no_op(app, share one decorator, and reverting ``@parse_payload`` on this one is the case this test exists to report. """ - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app.set_panel_top_right_tab_index({}) assert result is None diff --git a/tests/unit/app/test_local_app_widget_triggers.py b/tests/unit/app/test_local_app_widget_triggers.py index b7a89929..07fffb1d 100644 --- a/tests/unit/app/test_local_app_widget_triggers.py +++ b/tests/unit/app/test_local_app_widget_triggers.py @@ -62,6 +62,7 @@ def mock_server(): server.http_headers.set_header = MagicMock() server.name = "TestServer" server._www = None + server.state = {} return server @@ -129,7 +130,7 @@ def test_set_bounding_box_visibility_delegates_the_visible_value(app, api): def test_set_cross_section_visibility_missing_visible_is_a_logged_no_op(app, api): """An empty payload never reaches the handler body.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app.set_cross_section_visibility({}) assert result is None @@ -139,7 +140,7 @@ def test_set_cross_section_visibility_missing_visible_is_a_logged_no_op(app, api def test_set_edges_visible_missing_visible_is_a_logged_no_op(app, api): """An empty payload never reaches the handler body.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app.set_edges_visible({}) assert result is None @@ -149,7 +150,7 @@ def test_set_edges_visible_missing_visible_is_a_logged_no_op(app, api): def test_set_bounding_box_visibility_missing_visible_is_a_logged_no_op(app, api): """An empty payload never reaches the handler body.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app.set_bounding_box_visibility({}) assert result is None @@ -165,7 +166,7 @@ def test_set_edges_visible_non_mapping_payload_is_a_logged_no_op(app, api): same guard as a payload with a missing key. Asserted against the whole mock, so a leak under any other method name still fails. """ - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app.set_edges_visible("visible") assert result is None @@ -181,7 +182,7 @@ def test_set_cross_section_visibility_is_a_logged_no_op_when_no_coordinator_inje app_without_api, ): """With nothing injected the trigger logs and returns without raising.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app_without_api.set_cross_section_visibility({"visible": True}) assert result is None @@ -190,7 +191,7 @@ def test_set_cross_section_visibility_is_a_logged_no_op_when_no_coordinator_inje def test_set_edges_visible_is_a_logged_no_op_when_no_coordinator_injected(app_without_api): """With nothing injected the trigger logs and returns without raising.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app_without_api.set_edges_visible({"visible": True}) assert result is None @@ -201,7 +202,7 @@ def test_set_bounding_box_visibility_is_a_logged_no_op_when_no_coordinator_injec app_without_api, ): """With nothing injected the trigger logs and returns without raising.""" - with patch("ansys.visor.viewer.app.trame.local_app.logger") as log: + with patch("ansys.visor.viewer.app.trame.scene_trigger_app.logger") as log: result = app_without_api.set_bounding_box_visibility({"visible": True}) assert result is None diff --git a/tests/unit/app/test_scene_trigger_app.py b/tests/unit/app/test_scene_trigger_app.py new file mode 100644 index 00000000..0e2a1dcc --- /dev/null +++ b/tests/unit/app/test_scene_trigger_app.py @@ -0,0 +1,111 @@ +"""Unit tests for the shared trigger registration ``SceneTriggerApp`` moved into. + +Coverage targets +---------------- +1. ``LocalApp`` registers exactly the 21 trigger names it registered before + the move: the 18 shared ones defined on ``SceneTriggerApp`` plus its own + three local-only ones. Neither a missing nor a doubled registration + passes this. +2. ``LocalApp`` seeds ``rendering_mode`` into server state as ``"local"`` + before the server starts. + +The coordinator and payload behaviour of the 18 shared triggers is already +pinned, trigger by trigger, in ``test_local_app.py`` and its sibling modules; +this module does not restate that. +""" + +from unittest.mock import MagicMock + +import pytest + +from ansys.visor.viewer.app.trame.local_app import LocalApp + +# The exact 21 trigger names LocalApp registers: the 18 names shared with +# every rendering mode, plus LocalApp's own three local-only ones. Written +# out by hand, not derived from the production trigger list, so a missing or +# a doubled registration fails this rather than following along. +LOCAL_APP_TRIGGER_NAMES = sorted( + [ + "get_visor_scene_details_json", + "set_part_visibility", + "set_part_opacity", + "set_part_diffuse_color", + "set_part_selected", + "set_part_color_variable", + "clear_part_color_variable", + "set_variable_range", + "sync_camera", + "set_cross_section_visibility", + "set_edges_visible", + "set_bounding_box_visibility", + "set_projection", + "sync_cross_section_plane", + "set_panel_top_left_panel_collapsed", + "set_panel_top_right_panel_collapsed", + "set_panel_top_right_legend_collapsed", + "set_panel_top_right_tab_index", + "pick_geometry", + "perf_report_wasm", + "perf_report_server_update", + ] +) + + +class MockController: + """Minimal trame controller stand-in that records added handlers.""" + + def __init__(self): + self.handlers = {} + self.add_call_count = 0 + + def add(self, event): + self.add_call_count += 1 + + def decorator(fn): + self.handlers[event] = fn + return fn + + return decorator + + +@pytest.fixture +def mock_server(): + """Provide a mock Trame server with real dict-backed state.""" + server = MagicMock() + server.controller = MockController() + server.http_headers.set_header = MagicMock() + server.name = "TestServer" + server._www = None + server.state = {} + return server + + +def test_local_app_registers_exactly_these_triggers(mock_server): + """The registered trigger names are exactly the hand-written 21, sorted. + + A missing registration shortens the list; a doubled one (``@TrameApp()`` + on both the base and the leaf) duplicates a name in + ``mock_server.trigger.call_args_list``. ``sorted`` on the actual names + makes either failure visible against the hand-written literal. + """ + LocalApp( + server=mock_server, + get_scene_details_json=MagicMock(), + standalone=True, + ) + + names = sorted(call.args[0] for call in mock_server.trigger.call_args_list) + + assert names == LOCAL_APP_TRIGGER_NAMES + + +def test_local_app_seeds_rendering_mode_local(mock_server): + """``rendering_mode`` is written into state as ``"local"`` before start.""" + LocalApp( + server=mock_server, + get_scene_details_json=MagicMock(), + standalone=True, + ) + + assert mock_server.state["rendering_mode"] == "local" + diff --git a/tests/unit/app/test_visor_trame_app.py b/tests/unit/app/test_visor_trame_app.py index a8ce033f..99f5a84e 100644 --- a/tests/unit/app/test_visor_trame_app.py +++ b/tests/unit/app/test_visor_trame_app.py @@ -24,6 +24,7 @@ def mock_server(): server.http_headers.set_header = MagicMock() server.name = "TestServer" server._www = None + server.state = {} return server @@ -41,7 +42,7 @@ def mock_get_scene_details_json(): return MagicMock(return_value={"scene": "details"}) -@patch("ansys.visor.viewer.app.trame.local_app.settings") +@patch("ansys.visor.viewer.app.trame.scene_trigger_app.settings") def test_init_sets_attributes( mock_settings, mock_server, @@ -58,7 +59,7 @@ def test_init_sets_attributes( ) assert app.server == mock_server assert app._get_scene_details_json == mock_get_scene_details_json - assert app._LocalApp__trame_logger == mock_logger + assert app._SceneTriggerApp__trame_logger == mock_logger def test_register_lifecycle_hooks_adds_handlers( @@ -90,7 +91,7 @@ def test_set_headers_sets_http_headers( ) -@patch("ansys.visor.viewer.app.trame.local_app.settings") +@patch("ansys.visor.viewer.app.trame.scene_trigger_app.settings") def test_set_www_path_sets_www( mock_settings, mock_server, @@ -119,7 +120,7 @@ def test_get_visor_scene_details_json_returns_scene_details( assert result == {"scene": "details"} -@patch("ansys.visor.viewer.app.trame.local_app.settings") +@patch("ansys.visor.viewer.app.trame.scene_trigger_app.settings") def test_trame_logger_is_set_when_provided( mock_settings, mock_server, @@ -134,10 +135,10 @@ def test_trame_logger_is_set_when_provided( standalone=True, trame_logger=mock_logger, ) - assert app._LocalApp__trame_logger == mock_logger + assert app._SceneTriggerApp__trame_logger == mock_logger -@patch("ansys.visor.viewer.app.trame.local_app.settings") +@patch("ansys.visor.viewer.app.trame.scene_trigger_app.settings") def test_trame_logger_is_none_when_not_provided( mock_settings, mock_server, @@ -150,10 +151,10 @@ def test_trame_logger_is_none_when_not_provided( get_scene_details_json=mock_get_scene_details_json, standalone=True, ) - assert app._LocalApp__trame_logger is None + assert app._SceneTriggerApp__trame_logger is None -@patch("ansys.visor.viewer.app.trame.local_app.settings") +@patch("ansys.visor.viewer.app.trame.scene_trigger_app.settings") def test_lifecycle_logging_calls_debug( mock_settings, mock_server, From 72df73d06a042032038b68fc367f550acb6e9129 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 20 Oct 2026 14:03:44 -0700 Subject: [PATCH 2/8] feat: add server scene, with background color per theme --- .../visor/viewer/renderer/server_renderer.py | 8 + src/ansys/visor/viewer/vtk/scene/base.py | 8 +- .../visor/viewer/vtk/scene/server_scene.py | 80 +++++++++ tests/helpers/utils/locks.py | 29 ++++ tests/unit/renderer/test_server_renderer.py | 10 ++ tests/unit/vtk/scene/test_base.py | 31 +--- tests/unit/vtk/scene/test_server_scene.py | 153 ++++++++++++++++++ 7 files changed, 286 insertions(+), 33 deletions(-) create mode 100644 src/ansys/visor/viewer/vtk/scene/server_scene.py create mode 100644 tests/unit/vtk/scene/test_server_scene.py diff --git a/src/ansys/visor/viewer/renderer/server_renderer.py b/src/ansys/visor/viewer/renderer/server_renderer.py index 7ea0b1c0..4a343605 100644 --- a/src/ansys/visor/viewer/renderer/server_renderer.py +++ b/src/ansys/visor/viewer/renderer/server_renderer.py @@ -67,6 +67,14 @@ def set_viewport_size(self, width: int, height: int) -> None: """ self._render_window_interactor.UpdateSize(width, height) + def set_background_rgb(self, background_rgb: tuple[float, float, float]) -> None: + """Clear to this colour from the next frame. + + Requests no frame: the caller requests one once its other writes are done. + """ + self._background_rgb = background_rgb + self._vtk_renderer.SetBackground(background_rgb) + # ------------------------------------------------------------------ # IRenderer: wire contract # ------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 492f6836..eb3ea190 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -83,8 +83,8 @@ class VisorSceneBase(ABC): in *state delivery* between rendering backends: * :meth:`_push_runtime_state` — wasm path calls a JS - ``set_state``; RCA path pushes camera onto ``vtkCamera``; headless - is a no-op. + ``set_state``; server-rendering path re-colours the background from + the restored theme and requests a frame; headless is a no-op. The saved state is built here, from the server's own records, for every backend; no backend asks a client for it. @@ -197,7 +197,9 @@ def _push_runtime_state(self, runtime_app_state: "RuntimeAppState") -> None: shared per-part state has already been restored. * Wasm path: ``render_window_only()`` + JS ``set_state`` call. - * RCA path: push camera onto ``vtkCamera`` + ``render_window_only()``. + * Server-rendering path: re-colour the background from the restored + theme, then request a frame. The camera is already projected by + the view restore that precedes this hook. * Headless path: no-op. """ diff --git a/src/ansys/visor/viewer/vtk/scene/server_scene.py b/src/ansys/visor/viewer/vtk/scene/server_scene.py new file mode 100644 index 00000000..5858bf06 --- /dev/null +++ b/src/ansys/visor/viewer/vtk/scene/server_scene.py @@ -0,0 +1,80 @@ +""" +VisorServerScene +================ +:class:`VisorSceneBase` for a scene rendered on the server and streamed to the +client through trame-rca. + +The streamed frames carry no transparency, so the window clears to the +client's viewer background for the current theme rather than leaving the +background to the page's CSS. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from trame_rca.rca import VtkRemoteControlledArea +from trame_server import Server + +from ansys.visor.viewer.renderer.server_renderer import ServerRenderer +from ansys.visor.viewer.renderer.server_window import VisorRcaWindow +from ansys.visor.viewer.vtk.scene.base import VisorSceneBase + +if TYPE_CHECKING: + from ansys.visor.viewer.app.trame.server_loop_bridge import ServerLoopBridge + from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState + +BACKGROUND_RGB: dict[bool, tuple[float, float, float]] = { + False: (247 / 255, 247 / 255, 247 / 255), + True: (54 / 255, 60 / 255, 67 / 255), +} +"""Viewer background per theme, keyed on dark. + +The values of ``--visor-viewer-background`` in the client's +``public/css/theme-light.css`` and ``public/css/theme-dark.css``. +""" + + +class VisorServerScene(VisorSceneBase): + """Coordinator for a viewer scene rendered on the server and streamed to the client.""" + + _renderer: ServerRenderer + _rca_window: VisorRcaWindow + + def __init__(self, server: Server, dark_mode: bool, bridge: ServerLoopBridge) -> None: + """Build the ServerRenderer and the RCA window over this scene's lock.""" + renderer = ServerRenderer(BACKGROUND_RGB[dark_mode], bridge) + super().__init__(server, dark_mode, renderer=renderer) + self._rca_window = VisorRcaWindow( + VtkRemoteControlledArea(renderer.render_window), + renderer, + self._vtk_lock, + bridge.retry_frame, + ) + + @property + def rca_window(self) -> VisorRcaWindow: + """The trame-rca area the app streams.""" + return self._rca_window + + def pick_part_at(self, x: int, y: int) -> int | None: + """Return the node under a render-window pixel, under the scene lock.""" + with self._vtk_lock: + return self._renderer.pick_part_at(x, y) + + def set_dark_mode(self, dark_mode: bool) -> None: + """Set the theme on the UI record and re-colour the background, under the scene lock; requests one frame.""" + with self._vtk_lock: + self.dark_mode = dark_mode + self._apply_background() + self._renderer.render() + + def _apply_background(self) -> None: + """Set the renderer background from the current theme; caller holds the lock.""" + self._renderer.set_background_rgb(BACKGROUND_RGB[self.dark_mode]) + + def _push_runtime_state(self, runtime_app_state: RuntimeAppState) -> None: + """Re-colour the background from the restored theme, then request a frame.""" + self._apply_background() + self._renderer.render() + diff --git a/tests/helpers/utils/locks.py b/tests/helpers/utils/locks.py index 14e5b824..95e2af4e 100644 --- a/tests/helpers/utils/locks.py +++ b/tests/helpers/utils/locks.py @@ -7,6 +7,35 @@ LOCK_TIMEOUT_S = 5.0 +class LockSpy: + """Re-entrant lock that records its own acquisition depth. + + Wraps a real RLock so nesting still behaves, and exposes ``depth`` so a + test can assert the lock was *held* at the moment some inner call ran, + rather than merely acquired at some point. + """ + + def __init__(self): + self._lock = threading.RLock() + self.enter_count = 0 + self.exit_count = 0 + self.depth = 0 + self.max_depth = 0 + + def __enter__(self): + self._lock.acquire() + self.enter_count += 1 + self.depth += 1 + self.max_depth = max(self.max_depth, self.depth) + return self + + def __exit__(self, exc_type, exc, tb): + self.depth -= 1 + self.exit_count += 1 + self._lock.release() + return False + + @contextmanager def lock_held_by_another_thread(lock) -> Iterator[None]: """Hold ``lock`` from a second thread for the body of the ``with`` block. diff --git a/tests/unit/renderer/test_server_renderer.py b/tests/unit/renderer/test_server_renderer.py index f25ba714..793ff124 100644 --- a/tests/unit/renderer/test_server_renderer.py +++ b/tests/unit/renderer/test_server_renderer.py @@ -160,6 +160,16 @@ def test_set_viewport_size_resizes_window(renderer): assert renderer.render_window.GetSize() == (64, 48) +def test_set_background_rgb_sets_vtk_background(renderer): + renderer.set_background_rgb((0.25, 0.5, 0.75)) + assert renderer._vtk_renderer.GetBackground() == (0.25, 0.5, 0.75) + + +def test_set_background_rgb_sets_background_rgb(renderer): + renderer.set_background_rgb((0.25, 0.5, 0.75)) + assert renderer.background_rgb == (0.25, 0.5, 0.75) + + def test_cross_section_visibility_turns_widget_on(renderer): renderer.set_cross_section_visibility(True) assert renderer._cross_section_widget.is_on is True diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 3b2e91c9..5bd93c79 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -16,7 +16,6 @@ test_local_scene.py, exercised through the concrete VisorLocalScene subclass. """ import json -import threading from unittest.mock import MagicMock, patch import pytest @@ -48,6 +47,7 @@ from ansys.visor.viewer.vtk.scene.base import VisorSceneBase from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables from ansys.visor.viewer.vtk.variables.visor_variables import VisorVariable +from tests.helpers.utils.locks import LockSpy as _LockSpy NODE_ID = 7 SECOND_NODE_ID = 8 @@ -85,35 +85,6 @@ def _push_runtime_state(self, runtime_app_state) -> None: return None -class _LockSpy: - """Re-entrant lock that records its own acquisition depth. - - Wraps a real RLock so nesting still behaves, and exposes ``depth`` so a - test can assert the lock was *held* at the moment some inner call ran, - rather than merely acquired at some point. - """ - - def __init__(self): - self._lock = threading.RLock() - self.enter_count = 0 - self.exit_count = 0 - self.depth = 0 - self.max_depth = 0 - - def __enter__(self): - self._lock.acquire() - self.enter_count += 1 - self.depth += 1 - self.max_depth = max(self.max_depth, self.depth) - return self - - def __exit__(self, exc_type, exc, tb): - self.depth -= 1 - self.exit_count += 1 - self._lock.release() - return False - - def _make_part_dataset(dataset_id: int, part_ids, part_states=None, part_variables=None): """Dataset stand-in with a real PartIndex.part_ids and a real state object. diff --git a/tests/unit/vtk/scene/test_server_scene.py b/tests/unit/vtk/scene/test_server_scene.py new file mode 100644 index 00000000..e516a974 --- /dev/null +++ b/tests/unit/vtk/scene/test_server_scene.py @@ -0,0 +1,153 @@ +""" +Unit tests for VisorServerScene. + +Each test builds a real VisorServerScene, so a real ServerRenderer and a real +trame-rca capture over its off-screen window, with a recording bridge in +place of the ServerLoopBridge. No test renders a frame. Expected colours +are the client CSS's 0-255 triples, written by hand; the VTK background is +read back into that form. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 +from ansys.visor.viewer.vtk.scene.server_scene import VisorServerScene +from tests.helpers.utils.locks import LockSpy, lock_held_by_another_thread + +LIGHT_CSS_RGB = (247, 247, 247) +DARK_CSS_RGB = (54, 60, 67) + + +class _RecordingBridge: + """Counts frame requests and retries.""" + + def __init__(self) -> None: + self.request_count = 0 + self.retry_count = 0 + + def request_frame(self) -> None: + self.request_count += 1 + + def retry_frame(self) -> None: + self.retry_count += 1 + + +def _css_rgb(rgb) -> tuple[int, int, int]: + """A 0-1 VTK colour as the 0-255 triple a CSS variable holds.""" + return tuple(round(c * 255) for c in rgb) + + +def _background(scene: VisorServerScene) -> tuple[int, int, int]: + return _css_rgb(scene._renderer._vtk_renderer.GetBackground()) + + +@pytest.fixture +def bridge() -> _RecordingBridge: + return _RecordingBridge() + + +@pytest.fixture +def scene(bridge) -> VisorServerScene: + """A light-theme scene with no datasets; bridge counts start at zero.""" + s = VisorServerScene(MagicMock(name="server"), False, bridge) + bridge.request_count = 0 + bridge.retry_count = 0 + return s + + +# --------------------------------------------------------------------------- +# Background follows the theme +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("dark_mode", "expected"), + [(False, LIGHT_CSS_RGB), (True, DARK_CSS_RGB)], + ids=["light", "dark"], +) +def test_initial_background_follows_theme(bridge, dark_mode, expected): + s = VisorServerScene(MagicMock(name="server"), dark_mode, bridge) + assert _background(s) == expected + + +def test_dark_theme_change_recolors_background(scene): + scene.apply_state(PersistedViewerStateV1.model_validate({"ui": {"darkTheme": True}, "scene": {}})) + assert _background(scene) == DARK_CSS_RGB + + +def test_push_runtime_state_requests_one_frame(scene, bridge): + scene._push_runtime_state(scene._current_runtime_state()) + assert bridge.request_count == 1 + + +# --------------------------------------------------------------------------- +# set_dark_mode: the theme change that does not go through apply_state +# --------------------------------------------------------------------------- + + +def test_set_dark_mode_recolors_without_apply_state(scene): + scene.set_dark_mode(True) + assert _background(scene) == DARK_CSS_RGB + + +def test_set_dark_mode_sets_dark_mode(scene): + scene.set_dark_mode(True) + assert scene.dark_mode is True + + +def test_set_dark_mode_requests_one_frame(scene, bridge): + scene.set_dark_mode(True) + assert bridge.request_count == 1 + + +def test_set_dark_mode_holds_lock(scene): + scene._vtk_lock = LockSpy() + depths = [] + set_background_rgb = scene._renderer.set_background_rgb + + def recording_set_background_rgb(background_rgb): + depths.append(scene._vtk_lock.depth) + set_background_rgb(background_rgb) + + scene._renderer.set_background_rgb = recording_set_background_rgb + scene.set_dark_mode(True) + assert depths == [1] + + +# --------------------------------------------------------------------------- +# Picking +# --------------------------------------------------------------------------- + + +def test_pick_part_at_holds_lock(scene): + scene._vtk_lock = LockSpy() + depths = [] + + def recording_pick_part_at(x, y): + depths.append(scene._vtk_lock.depth) + return None + + scene._renderer.pick_part_at = recording_pick_part_at + scene.pick_part_at(31, 17) + assert depths == [1] + + +def test_pick_part_at_returns_the_renderer_pick(scene): + scene._renderer.pick_part_at = lambda x, y: 42 if (x, y) == (31, 17) else None + assert scene.pick_part_at(31, 17) == 42 + + +# --------------------------------------------------------------------------- +# RCA window +# --------------------------------------------------------------------------- + + +def test_rca_window_wraps_scene_lock(scene, bridge): + with lock_held_by_another_thread(scene._vtk_lock): + _ = scene.rca_window.img_cols_rows + assert bridge.retry_count == 1 + From 3105dafd38f41752b20f1f173080058ccb7dab37 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 20 Oct 2026 14:47:43 -0700 Subject: [PATCH 3/8] feat: Add server mode: app, stream wiring and SERVER option --- src/ansys/visor/viewer/api/models.py | 4 +- src/ansys/visor/viewer/api/server.py | 4 +- src/ansys/visor/viewer/app/trame/local_app.py | 2 +- .../visor/viewer/app/trame/server_app.py | 112 +++++++++++ .../viewer/app/trame/trame_server_manager.py | 5 + src/ansys/visor/viewer/app/visor.py | 2 +- src/ansys/visor/viewer/app/visor_vtk.py | 8 +- src/ansys/visor/viewer/app/visor_vtk_local.py | 10 +- .../visor/viewer/app/visor_vtk_server.py | 48 +++++ src/ansys/visor/viewer/cli/parse_args.py | 2 +- src/ansys/visor/viewer/core/visor_enums.py | 10 +- .../visor/viewer/vtk/scene/local_scene.py | 2 +- tests/integration/test_server_stream.py | 48 +++++ tests/unit/api/test_visor_cache.py | 6 +- tests/unit/app/test_scene_trigger_app.py | 8 +- tests/unit/app/test_server_app.py | 182 ++++++++++++++++++ tests/unit/app/test_visor_vtk.py | 21 +- tests/unit/app/test_visor_vtk_server.py | 49 +++++ tests/unit/cli/test_apis.py | 8 +- tests/unit/cli/test_parse_args.py | 8 +- tests/unit/cli/test_visor_cli.py | 8 +- 21 files changed, 502 insertions(+), 45 deletions(-) create mode 100644 src/ansys/visor/viewer/app/trame/server_app.py create mode 100644 src/ansys/visor/viewer/app/visor_vtk_server.py create mode 100644 tests/integration/test_server_stream.py create mode 100644 tests/unit/app/test_server_app.py create mode 100644 tests/unit/app/test_visor_vtk_server.py diff --git a/src/ansys/visor/viewer/api/models.py b/src/ansys/visor/viewer/api/models.py index 24b48247..64ba2a20 100644 --- a/src/ansys/visor/viewer/api/models.py +++ b/src/ansys/visor/viewer/api/models.py @@ -28,12 +28,12 @@ class InitProps(BaseModel): ) standalone: bool | None = Field(None, description="Standalone", examples=[True]) dark_mode: bool | None = Field(None, description="Dark mode enabled", examples=[False]) - rendering_mode: RenderingMode | None = Field(None, description="Rendering mode (LOCAL)", examples=["LOCAL"]) + rendering_mode: RenderingMode | None = Field(None, description="Rendering mode (CLIENT or SERVER)", examples=["CLIENT"]) @field_validator("rendering_mode", mode="before") @classmethod def coerce_rendering_mode(cls, v): - """Accept both the enum member and its name as a string (e.g. 'LOCAL', 'REMOTE').""" + """Accept both the enum member and its name as a string (e.g. 'CLIENT', 'SERVER').""" if v is None or isinstance(v, RenderingMode): return v if isinstance(v, str): diff --git a/src/ansys/visor/viewer/api/server.py b/src/ansys/visor/viewer/api/server.py index fc859ea6..c2caa4ca 100644 --- a/src/ansys/visor/viewer/api/server.py +++ b/src/ansys/visor/viewer/api/server.py @@ -205,7 +205,7 @@ async def connect_or_initialize_server(self, init_props: InitProps = Body( dark_mode = init_props.dark_mode if init_props.dark_mode is not None\ else settings.default_dark_mode rendering_mode = init_props.rendering_mode if init_props.rendering_mode is not None\ - else RenderingMode.LOCAL + else RenderingMode.CLIENT # initialize an instance of Visor self.visualizer, warnings = self._connect_or_initialize_visualizer( host, @@ -416,7 +416,7 @@ def _connect_or_initialize_visualizer( port: int, standalone: bool, dark_mode: bool, - rendering_mode: RenderingMode = RenderingMode.LOCAL, + rendering_mode: RenderingMode = RenderingMode.CLIENT, ) -> Tuple[Visor, str]: """Connect to a visualizer instance with the given host and port if one exists; otherwise, create a new visualizer instance on that host and port.""" diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index 34fa86ea..aefe75da 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -52,7 +52,7 @@ def __init__( @property def rendering_mode(self) -> RenderingMode: """This app serves client-side rendering via VTK-WASM in the browser.""" - return RenderingMode.LOCAL + return RenderingMode.CLIENT @trigger("pick_geometry") def pick_geometry(self, actor_wasm_id, cell_id, mode: str, world_x: float, world_y: float, world_z: float): diff --git a/src/ansys/visor/viewer/app/trame/server_app.py b/src/ansys/visor/viewer/app/trame/server_app.py new file mode 100644 index 00000000..16ef05b3 --- /dev/null +++ b/src/ansys/visor/viewer/app/trame/server_app.py @@ -0,0 +1,112 @@ +""" +ServerApp +========= +Trame app for server-side rendering: the shared scene triggers, part picking, +the client theme report, and the trame-rca area the rendered frames stream +through. +""" + +from __future__ import annotations + +from logging import Logger +from typing import Callable + +from pydantic import BaseModel +from trame.app.core import Server +from trame.decorators import TrameApp, trigger +from trame_rca.widgets.rca import RemoteControlledArea + +from ansys.visor.viewer.app.trame.scene_trigger_app import ( + SceneMutationApi, + SceneTriggerApp, + parse_payload, +) +from ansys.visor.viewer.app.trame.server_loop_bridge import ServerLoopBridge +from ansys.visor.viewer.core.visor_enums import RenderingMode +from ansys.visor.viewer.renderer.server_window import VisorRcaWindow + +RCA_AREA_NAME: str = "view" +"""Name of the trame-rca area the client subscribes to.""" + + +class PickPartAtPayload(BaseModel): + """Payload of the pick_part_at trigger: render-window device pixels, origin bottom-left.""" + + x: int + y: int + + +class SetDarkThemePayload(BaseModel): + """Payload of the set_dark_theme trigger.""" + + dark: bool + + +@TrameApp() +class ServerApp(SceneTriggerApp): + """Serves the scene triggers, part picking and the streamed view for server-side rendering.""" + + def __init__( + self, + server: Server, + get_scene_details_json: Callable[[], str], + standalone: bool, + trame_logger: Logger | None, + scene_mutation_api: SceneMutationApi, + pick_part_at: Callable[[int, int], int | None], + set_dark_mode: Callable[[bool], None], + rca_window: VisorRcaWindow, + bridge: ServerLoopBridge, + ) -> None: + """Create the area before start; the view handler is built when the server is ready.""" + self._pick_part_at = pick_part_at + self._set_dark_mode = set_dark_mode + self._rca_window = rca_window + self._bridge = bridge + super().__init__( + server=server, + get_scene_details_json=get_scene_details_json, + standalone=standalone, + trame_logger=trame_logger, + scene_mutation_api=scene_mutation_api, + ) + # The server is passed explicitly: a widget left to resolve its own + # would register the stream protocol on the default server. + self._rca_area = RemoteControlledArea(name=RCA_AREA_NAME, trame_server=server) + + @self.server.controller.add("on_server_ready") + def bind_stream(**_): + self._bind_stream() + + @property + def rendering_mode(self) -> RenderingMode: + """RenderingMode.SERVER.""" + return RenderingMode.SERVER + + def _bind_stream(self) -> None: + """Build the view handler over the RCA window and bind the bridge to it; loop thread, server ready. + + The encoder is named because trame-rca ignores the fps and quality + arguments without one. + """ + view_handler = self._rca_area.create_view_handler( + self._rca_window, + encoder="jpeg", + target_fps=30.0, + interactive_quality=60, + still_quality=90, + ) + self._bridge.bind(view_handler) + + @trigger("pick_part_at") + @parse_payload(PickPartAtPayload) + def pick_part_at(self, payload) -> int | None: + """Frontend -> Backend: the node under a render-window pixel, or None; selects nothing.""" + return self._pick_part_at(payload.x, payload.y) + + @trigger("set_dark_theme") + @parse_payload(SetDarkThemePayload) + def set_dark_theme(self, payload) -> None: + """Frontend -> Backend: the theme the client shows; the streamed background follows it.""" + self._set_dark_mode(payload.dark) + diff --git a/src/ansys/visor/viewer/app/trame/trame_server_manager.py b/src/ansys/visor/viewer/app/trame/trame_server_manager.py index ee942c74..19323a95 100644 --- a/src/ansys/visor/viewer/app/trame/trame_server_manager.py +++ b/src/ansys/visor/viewer/app/trame/trame_server_manager.py @@ -66,6 +66,11 @@ def server(self) -> Server: """Return the Trame server instance.""" return self._server + @property + def async_runner(self) -> AsyncRunner: + """The runner whose loop thread runs the trame server.""" + return self._async_runner + @property def state(self): """ diff --git a/src/ansys/visor/viewer/app/visor.py b/src/ansys/visor/viewer/app/visor.py index 89a400fc..35e8eea4 100644 --- a/src/ansys/visor/viewer/app/visor.py +++ b/src/ansys/visor/viewer/app/visor.py @@ -37,7 +37,7 @@ class Visor: """ def __new__(cls, rendering_engine: RenderingEngine = RenderingEngine.VTK, - rendering_mode: RenderingMode = RenderingMode.LOCAL, + rendering_mode: RenderingMode = RenderingMode.CLIENT, url: str | None = None, standalone: bool = True, dark_mode: bool | None = None, diff --git a/src/ansys/visor/viewer/app/visor_vtk.py b/src/ansys/visor/viewer/app/visor_vtk.py index cacc1efa..dbee29f0 100644 --- a/src/ansys/visor/viewer/app/visor_vtk.py +++ b/src/ansys/visor/viewer/app/visor_vtk.py @@ -120,7 +120,7 @@ class VisorVTK(Visor): def __new__( cls, - rendering_mode: RenderingMode = RenderingMode.LOCAL, + rendering_mode: RenderingMode = RenderingMode.CLIENT, **kwargs, ) -> "VisorVTK": # If we are already constructing a concrete subclass, skip dispatch. @@ -130,9 +130,11 @@ def __new__( rendering_mode = RenderingMode.from_string(rendering_mode) from ansys.visor.viewer.app.visor_vtk_local import VisorVTKLocal + from ansys.visor.viewer.app.visor_vtk_server import VisorVTKServer mode_map: dict[RenderingMode, type] = { - RenderingMode.LOCAL: VisorVTKLocal, + RenderingMode.CLIENT: VisorVTKLocal, + RenderingMode.SERVER: VisorVTKServer, } subclass = mode_map.get(rendering_mode) @@ -148,7 +150,7 @@ def __new__( def __init__( self, url: str | None = None, - rendering_mode: RenderingMode = RenderingMode.LOCAL, + rendering_mode: RenderingMode = RenderingMode.CLIENT, standalone: bool = True, dark_mode: bool | None = None, trame_log_dir: str | None = None, diff --git a/src/ansys/visor/viewer/app/visor_vtk_local.py b/src/ansys/visor/viewer/app/visor_vtk_local.py index 6887ffe5..465470cd 100644 --- a/src/ansys/visor/viewer/app/visor_vtk_local.py +++ b/src/ansys/visor/viewer/app/visor_vtk_local.py @@ -2,14 +2,14 @@ VisorVTKLocal ============= A :class:`~ansys.visor.viewer.app.visor_vtk.VisorVTK` subclass selected when -``rendering_mode=RenderingMode.LOCAL``. +``rendering_mode=RenderingMode.CLIENT``. This implementation uses **VTK-WASM** (via trame-vtklocal / LocalView) for client-side rendering in the browser. All lifecycle logic (start/stop, add/remove dataset, save/load state, …) is inherited from :class:`~ansys.visor.viewer.app.visor_vtk.VisorVTK`. -This class only wires the LOCAL-specific scene and trame app. +This class only wires the CLIENT-specific scene and trame app. """ from __future__ import annotations @@ -25,16 +25,16 @@ class VisorVTKLocal(VisorVTK): """ - Visor interface for VTK LOCAL mode: browser-side rendering via VTK-WASM. + Visor interface for VTK CLIENT mode: browser-side rendering via VTK-WASM. All public API is inherited from :class:`VisorVTK`. Only the rendering wiring (scene, trame app) is specific to this class. - Use ``Visor(rendering_mode=RenderingMode.LOCAL)`` to obtain an instance. + Use ``Visor(rendering_mode=RenderingMode.CLIENT)`` to obtain an instance. """ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) -> None: - """Wire up the LOCAL scene, and LocalApp.""" + """Wire up the CLIENT scene, and LocalApp.""" dark_mode = self._dark_mode if self._dark_mode is not None else settings.default_dark_mode self._scene = VisorLocalScene(self.server, dark_mode=dark_mode) diff --git a/src/ansys/visor/viewer/app/visor_vtk_server.py b/src/ansys/visor/viewer/app/visor_vtk_server.py new file mode 100644 index 00000000..8ada012e --- /dev/null +++ b/src/ansys/visor/viewer/app/visor_vtk_server.py @@ -0,0 +1,48 @@ +""" +VisorVTKServer +============== +A :class:`~ansys.visor.viewer.app.visor_vtk.VisorVTK` subclass selected when +``rendering_mode=RenderingMode.SERVER``. + +The scene is rendered off-screen on the server and streamed to the browser +through trame-rca. + +All lifecycle logic is inherited from +:class:`~ansys.visor.viewer.app.visor_vtk.VisorVTK`. This class only wires +the SERVER-specific bridge, scene and trame app. +""" + +from __future__ import annotations + +from ansys.visor.viewer.app.trame.server_app import ServerApp +from ansys.visor.viewer.app.trame.server_loop_bridge import ServerLoopBridge +from ansys.visor.viewer.app.visor_vtk import VisorVTK +from ansys.visor.viewer.core.visor_logging import get_trame_logger +from ansys.visor.viewer.vtk.scene.server_scene import VisorServerScene + + +class VisorVTKServer(VisorVTK): + """Visor interface for VTK SERVER mode: server-side off-screen rendering streamed through trame-rca.""" + + def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) -> None: + """Wire the bridge, 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. + """ + bridge = ServerLoopBridge(self._server_manager.async_runner) + + self._scene = VisorServerScene(self.server, dark_mode=self._dark_mode, bridge=bridge) + + self._trame_app = ServerApp( + self.server, + lambda: self._scene.get_scene_details_json(), + standalone, + trame_logger=get_trame_logger(trame_log_dir), + scene_mutation_api=self._scene, + pick_part_at=self._scene.pick_part_at, + set_dark_mode=self._scene.set_dark_mode, + rca_window=self._scene.rca_window, + bridge=bridge, + ) + diff --git a/src/ansys/visor/viewer/cli/parse_args.py b/src/ansys/visor/viewer/cli/parse_args.py index 19b016b9..87624aa1 100644 --- a/src/ansys/visor/viewer/cli/parse_args.py +++ b/src/ansys/visor/viewer/cli/parse_args.py @@ -50,7 +50,7 @@ def parse_args(): help=( "Rendering mode to use. Choices: " + ", ".join(e.name for e in RenderingMode) - + ". Default: LOCAL. Use HEADLESS for headless/no-GPU mode." + + ". Default: CLIENT." ), ) init_parser.add_argument( diff --git a/src/ansys/visor/viewer/core/visor_enums.py b/src/ansys/visor/viewer/core/visor_enums.py index dea392a9..d5b52e89 100644 --- a/src/ansys/visor/viewer/core/visor_enums.py +++ b/src/ansys/visor/viewer/core/visor_enums.py @@ -43,9 +43,11 @@ class RenderingMode(Enum): How the chosen rendering engine is deployed. Attributes: - LOCAL: Client-side rendering via VTK-WASM in the browser. + CLIENT: Client-side rendering via VTK-WASM in the browser. + SERVER: Server-side off-screen rendering streamed to the browser through trame-rca. """ - LOCAL = "local" + CLIENT = "client" + SERVER = "server" @classmethod def from_string(cls, value: Union[str, "RenderingMode"]) -> "RenderingMode": @@ -54,8 +56,8 @@ def from_string(cls, value: Union[str, "RenderingMode"]) -> "RenderingMode": Examples -------- - >>> RenderingMode.from_string("local") - + >>> RenderingMode.from_string("client") + """ if isinstance(value, cls): return value diff --git a/src/ansys/visor/viewer/vtk/scene/local_scene.py b/src/ansys/visor/viewer/vtk/scene/local_scene.py index deacb7e7..c9b30146 100644 --- a/src/ansys/visor/viewer/vtk/scene/local_scene.py +++ b/src/ansys/visor/viewer/vtk/scene/local_scene.py @@ -25,7 +25,7 @@ class VisorLocalScene(VisorSceneBase): This is the scene used by :class:`~ansys.visor.viewer.app.visor_vtk_local.VisorVTKLocal` - (``RenderingMode.LOCAL``). + (``RenderingMode.CLIENT``). """ _renderer: "VisorLocalRenderer" diff --git a/tests/integration/test_server_stream.py b/tests/integration/test_server_stream.py new file mode 100644 index 00000000..c8f1ade0 --- /dev/null +++ b/tests/integration/test_server_stream.py @@ -0,0 +1,48 @@ +""" +Integration test: a server-rendering viewer pushes its first frame. + +Starts a real ``Visor(rendering_mode="server")`` with no client attached and +waits for trame-rca to hand a frame to ``StreamManager.push_content``. Needs +an OpenGL context; runs unmarked in the gates. +""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +from trame_rca.protocol import StreamManager + +from ansys.visor.viewer import Visor +from ansys.visor.viewer.core.net import find_unused_port + +FIRST_FRAME_TIMEOUT_S = 5.0 + + +def test_first_frame_is_pushed(): + calls: list[str] = [] + errors: list[BaseException] = [] + original = StreamManager.push_content + + def recording_push(self, area_name, metadata, content): + calls.append(area_name) + try: + return original(self, area_name, metadata, content) + except BaseException as exc: + errors.append(exc) + raise + + port = find_unused_port("localhost") + with patch.object(StreamManager, "push_content", recording_push): + vis = Visor(rendering_mode="server", url=f"http://localhost:{port}") + vis.start() + try: + deadline = time.monotonic() + FIRST_FRAME_TIMEOUT_S + while not calls and time.monotonic() < deadline: + time.sleep(0.05) + finally: + vis.stop() + + assert calls[:1] == ["view"] + assert errors == [] + diff --git a/tests/unit/api/test_visor_cache.py b/tests/unit/api/test_visor_cache.py index bfb52cc2..75a3a1e2 100644 --- a/tests/unit/api/test_visor_cache.py +++ b/tests/unit/api/test_visor_cache.py @@ -19,7 +19,7 @@ def test_get_instance_creates_new(monkeypatch): with patch("ansys.visor.viewer.api.visor_cache.Visor", return_value=mock_visor) as mock_cls: inst, warnings = VisorCache.get_instance( "url1", - rendering_mode=RenderingMode.LOCAL, + rendering_mode=RenderingMode.CLIENT, standalone=False, dark_mode=False, trame_log_dir="dir" @@ -29,7 +29,7 @@ def test_get_instance_creates_new(monkeypatch): assert "url1" in VisorCache._instances mock_cls.assert_called_once_with( url="url1", - rendering_mode=RenderingMode.LOCAL, + rendering_mode=RenderingMode.CLIENT, standalone=False, dark_mode=False, trame_log_dir="dir" @@ -46,7 +46,7 @@ def test_get_instance_returns_existing(monkeypatch): with patch("ansys.visor.viewer.api.visor_cache.Visor") as mock_cls: inst, warnings = VisorCache.get_instance( "url2", - rendering_mode=RenderingMode.LOCAL, + rendering_mode=RenderingMode.CLIENT, standalone=False, dark_mode=False ) diff --git a/tests/unit/app/test_scene_trigger_app.py b/tests/unit/app/test_scene_trigger_app.py index 0e2a1dcc..201a5d8d 100644 --- a/tests/unit/app/test_scene_trigger_app.py +++ b/tests/unit/app/test_scene_trigger_app.py @@ -6,7 +6,7 @@ the move: the 18 shared ones defined on ``SceneTriggerApp`` plus its own three local-only ones. Neither a missing nor a doubled registration passes this. -2. ``LocalApp`` seeds ``rendering_mode`` into server state as ``"local"`` +2. ``LocalApp`` seeds ``rendering_mode`` into server state as ``"client"`` before the server starts. The coordinator and payload behaviour of the 18 shared triggers is already @@ -99,13 +99,13 @@ def test_local_app_registers_exactly_these_triggers(mock_server): assert names == LOCAL_APP_TRIGGER_NAMES -def test_local_app_seeds_rendering_mode_local(mock_server): - """``rendering_mode`` is written into state as ``"local"`` before start.""" +def test_local_app_seeds_rendering_mode_client(mock_server): + """``rendering_mode`` is written into state as ``"client"`` before start.""" LocalApp( server=mock_server, get_scene_details_json=MagicMock(), standalone=True, ) - assert mock_server.state["rendering_mode"] == "local" + assert mock_server.state["rendering_mode"] == "client" diff --git a/tests/unit/app/test_server_app.py b/tests/unit/app/test_server_app.py new file mode 100644 index 00000000..ba027fd6 --- /dev/null +++ b/tests/unit/app/test_server_app.py @@ -0,0 +1,182 @@ +"""Unit tests for ``ServerApp``: its trigger set, its mode seed, its two +server-only triggers, and the stream binding made when the server is ready. + +The shared triggers' coordinator and payload behaviour is pinned in +``test_local_app.py`` and its sibling modules and is not restated here. +``RemoteControlledArea`` is replaced by a double, so nothing here builds a +render scheduler; the real stream is exercised in +``tests/integration/test_server_stream.py``. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from ansys.visor.viewer.app.trame.server_app import ServerApp + +# The exact 20 trigger names ServerApp registers: the 18 shared with every +# rendering mode, plus its own two. Written out by hand, not derived from the +# production trigger list. +SERVER_APP_TRIGGER_NAMES = sorted( + [ + "get_visor_scene_details_json", + "set_part_visibility", + "set_part_opacity", + "set_part_diffuse_color", + "set_part_selected", + "set_part_color_variable", + "clear_part_color_variable", + "set_variable_range", + "sync_camera", + "set_cross_section_visibility", + "set_edges_visible", + "set_bounding_box_visibility", + "set_projection", + "sync_cross_section_plane", + "set_panel_top_left_panel_collapsed", + "set_panel_top_right_panel_collapsed", + "set_panel_top_right_legend_collapsed", + "set_panel_top_right_tab_index", + "pick_part_at", + "set_dark_theme", + ] +) + + +class MockController: + """Trame controller stand-in that keeps every handler added per event.""" + + def __init__(self): + self.handlers = {} + + def add(self, event): + def decorator(fn): + self.handlers.setdefault(event, []).append(fn) + return fn + + return decorator + + +@pytest.fixture +def mock_server(): + """A mock trame server with real dict-backed state.""" + server = MagicMock() + server.controller = MockController() + server.http_headers.set_header = MagicMock() + server.name = "TestServer" + server._www = None + server.state = {} + return server + + +@pytest.fixture +def area_cls(): + """The RemoteControlledArea class as ServerApp sees it, replaced by a double.""" + with patch("ansys.visor.viewer.app.trame.server_app.RemoteControlledArea") as cls: + yield cls + + +class _Recorder: + """A callable that records its arguments and returns a fixed value.""" + + def __init__(self, result=None): + self.calls = [] + self._result = result + + def __call__(self, *args): + self.calls.append(args) + return self._result + + +def _build(mock_server, pick_part_at=None, set_dark_mode=None, rca_window=None, bridge=None): + return ServerApp( + server=mock_server, + get_scene_details_json=MagicMock(), + standalone=True, + trame_logger=None, + scene_mutation_api=MagicMock(), + pick_part_at=pick_part_at if pick_part_at is not None else _Recorder(), + set_dark_mode=set_dark_mode if set_dark_mode is not None else _Recorder(), + rca_window=rca_window if rca_window is not None else MagicMock(name="rca_window"), + bridge=bridge if bridge is not None else MagicMock(name="bridge"), + ) + + +def test_server_app_registers_exactly_these_triggers(mock_server, area_cls): + """The registered trigger names are exactly the hand-written 20, sorted.""" + _build(mock_server) + + names = sorted(call.args[0] for call in mock_server.trigger.call_args_list) + + assert names == SERVER_APP_TRIGGER_NAMES + + +def test_server_app_seeds_rendering_mode_server(mock_server, area_cls): + """``rendering_mode`` is written into state as ``"server"`` before start.""" + _build(mock_server) + + assert mock_server.state["rendering_mode"] == "server" + + +def test_pick_part_at_forwards_and_returns(mock_server, area_cls): + """The pixel reaches the injected pick, and its node id is the trigger's return.""" + pick = _Recorder(result=7) + app = _build(mock_server, pick_part_at=pick) + + result = app.pick_part_at({"x": 3, "y": 4}) + + assert result == 7 + assert pick.calls == [(3, 4)] + + +def test_pick_part_at_rejects_bad_payload(mock_server, area_cls): + """A payload missing a coordinate is a logged no-op: nothing is picked.""" + pick = _Recorder(result=7) + app = _build(mock_server, pick_part_at=pick) + + result = app.pick_part_at({"x": 1}) + + assert result is None + assert pick.calls == [] + + +def test_set_dark_theme_calls_set_dark_mode(mock_server, area_cls): + """The client's theme reaches the injected set_dark_mode.""" + set_dark_mode = _Recorder() + app = _build(mock_server, set_dark_mode=set_dark_mode) + + app.set_dark_theme({"dark": True}) + + assert set_dark_mode.calls == [(True,)] + + +def test_set_dark_theme_rejects_bad_payload(mock_server, area_cls): + """A payload without ``dark`` is a logged no-op: the theme is not changed.""" + set_dark_mode = _Recorder() + app = _build(mock_server, set_dark_mode=set_dark_mode) + + app.set_dark_theme({}) + + assert set_dark_mode.calls == [] + + +def test_server_ready_builds_handler_and_binds(mock_server, area_cls): + """When the server is ready, the view handler is built over the RCA window + with the JPEG encoder, and the bridge is bound to that handler.""" + rca_window = MagicMock(name="rca_window") + bridge = MagicMock(name="bridge") + _build(mock_server, rca_window=rca_window, bridge=bridge) + area = area_cls.return_value + + for handler in mock_server.controller.handlers["on_server_ready"]: + handler() + + area.create_view_handler.assert_called_once_with( + rca_window, + encoder="jpeg", + target_fps=30.0, + interactive_quality=60, + still_quality=90, + ) + bridge.bind.assert_called_once_with(area.create_view_handler.return_value) + diff --git a/tests/unit/app/test_visor_vtk.py b/tests/unit/app/test_visor_vtk.py index 8b4d74b7..089db47b 100644 --- a/tests/unit/app/test_visor_vtk.py +++ b/tests/unit/app/test_visor_vtk.py @@ -4,10 +4,14 @@ Tests for VisorVTK.__new__ dispatch and abstract-method contract. Lifecycle behaviour is covered by test_visor_vtk_local.py; nothing here -starts a trame server. Mocking boundary is identical to that file: +starts a trame server. Mocking boundary is identical to that file, plus the +server-mode wiring: - ansys.visor.viewer.app.visor_vtk.TrameServerManager - ansys.visor.viewer.app.visor_vtk_local.LocalApp - ansys.visor.viewer.app.visor_vtk_local.VisorLocalScene + - ansys.visor.viewer.app.visor_vtk_server.ServerApp + - ansys.visor.viewer.app.visor_vtk_server.VisorServerScene + - ansys.visor.viewer.app.visor_vtk_server.ServerLoopBridge """ from contextlib import contextmanager @@ -17,10 +21,12 @@ from ansys.visor.viewer.app.visor_vtk import VisorVTK from ansys.visor.viewer.app.visor_vtk_local import VisorVTKLocal +from ansys.visor.viewer.app.visor_vtk_server import VisorVTKServer from ansys.visor.viewer.core.visor_enums import RenderingMode MODE_TO_CLASS = [ - pytest.param(RenderingMode.LOCAL, VisorVTKLocal, id="LOCAL"), + pytest.param(RenderingMode.CLIENT, VisorVTKLocal, id="CLIENT"), + pytest.param(RenderingMode.SERVER, VisorVTKServer, id="SERVER"), ] @@ -29,7 +35,10 @@ def vtk_patches(): """Suppress all trame / VTK side-effects so no server is started.""" with patch("ansys.visor.viewer.app.visor_vtk.TrameServerManager") as mock_mgr, \ patch("ansys.visor.viewer.app.visor_vtk_local.LocalApp"), \ - patch("ansys.visor.viewer.app.visor_vtk_local.VisorLocalScene") as mock_scene: + patch("ansys.visor.viewer.app.visor_vtk_local.VisorLocalScene") as mock_scene, \ + patch("ansys.visor.viewer.app.visor_vtk_server.ServerApp"), \ + patch("ansys.visor.viewer.app.visor_vtk_server.VisorServerScene"), \ + patch("ansys.visor.viewer.app.visor_vtk_server.ServerLoopBridge"): mock_mgr_inst = MagicMock() mock_mgr_inst.running = False mock_scene_inst = MagicMock() @@ -57,11 +66,11 @@ def test_init_called_once_with_original_args(): """__init__ is invoked exactly once, forwarding the original call-site kwargs.""" with vtk_patches(): with patch.object(VisorVTK, "__init__", return_value=None) as mock_init: - VisorVTK(rendering_mode=RenderingMode.LOCAL, url="http://test", standalone=False) + VisorVTK(rendering_mode=RenderingMode.CLIENT, url="http://test", standalone=False) mock_init.assert_called_once() _, kwargs = mock_init.call_args - assert kwargs.get("rendering_mode") == RenderingMode.LOCAL + assert kwargs.get("rendering_mode") == RenderingMode.CLIENT assert kwargs.get("url") == "http://test" assert kwargs.get("standalone") is False @@ -89,7 +98,7 @@ def test_unrecognized_mode_raises_value_error(): def test_default_rendering_mode_dispatches_to_local(): - """VisorVTK() with no rendering_mode argument falls back to LOCAL.""" + """VisorVTK() with no rendering_mode argument falls back to CLIENT.""" with vtk_patches(): inst = VisorVTK() assert isinstance(inst, VisorVTKLocal) diff --git a/tests/unit/app/test_visor_vtk_server.py b/tests/unit/app/test_visor_vtk_server.py new file mode 100644 index 00000000..97f35088 --- /dev/null +++ b/tests/unit/app/test_visor_vtk_server.py @@ -0,0 +1,49 @@ +"""Unit tests for ``VisorVTKServer``'s wiring of the bridge, the server scene +and ``ServerApp``. + +Mocking boundary: ``visor_vtk.TrameServerManager``, +``visor_vtk_server.ServerApp``, ``visor_vtk_server.VisorServerScene`` and +``visor_vtk_server.ServerLoopBridge``. Nothing here starts a trame server. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from ansys.visor.viewer.app.visor_vtk_server import VisorVTKServer + + +@pytest.fixture +def wiring(): + """Build a VisorVTKServer against doubles; yield (app_cls, scene_cls, bridge_cls).""" + with patch("ansys.visor.viewer.app.visor_vtk.TrameServerManager") as mgr_cls, \ + patch("ansys.visor.viewer.app.visor_vtk_server.ServerApp") as app_cls, \ + patch("ansys.visor.viewer.app.visor_vtk_server.VisorServerScene") as scene_cls, \ + patch("ansys.visor.viewer.app.visor_vtk_server.ServerLoopBridge") as bridge_cls: + mgr = MagicMock() + mgr.running = False + mgr_cls.return_value = mgr + # A distinct bridge per construction, so two bridges cannot pass for one. + bridge_cls.side_effect = lambda *args, **kwargs: MagicMock(name="bridge") + VisorVTKServer() + yield app_cls, scene_cls, bridge_cls + + +def test_scene_and_app_share_one_bridge(wiring): + """The scene's renderer requests frames through the bridge the app binds.""" + app_cls, scene_cls, _bridge_cls = wiring + + assert scene_cls.call_args.kwargs["bridge"] is app_cls.call_args.kwargs["bridge"] + + +def test_app_receives_the_scene_members(wiring): + """The app is given the scene itself, its RCA window, and its pick and theme methods.""" + app_cls, scene_cls, _bridge_cls = wiring + scene = scene_cls.return_value + kwargs = app_cls.call_args.kwargs + + assert kwargs["scene_mutation_api"] is scene + assert kwargs["rca_window"] is scene.rca_window + assert kwargs["pick_part_at"] is scene.pick_part_at + assert kwargs["set_dark_mode"] is scene.set_dark_mode + diff --git a/tests/unit/cli/test_apis.py b/tests/unit/cli/test_apis.py index 5aa569dd..5521143c 100644 --- a/tests/unit/cli/test_apis.py +++ b/tests/unit/cli/test_apis.py @@ -46,7 +46,7 @@ def test_serverapi_initialize_prints_response(): api = ServerAPI("host", 1234) with patch("requests.post") as mock_post, patch("builtins.print") as mock_print: mock_post.return_value.json.return_value = {"result": "ok"} - api.initialize("h", 1, RenderingMode.LOCAL, True, dark_mode=False, start=False) + api.initialize("h", 1, RenderingMode.CLIENT, True, dark_mode=False, start=False) mock_post.assert_called_once() mock_print.assert_called_with({"result": "ok"}) @@ -55,10 +55,10 @@ def test_serverapi_initialize_with_start_also_starts_instance(): api = ServerAPI("host", 1234) with patch("requests.post") as mock_post, patch("builtins.print"): mock_post.return_value.json.return_value = {"result": "ok"} - api.initialize("h", 1, RenderingMode.LOCAL, True, dark_mode=False, start=True) + api.initialize("h", 1, RenderingMode.CLIENT, True, dark_mode=False, start=True) assert mock_post.call_count == 2 mock_post.assert_any_call(f"{api.base}/initialize", json={ - "host": "h", "port": 1, "rendering_mode": RenderingMode.LOCAL, + "host": "h", "port": 1, "rendering_mode": RenderingMode.CLIENT, "standalone": True, "dark_mode": False, }) mock_post.assert_any_call(f"{api.base}/start", json={}) @@ -73,7 +73,7 @@ def test_serverapi_initialize_with_start_raises_on_start_failure(): start_resp.raise_for_status.side_effect = requests.HTTPError("500") with patch("requests.post", side_effect=[init_resp, start_resp]), patch("builtins.print"): with pytest.raises(requests.HTTPError): - api.initialize("h", 1, RenderingMode.LOCAL, True, dark_mode=False, start=True) + api.initialize("h", 1, RenderingMode.CLIENT, True, dark_mode=False, start=True) init_resp.raise_for_status.assert_called_once() start_resp.raise_for_status.assert_called_once() diff --git a/tests/unit/cli/test_parse_args.py b/tests/unit/cli/test_parse_args.py index 6309f436..ecd74f83 100644 --- a/tests/unit/cli/test_parse_args.py +++ b/tests/unit/cli/test_parse_args.py @@ -191,7 +191,7 @@ def fail_if_called(*args, **kwargs): ) # Neither standalone nor dark_mode set -> should return cleanly and not call requests.get - check_init_args("api-host", 0, "host", 9000, RenderingMode.LOCAL, None, None) + check_init_args("api-host", 0, "host", 9000, RenderingMode.CLIENT, None, None) def test_flags_and_request_raises_warning(monkeypatch, capsys): """Verify that request failures emit a warning without exiting.""" @@ -203,7 +203,7 @@ def raise_exc(*args, **kwargs): ) # Should not raise SystemExit; should print a warning - check_init_args("api-host", 0, "host", 9000, RenderingMode.LOCAL, True, None) + check_init_args("api-host", 0, "host", 9000, RenderingMode.CLIENT, True, None) captured = capsys.readouterr() assert "Warning: could not check existing instances: boom" in captured.out @@ -222,7 +222,7 @@ def fake_get(url, timeout=5): ) with pytest.raises(SystemExit) as excinfo: - check_init_args("api-host", 0, "target-host", 1234, RenderingMode.LOCAL, True, None) + check_init_args("api-host", 0, "target-host", 1234, RenderingMode.CLIENT, True, None) # sys.exit(1) should produce SystemExit with code 1 assert excinfo.value.code == 1 @@ -244,4 +244,4 @@ def fake_get(url, timeout=5): ) # Should not raise - check_init_args("api-host", 0, "target-host", 1234, RenderingMode.LOCAL, True, False) + check_init_args("api-host", 0, "target-host", 1234, RenderingMode.CLIENT, True, False) diff --git a/tests/unit/cli/test_visor_cli.py b/tests/unit/cli/test_visor_cli.py index 9f3caaae..eeece361 100644 --- a/tests/unit/cli/test_visor_cli.py +++ b/tests/unit/cli/test_visor_cli.py @@ -97,7 +97,7 @@ def test_main_server_init(mock_parse_args, mock_server_api): """Verify that server initialization forwards the expected arguments.""" args = make_args( "server", "init", - host="h", port=42, rendering_mode=RenderingMode.LOCAL, + host="h", port=42, rendering_mode=RenderingMode.CLIENT, standalone=True, dark_mode=False, start=False, ) mock_parse_args.return_value = args @@ -105,13 +105,13 @@ def test_main_server_init(mock_parse_args, mock_server_api): mock_server_api.return_value = api with patch("ansys.visor.viewer.cli.visor_cli.check_server_running", return_value=True): visor_cli.main() - api.initialize.assert_called_once_with("h", 42, RenderingMode.LOCAL, True, False, False) + api.initialize.assert_called_once_with("h", 42, RenderingMode.CLIENT, True, False, False) def test_main_server_init_with_start(mock_parse_args, mock_server_api): """Verify that server initialization forwards the --start flag.""" args = make_args( "server", "init", - host="h", port=42, rendering_mode=RenderingMode.LOCAL, + host="h", port=42, rendering_mode=RenderingMode.CLIENT, standalone=True, dark_mode=False, start=True, ) mock_parse_args.return_value = args @@ -119,7 +119,7 @@ def test_main_server_init_with_start(mock_parse_args, mock_server_api): mock_server_api.return_value = api with patch("ansys.visor.viewer.cli.visor_cli.check_server_running", return_value=True): visor_cli.main() - api.initialize.assert_called_once_with("h", 42, RenderingMode.LOCAL, True, False, True) + api.initialize.assert_called_once_with("h", 42, RenderingMode.CLIENT, True, False, True) def test_main_instance_actions(mock_parse_args, mock_instance_api): """Verify that instance actions invoke the corresponding API methods.""" From 4cc8ef28d57541299c2763077ec321e94573b2fc Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 20 Oct 2026 15:48:55 -0700 Subject: [PATCH 4/8] feat: publish camera to client in server mode --- .../app/trame/server_state_publisher.py | 48 ++++++++ .../visor/viewer/app/visor_vtk_server.py | 10 +- .../visor/viewer/renderer/server_renderer.py | 22 +++- .../visor/viewer/vtk/scene/server_scene.py | 11 +- .../integration/test_server_window_render.py | 3 +- tests/unit/app/test_server_loop_bridge.py | 3 +- tests/unit/app/test_server_state_publisher.py | 115 ++++++++++++++++++ tests/unit/app/test_visor_vtk_server.py | 33 +++-- tests/unit/renderer/test_per_part_parity.py | 2 +- tests/unit/renderer/test_server_renderer.py | 65 ++++++++-- tests/unit/renderer/test_server_window.py | 4 +- tests/unit/vtk/scene/test_server_scene.py | 4 +- 12 files changed, 288 insertions(+), 32 deletions(-) create mode 100644 src/ansys/visor/viewer/app/trame/server_state_publisher.py create mode 100644 tests/unit/app/test_server_state_publisher.py diff --git a/src/ansys/visor/viewer/app/trame/server_state_publisher.py b/src/ansys/visor/viewer/app/trame/server_state_publisher.py new file mode 100644 index 00000000..e1c2e00b --- /dev/null +++ b/src/ansys/visor/viewer/app/trame/server_state_publisher.py @@ -0,0 +1,48 @@ +""" +ServerStatePublisher +==================== +Writes server-originated trame state keys from any thread onto the trame +event loop. + +A state write flushes through wslink's publish path, which schedules its send +on the calling thread's event loop; from a caller thread that loop never runs, +so every write is handed to the :class:`AsyncRunner` that runs the trame server. +""" + +from __future__ import annotations + +from trame_server.state import State + +from ansys.visor.viewer.app.trame.async_runner import AsyncRunner +from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState +from ansys.visor.viewer.renderer.server_renderer import CameraPublisher + +SERVER_CAMERA_STATE_KEY: str = "server_camera_state" +"""State key carrying the server's camera record, as its camelCase fields, or None.""" + + +class ServerStatePublisher(CameraPublisher): + """Writes server-originated state keys from any thread onto the trame event loop.""" + + _async_runner: AsyncRunner + _state: State + + def __init__(self, async_runner: AsyncRunner, state: State) -> None: + """Both required: the runner whose loop runs the trame server, and that server's state.""" + self._async_runner = async_runner + self._state = state + + def publish_camera(self, camera_state: VisorCameraState | None) -> None: + """Dump the record by alias on the calling thread, or None, then write server_camera_state on the loop thread. + + The dump is taken here, not on the loop: the record is mutated in + place by later camera writes, and the caller holds the scene lock. + """ + 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 _write(self, key: str, value: object) -> None: + """Set one state key and flush it; loop thread only.""" + self._state[key] = value + self._state.flush() + diff --git a/src/ansys/visor/viewer/app/visor_vtk_server.py b/src/ansys/visor/viewer/app/visor_vtk_server.py index 8ada012e..bdd93e5d 100644 --- a/src/ansys/visor/viewer/app/visor_vtk_server.py +++ b/src/ansys/visor/viewer/app/visor_vtk_server.py @@ -16,6 +16,7 @@ from ansys.visor.viewer.app.trame.server_app import ServerApp from ansys.visor.viewer.app.trame.server_loop_bridge import ServerLoopBridge +from ansys.visor.viewer.app.trame.server_state_publisher import ServerStatePublisher from ansys.visor.viewer.app.visor_vtk import VisorVTK from ansys.visor.viewer.core.visor_logging import get_trame_logger from ansys.visor.viewer.vtk.scene.server_scene import VisorServerScene @@ -25,14 +26,19 @@ class VisorVTKServer(VisorVTK): """Visor interface for VTK SERVER mode: server-side off-screen rendering streamed through trame-rca.""" def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) -> None: - """Wire the bridge, the server scene and ServerApp. + """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. """ bridge = ServerLoopBridge(self._server_manager.async_runner) + publisher = ServerStatePublisher(self._server_manager.async_runner, self.server.state) - self._scene = VisorServerScene(self.server, dark_mode=self._dark_mode, bridge=bridge) + self._scene = VisorServerScene( + self.server, dark_mode=self._dark_mode, bridge=bridge, camera_publisher=publisher + ) self._trame_app = ServerApp( self.server, diff --git a/src/ansys/visor/viewer/renderer/server_renderer.py b/src/ansys/visor/viewer/renderer/server_renderer.py index 4a343605..da7a2d13 100644 --- a/src/ansys/visor/viewer/renderer/server_renderer.py +++ b/src/ansys/visor/viewer/renderer/server_renderer.py @@ -6,6 +6,8 @@ Owns an off-screen VTK pipeline and presents each write by asking a :class:`FrameRequester` for a streamed frame. It never calls ``Render()`` itself: every render happens on the streaming path, under the scene lock. +Each camera write also hands the camera record to a :class:`CameraPublisher`, +since a client of a streamed view has no camera of its own to read. """ from __future__ import annotations @@ -18,6 +20,7 @@ from vtkmodules.vtkCommonCore import vtkObject from vtkmodules.vtkRenderingCore import vtkRenderWindow +from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.renderer.pipeline_renderer import VtkPipelineRenderer INITIAL_WINDOW_SIZE: tuple[int, int] = (300, 300) @@ -31,23 +34,33 @@ def request_frame(self) -> None: """Ask for a streamed frame; callable from any thread.""" +class CameraPublisher(Protocol): + """Where a ServerRenderer publishes its camera record.""" + + def publish_camera(self, camera_state: VisorCameraState | None) -> None: + """Publish the camera record to clients, or None when there is none; callable from any thread.""" + + class ServerRenderer(VtkPipelineRenderer): """Off-screen VTK pipeline whose writes are presented by requesting a streamed frame.""" _background_rgb: tuple[float, float, float] _frame_requester: FrameRequester + _camera_publisher: CameraPublisher _bounding_box_visible: bool def __init__( self, background_rgb: tuple[float, float, float], frame_requester: FrameRequester, + camera_publisher: CameraPublisher, ) -> None: - """Build the off-screen pipeline; every write ends in a frame request.""" - # Both are read during the base build: the background by - # _initialize_vtk_renderer, the requester by any publish hook. + """Build the off-screen pipeline; every write ends in a frame request, every camera write also publishes the record.""" + # All three are read during the base build: the background by + # _initialize_vtk_renderer, the requester and publisher by any publish hook. self._background_rgb = background_rgb self._frame_requester = frame_requester + self._camera_publisher = camera_publisher self._bounding_box_visible = False super().__init__() @@ -137,8 +150,9 @@ def _publish_part_mapper(self, node_id: int) -> None: self._frame_requester.request_frame() def _publish_camera(self) -> None: - """Request a frame from the written camera.""" + """Request a frame from the written camera, then publish the camera record.""" self._frame_requester.request_frame() + self._camera_publisher.publish_camera(self.get_camera_state()) def _publish_cross_section(self) -> None: """Request a frame showing the written plane.""" diff --git a/src/ansys/visor/viewer/vtk/scene/server_scene.py b/src/ansys/visor/viewer/vtk/scene/server_scene.py index 5858bf06..e6c42b17 100644 --- a/src/ansys/visor/viewer/vtk/scene/server_scene.py +++ b/src/ansys/visor/viewer/vtk/scene/server_scene.py @@ -23,6 +23,7 @@ if TYPE_CHECKING: from ansys.visor.viewer.app.trame.server_loop_bridge import ServerLoopBridge from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState + from ansys.visor.viewer.renderer.server_renderer import CameraPublisher BACKGROUND_RGB: dict[bool, tuple[float, float, float]] = { False: (247 / 255, 247 / 255, 247 / 255), @@ -41,9 +42,15 @@ class VisorServerScene(VisorSceneBase): _renderer: ServerRenderer _rca_window: VisorRcaWindow - def __init__(self, server: Server, dark_mode: bool, bridge: ServerLoopBridge) -> None: + def __init__( + self, + server: Server, + dark_mode: bool, + bridge: ServerLoopBridge, + camera_publisher: CameraPublisher, + ) -> None: """Build the ServerRenderer and the RCA window over this scene's lock.""" - renderer = ServerRenderer(BACKGROUND_RGB[dark_mode], bridge) + renderer = ServerRenderer(BACKGROUND_RGB[dark_mode], bridge, camera_publisher) super().__init__(server, dark_mode, renderer=renderer) self._rca_window = VisorRcaWindow( VtkRemoteControlledArea(renderer.render_window), diff --git a/tests/integration/test_server_window_render.py b/tests/integration/test_server_window_render.py index bf2bc9b2..df1653cd 100644 --- a/tests/integration/test_server_window_render.py +++ b/tests/integration/test_server_window_render.py @@ -9,6 +9,7 @@ from __future__ import annotations import threading +from unittest.mock import MagicMock from trame_rca.rca.vtk_rca import VtkRemoteControlledArea @@ -27,7 +28,7 @@ def request_frame(self) -> None: def test_first_frame_is_background(): - renderer = ServerRenderer((0.2, 0.4, 0.6), _RecordingFrameRequester()) + renderer = ServerRenderer((0.2, 0.4, 0.6), _RecordingFrameRequester(), MagicMock(name="camera_publisher")) capture = VtkRemoteControlledArea(renderer.render_window) window = VisorRcaWindow(capture, renderer, threading.RLock(), lambda: None) diff --git a/tests/unit/app/test_server_loop_bridge.py b/tests/unit/app/test_server_loop_bridge.py index da2f8dc9..fb53007f 100644 --- a/tests/unit/app/test_server_loop_bridge.py +++ b/tests/unit/app/test_server_loop_bridge.py @@ -11,6 +11,7 @@ from __future__ import annotations import threading +from unittest.mock import MagicMock import pytest @@ -100,7 +101,7 @@ def lock() -> threading.RLock: @pytest.fixture def window(bound_bridge, lock) -> VisorRcaWindow: - renderer = ServerRenderer((0.2, 0.4, 0.6), bound_bridge) + renderer = ServerRenderer((0.2, 0.4, 0.6), bound_bridge, MagicMock(name="camera_publisher")) return VisorRcaWindow(_NeverCapture(), renderer, lock, bound_bridge.retry_frame) diff --git a/tests/unit/app/test_server_state_publisher.py b/tests/unit/app/test_server_state_publisher.py new file mode 100644 index 00000000..f1d25476 --- /dev/null +++ b/tests/unit/app/test_server_state_publisher.py @@ -0,0 +1,115 @@ +""" +Unit tests for ServerStatePublisher. + +The state is a recording double. The loop-thread test uses a real +AsyncRunner; the others use a recording runner whose callbacks run only when +the test calls them. Expected records are hand-written literals. +""" + +from __future__ import annotations + +import threading + +import pytest + +from ansys.visor.viewer.app.trame.async_runner import AsyncRunner +from ansys.visor.viewer.app.trame.server_state_publisher import ServerStatePublisher +from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState + +WAIT_TIMEOUT_S = 5.0 + +CAMERA_RECORD = { + "position": [0.0, 0.0, 9.0], + "focalPoint": [0.0, 0.0, 0.0], + "viewUp": [0.0, 1.0, 0.0], + "clippingRange": [1.0, 20.0], + "parallelProjection": False, + "viewAngle": 25.0, + "parallelScale": 3.0, +} + + +def _camera() -> VisorCameraState: + return VisorCameraState( + position=[0.0, 0.0, 9.0], + focal_point=[0.0, 0.0, 0.0], + view_up=[0.0, 1.0, 0.0], + clipping_range=[1.0, 20.0], + parallel_projection=False, + view_angle=25.0, + parallel_scale=3.0, + ) + + +class _RecordingState: + """Records each key write and each flush, with the thread it ran on.""" + + def __init__(self) -> None: + self.events: list[tuple] = [] + self.thread_ids: list[int] = [] + self._changed = threading.Condition() + + def _record(self, event: tuple) -> None: + with self._changed: + self.events.append(event) + self.thread_ids.append(threading.get_ident()) + self._changed.notify_all() + + def __setitem__(self, key, value) -> None: + self._record(("set", key, value)) + + def flush(self) -> None: + self._record(("flush",)) + + def wait_for(self, count: int) -> bool: + with self._changed: + return self._changed.wait_for(lambda: len(self.events) >= count, WAIT_TIMEOUT_S) + + +class _RecordingRunner: + """Records the callbacks handed to call_soon, without running them.""" + + def __init__(self) -> None: + self.callbacks: list = [] + + def call_soon(self, callback) -> None: + self.callbacks.append(callback) + + +@pytest.fixture +def state() -> _RecordingState: + return _RecordingState() + + +@pytest.fixture +def runner() -> _RecordingRunner: + return _RecordingRunner() + + +def test_publish_camera_writes_state_on_loop_thread(state): + async_runner = AsyncRunner() + try: + publisher = ServerStatePublisher(async_runner, state) + publisher.publish_camera(_camera()) + assert state.wait_for(2) + loop_thread_id = async_runner.thread.ident + assert state.events == [("set", "server_camera_state", CAMERA_RECORD), ("flush",)] + assert state.thread_ids == [loop_thread_id, loop_thread_id] + finally: + async_runner.dispose() + + +def test_publish_camera_dumps_at_call_time(runner, state): + camera = _camera() + ServerStatePublisher(runner, state).publish_camera(camera) + # A later camera write mutates the record in place before the loop runs. + camera.parallel_projection = True + runner.callbacks[0]() + assert state.events == [("set", "server_camera_state", CAMERA_RECORD), ("flush",)] + + +def test_publish_camera_none_writes_none(runner, state): + ServerStatePublisher(runner, state).publish_camera(None) + runner.callbacks[0]() + assert state.events == [("set", "server_camera_state", None), ("flush",)] + diff --git a/tests/unit/app/test_visor_vtk_server.py b/tests/unit/app/test_visor_vtk_server.py index 97f35088..4f8e366e 100644 --- a/tests/unit/app/test_visor_vtk_server.py +++ b/tests/unit/app/test_visor_vtk_server.py @@ -2,8 +2,9 @@ and ``ServerApp``. Mocking boundary: ``visor_vtk.TrameServerManager``, -``visor_vtk_server.ServerApp``, ``visor_vtk_server.VisorServerScene`` and -``visor_vtk_server.ServerLoopBridge``. Nothing here starts a trame server. +``visor_vtk_server.ServerApp``, ``visor_vtk_server.VisorServerScene``, +``visor_vtk_server.ServerLoopBridge`` and +``visor_vtk_server.ServerStatePublisher``. Nothing here starts a trame server. """ from unittest.mock import MagicMock, patch @@ -15,30 +16,39 @@ @pytest.fixture def wiring(): - """Build a VisorVTKServer against doubles; yield (app_cls, scene_cls, bridge_cls).""" + """Build a VisorVTKServer against doubles; yield (app_cls, scene_cls, bridge_cls, mgr, publishers).""" with patch("ansys.visor.viewer.app.visor_vtk.TrameServerManager") as mgr_cls, \ patch("ansys.visor.viewer.app.visor_vtk_server.ServerApp") as app_cls, \ patch("ansys.visor.viewer.app.visor_vtk_server.VisorServerScene") as scene_cls, \ - patch("ansys.visor.viewer.app.visor_vtk_server.ServerLoopBridge") as bridge_cls: + patch("ansys.visor.viewer.app.visor_vtk_server.ServerLoopBridge") as bridge_cls, \ + patch("ansys.visor.viewer.app.visor_vtk_server.ServerStatePublisher") as publisher_cls: mgr = MagicMock() mgr.running = False mgr_cls.return_value = mgr # A distinct bridge per construction, so two bridges cannot pass for one. bridge_cls.side_effect = lambda *args, **kwargs: MagicMock(name="bridge") + # Each publisher built is recorded with its constructor arguments. + publishers: list[tuple[tuple, MagicMock]] = [] + + def build_publisher(*args): + publishers.append((args, MagicMock(name="publisher"))) + return publishers[-1][1] + + publisher_cls.side_effect = build_publisher VisorVTKServer() - yield app_cls, scene_cls, bridge_cls + yield app_cls, scene_cls, bridge_cls, mgr, publishers def test_scene_and_app_share_one_bridge(wiring): """The scene's renderer requests frames through the bridge the app binds.""" - app_cls, scene_cls, _bridge_cls = wiring + app_cls, scene_cls, _bridge_cls, _mgr, _publishers = wiring assert scene_cls.call_args.kwargs["bridge"] is app_cls.call_args.kwargs["bridge"] def test_app_receives_the_scene_members(wiring): """The app is given the scene itself, its RCA window, and its pick and theme methods.""" - app_cls, scene_cls, _bridge_cls = wiring + app_cls, scene_cls, _bridge_cls, _mgr, _publishers = wiring scene = scene_cls.return_value kwargs = app_cls.call_args.kwargs @@ -47,3 +57,12 @@ def test_app_receives_the_scene_members(wiring): assert kwargs["pick_part_at"] is scene.pick_part_at assert kwargs["set_dark_mode"] is scene.set_dark_mode + +def test_scene_publishes_camera_into_the_viewer_state(wiring): + """The scene gets the one publisher, built over this viewer's runner and server state.""" + _app_cls, scene_cls, _bridge_cls, mgr, publishers = wiring + + assert [args for args, _ in publishers] == [(mgr.async_runner, mgr.server.state)] + assert scene_cls.call_args.kwargs["camera_publisher"] is publishers[0][1] + + diff --git a/tests/unit/renderer/test_per_part_parity.py b/tests/unit/renderer/test_per_part_parity.py index 36bd1714..9ea89573 100644 --- a/tests/unit/renderer/test_per_part_parity.py +++ b/tests/unit/renderer/test_per_part_parity.py @@ -60,7 +60,7 @@ def _build_local() -> VisorLocalRenderer: def _build_server() -> ServerRenderer: - return ServerRenderer((0.2, 0.4, 0.6), _RecordingFrameRequester()) + return ServerRenderer((0.2, 0.4, 0.6), _RecordingFrameRequester(), MagicMock(name="camera_publisher")) @pytest.fixture(params=["local", "server"]) diff --git a/tests/unit/renderer/test_server_renderer.py b/tests/unit/renderer/test_server_renderer.py index 793ff124..aced6ae6 100644 --- a/tests/unit/renderer/test_server_renderer.py +++ b/tests/unit/renderer/test_server_renderer.py @@ -2,8 +2,8 @@ Unit tests for ServerRenderer. Every test builds a real ServerRenderer (an off-screen VTK window that is -never rendered) with a recording frame requester. Expected values are -hand-written literals. +never rendered) with a recording frame requester and a recording camera +publisher. Expected values are hand-written literals. """ from __future__ import annotations @@ -33,6 +33,16 @@ def request_frame(self) -> None: self.count += 1 +class _RecordingCameraPublisher: + """Records every published camera record.""" + + def __init__(self) -> None: + self.cameras: list = [] + + def publish_camera(self, camera_state) -> None: + self.cameras.append(camera_state) + + def _unit_sphere(): source = vtkSphereSource() source.SetRadius(1.0) @@ -52,9 +62,14 @@ def requester() -> _RecordingFrameRequester: @pytest.fixture -def renderer(requester) -> ServerRenderer: +def publisher() -> _RecordingCameraPublisher: + return _RecordingCameraPublisher() + + +@pytest.fixture +def renderer(requester, publisher) -> ServerRenderer: """ServerRenderer with one unit-sphere part registered and the camera reset to it.""" - r = ServerRenderer((0.2, 0.4, 0.6), requester) + r = ServerRenderer((0.2, 0.4, 0.6), requester, publisher) _register_part(r) r.reset_camera(PART_BOUNDS) requester.count = 0 @@ -117,6 +132,36 @@ def test_render_requests_a_frame(renderer, requester): assert requester.count == 1 +# --------------------------------------------------------------------------- +# Camera writes publish the camera record +# --------------------------------------------------------------------------- + + +def test_sync_camera_publishes_record(renderer, publisher): + # A record of its own: sync_camera stores it by identity, and a later + # set_projection on that renderer would mutate a shared one in place. + renderer.sync_camera( + VisorCameraState( + position=[0.0, 0.0, 9.0], + focal_point=[0.0, 0.0, 0.0], + view_up=[0.0, 1.0, 0.0], + clipping_range=[1.0, 20.0], + parallel_projection=False, + view_angle=25.0, + parallel_scale=3.0, + ) + ) + assert publisher.cameras[-1].model_dump(by_alias=True) == { + "position": [0.0, 0.0, 9.0], + "focalPoint": [0.0, 0.0, 0.0], + "viewUp": [0.0, 1.0, 0.0], + "clippingRange": [1.0, 20.0], + "parallelProjection": False, + "viewAngle": 25.0, + "parallelScale": 3.0, + } + + # --------------------------------------------------------------------------- # Never renders # --------------------------------------------------------------------------- @@ -136,8 +181,8 @@ def count_start(*_): return window -def test_never_renders(requester): - r = _ObservedServerRenderer((0.2, 0.4, 0.6), requester) +def test_never_renders(requester, publisher): + r = _ObservedServerRenderer((0.2, 0.4, 0.6), requester, publisher) _register_part(r) r.reset_camera(PART_BOUNDS) for write in _WRITES.values(): @@ -175,16 +220,16 @@ def test_cross_section_visibility_turns_widget_on(renderer): assert renderer._cross_section_widget.is_on is True -def test_bounding_box_visibility_reapplied_on_part_count(requester): - r = ServerRenderer((0.2, 0.4, 0.6), requester) +def test_bounding_box_visibility_reapplied_on_part_count(requester, publisher): + r = ServerRenderer((0.2, 0.4, 0.6), requester, publisher) r.set_bounding_box_visibility(True) assert _outline_actor(r).GetVisibility() == 0 r.update_actor_count(1) assert _outline_actor(r).GetVisibility() == 1 -def test_bounding_box_hidden_when_part_count_drops_to_zero(requester): - r = ServerRenderer((0.2, 0.4, 0.6), requester) +def test_bounding_box_hidden_when_part_count_drops_to_zero(requester, publisher): + r = ServerRenderer((0.2, 0.4, 0.6), requester, publisher) r.set_bounding_box_visibility(True) r.update_actor_count(1) r.update_actor_count(0) diff --git a/tests/unit/renderer/test_server_window.py b/tests/unit/renderer/test_server_window.py index 5e77dc2a..3576b32f 100644 --- a/tests/unit/renderer/test_server_window.py +++ b/tests/unit/renderer/test_server_window.py @@ -12,7 +12,7 @@ import threading from typing import Callable -from unittest.mock import call, patch +from unittest.mock import MagicMock, call, patch import numpy as np import pytest @@ -88,7 +88,7 @@ def probe() -> None: @pytest.fixture def renderer() -> ServerRenderer: - return ServerRenderer((0.2, 0.4, 0.6), _RecordingFrameRequester()) + return ServerRenderer((0.2, 0.4, 0.6), _RecordingFrameRequester(), MagicMock(name="camera_publisher")) @pytest.fixture diff --git a/tests/unit/vtk/scene/test_server_scene.py b/tests/unit/vtk/scene/test_server_scene.py index e516a974..f559ffb7 100644 --- a/tests/unit/vtk/scene/test_server_scene.py +++ b/tests/unit/vtk/scene/test_server_scene.py @@ -53,7 +53,7 @@ def bridge() -> _RecordingBridge: @pytest.fixture def scene(bridge) -> VisorServerScene: """A light-theme scene with no datasets; bridge counts start at zero.""" - s = VisorServerScene(MagicMock(name="server"), False, bridge) + s = VisorServerScene(MagicMock(name="server"), False, bridge, MagicMock(name="camera_publisher")) bridge.request_count = 0 bridge.retry_count = 0 return s @@ -70,7 +70,7 @@ def scene(bridge) -> VisorServerScene: ids=["light", "dark"], ) def test_initial_background_follows_theme(bridge, dark_mode, expected): - s = VisorServerScene(MagicMock(name="server"), dark_mode, bridge) + s = VisorServerScene(MagicMock(name="server"), dark_mode, bridge, MagicMock(name="camera_publisher")) assert _background(s) == expected From 02255ee81b3a4bf12db19f5ab4fa72ec9bce756c Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:17:34 +0000 Subject: [PATCH 5/8] chore: adding changelog file 183.added.md [dependabot-skip] --- doc/changelog.d/183.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/183.added.md diff --git a/doc/changelog.d/183.added.md b/doc/changelog.d/183.added.md new file mode 100644 index 00000000..ff07ea8d --- /dev/null +++ b/doc/changelog.d/183.added.md @@ -0,0 +1 @@ +[Remote rendering 4.1d] Add a SERVER rendering mode From 953bbe55501f3d3c830dc29a93293d0e06a3611a Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:16:38 +0000 Subject: [PATCH 6/8] chore: adding changelog file 183.breaking.md [dependabot-skip] --- doc/changelog.d/{183.added.md => 183.breaking.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/changelog.d/{183.added.md => 183.breaking.md} (100%) diff --git a/doc/changelog.d/183.added.md b/doc/changelog.d/183.breaking.md similarity index 100% rename from doc/changelog.d/183.added.md rename to doc/changelog.d/183.breaking.md From 1311ae4e928c8311de50e0defc4b09207cd47cea Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:20:10 +0000 Subject: [PATCH 7/8] chore: adding changelog file 183.breaking.md [dependabot-skip] --- doc/changelog.d/183.breaking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.d/183.breaking.md b/doc/changelog.d/183.breaking.md index ff07ea8d..2e08f2f7 100644 --- a/doc/changelog.d/183.breaking.md +++ b/doc/changelog.d/183.breaking.md @@ -1 +1 @@ -[Remote rendering 4.1d] Add a SERVER rendering mode +[Remote rendering 4.1d] Add a SERVER rendering mode streamed through trame-rca From c16d87345cba34616711233d8a11f496220aef54 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:53:34 +0000 Subject: [PATCH 8/8] chore: adding changelog file 183.breaking.md [dependabot-skip] --- doc/changelog.d/183.breaking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.d/183.breaking.md b/doc/changelog.d/183.breaking.md index 2e08f2f7..051842b2 100644 --- a/doc/changelog.d/183.breaking.md +++ b/doc/changelog.d/183.breaking.md @@ -1 +1 @@ -[Remote rendering 4.1d] Add a SERVER rendering mode streamed through trame-rca +Remote rendering 4.1d: Add a SERVER rendering mode streamed through trame-rca