Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changelog.d/137.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Remote rendering 3.3a] server-authoritative widget toggles and projection
110 changes: 92 additions & 18 deletions src/ansys/visor/viewer/app/trame/local_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,31 @@
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.widget_state_payloads import (
SetBoundingBoxVisibilityPayload,
SetCrossSectionVisibilityPayload,
SetEdgesVisiblePayload,
SetProjectionPayload,
)

logger = VisorDefaultLogger(__name__)

# Disable VTK warning display
vtkObject.GlobalWarningDisplayOff()


class ScenePartStateApi(Protocol):
"""Structural type of the per-part coordinator surface LocalApp calls.
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.

``sync_camera`` is not per-part, and it is declared here anyway: the one
production injection site passes the whole scene coordinator, so a second
protocol would be the same object under a second name.
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: ...
Expand All @@ -56,6 +63,14 @@ 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: ...


# ----------------------------------------------------------------------
# Trigger payload models
Expand Down Expand Up @@ -184,6 +199,10 @@ class LocalApp:
set_part_color_variable: colours one part by a scalar variable
clear_part_color_variable: stops colouring one part by a scalar variable
sync_camera: records a settled camera reported by the frontend
set_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
set_only_cookie: sets a cookie on the server (note: Trame server only allows a single cookie header)
Protected Methods:
_cleanup(): Cleans up the active actor in the visualization pipeline.
Expand All @@ -196,7 +215,7 @@ def __init__(
standalone: bool = True,
trame_logger: Logger | None = None,
pick_geometry=None,
scene_part_state_api: ScenePartStateApi | None = None,
scene_mutation_api: SceneMutationApi | None = None,
):
self.server = server
# Callable to get the scene details in JSON format
Expand All @@ -205,10 +224,10 @@ def __init__(
self._handle_save_state_response = handle_save_state_response
# Callable for sub-geometry picking (optional)
self._pick_geometry = pick_geometry
# Per-part visual state coordinator (see ScenePartStateApi). The one
# 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_part_state_api = scene_part_state_api
self._scene_mutation_api = scene_mutation_api
# logger for logging trame server lifecycle info
self.__trame_logger = trame_logger

Expand Down Expand Up @@ -333,18 +352,19 @@ def perf_report_server_update(self, payload: dict):
# body.
# ------------------------------------------------------------------

def _part_state_api(self, trigger_name: str) -> ScenePartStateApi | None:
def _mutation_api(self, trigger_name: str, payload: BaseModel) -> SceneMutationApi | None:
"""Return the injected coordinator, or ``None`` after logging."""
if self._scene_part_state_api is None:
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_part_state_api
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._part_state_api("set_part_visibility")
api = self._mutation_api("set_part_visibility", payload)
if api is None:
return
api.set_part_visibility(payload.node_id, payload.visible)
Expand All @@ -357,7 +377,7 @@ def set_part_opacity(self, payload) -> None:
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._part_state_api("set_part_opacity")
api = self._mutation_api("set_part_opacity", payload)
if api is None:
return
api.set_part_opacity(payload.node_id, payload.opacity)
Expand All @@ -372,7 +392,7 @@ def set_part_diffuse_color(self, payload) -> None:
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._part_state_api("set_part_diffuse_color")
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)
Expand All @@ -385,7 +405,7 @@ def set_part_selected(self, payload) -> None:
No colour crosses this trigger: the server reads the part's stored
diffuse colour from its own record.
"""
api = self._part_state_api("set_part_selected")
api = self._mutation_api("set_part_selected", payload)
if api is None:
return
api.set_part_selected(payload.node_id, payload.selected)
Expand All @@ -403,7 +423,7 @@ def set_part_color_variable(self, payload) -> None:
name. ``variableId`` is forwarded verbatim and is never parsed by
the server.
"""
api = self._part_state_api("set_part_color_variable")
api = self._mutation_api("set_part_color_variable", payload)
if api is None:
return
api.set_part_color_variable(
Expand All @@ -420,7 +440,7 @@ def set_part_color_variable(self, payload) -> None:
@parse_payload(ClearPartColorVariablePayload)
def clear_part_color_variable(self, payload) -> None:
"""Frontend -> Backend: stop colouring one part by a scalar variable."""
api = self._part_state_api("clear_part_color_variable")
api = self._mutation_api("clear_part_color_variable", payload)
if api is None:
return
api.clear_part_color_variable(payload.node_id)
Expand Down Expand Up @@ -457,7 +477,7 @@ def sync_camera(self, payload) -> None:
if payload.origin != "gesture":
logger.debug("sync_camera: origin=%s; dropping.", payload.origin)
return
api = self._part_state_api("sync_camera")
api = self._mutation_api("sync_camera", payload)
if api is None:
return
logger.debug(
Expand All @@ -467,6 +487,60 @@ def sync_camera(self, payload) -> None:
)
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.
# ------------------------------------------------------------------

@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)

def set_only_cookie(self, key: str, value: str):
"""
Sets a cookie on the server. NOTE: there is a limitation
Expand Down
4 changes: 2 additions & 2 deletions src/ansys/visor/viewer/app/visor_vtk_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) ->
world_x,
world_y,
world_z: self._scene.pick_geometry(actor_wasm_id, cell_id, mode, world_x, world_y, world_z),
# The scene satisfies LocalApp's ScenePartStateApi protocol
# The scene satisfies LocalApp's SceneMutationApi protocol
# structurally: the six per-part coordinator methods carry exactly
# the names and signatures the triggers call.
scene_part_state_api=self._scene,
scene_mutation_api=self._scene,
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Models for the widget-state trigger payloads.

One model per server-tracked widget toggle, plus the projection.

Projection is not a fourth toggle. It has no store field on the scene: it
is the camera record's ``parallel_projection``, written through the
renderer and derived back out in ``get_state``, so that the projection has
exactly one source.

These live here rather than inline in ``local_app.py`` beside the six
per-part payload models, whose own block comment scopes itself to
per-part triggers carrying camelCase aliases. These are neither. The
precedent is ``sync_camera_payload.py``, the one existing non-per-part
trigger, whose model lives in this package.
"""

from pydantic import BaseModel, ConfigDict


class SetCrossSectionVisibilityPayload(BaseModel):
"""Payload of the ``set_cross_section_visibility`` trigger."""

model_config = ConfigDict(populate_by_name=True)

visible: bool


class SetEdgesVisiblePayload(BaseModel):
"""Payload of the ``set_edges_visible`` trigger."""

model_config = ConfigDict(populate_by_name=True)

visible: bool


class SetBoundingBoxVisibilityPayload(BaseModel):
"""Payload of the ``set_bounding_box_visibility`` trigger."""

model_config = ConfigDict(populate_by_name=True)

visible: bool


class SetProjectionPayload(BaseModel):
"""Payload of the ``set_projection`` trigger."""

model_config = ConfigDict(populate_by_name=True)

parallel: bool
8 changes: 8 additions & 0 deletions src/ansys/visor/viewer/models/runtime/visor_scene_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ def from_components(
dataset_states: Dict[int, RuntimeDatasetState],
scene_graph_state: SceneGraphNodeInfo | None = None,
renderer_annotation: RendererAnnotation | None = None,
orthographic_enabled: bool | None = None,
cross_section_enabled: bool | None = None,
edges_enabled: bool | None = None,
bounding_box_enabled: bool | None = None,
) -> "VisorSceneDetails":
"""Construct an instance from components."""
vtk_info = RuntimeVTKInfo(
Expand All @@ -41,6 +45,10 @@ def from_components(
dark_mode=dark_mode,
unit=unit,
dataset_states=dataset_states,
orthographic_enabled=orthographic_enabled,
cross_section_enabled=cross_section_enabled,
edges_enabled=edges_enabled,
bounding_box_enabled=bounding_box_enabled,
)
return cls(
app_state=app_state,
Expand Down
28 changes: 25 additions & 3 deletions src/ansys/visor/viewer/renderer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
without touching scene coordination.

Contract covers node lifecycle, per-part visual mutations,
camera, widget control (cross-section, bounding box), widget fan-out (scene
bounds, actor count), picking, and render/flush. Not covered yet:
camera, widget control (cross-section, bounding box, edges), widget fan-out
(scene bounds, actor count), picking, and render/flush. Not covered yet:
state-authority hooks, trigger-facing methods, round-trip additions.

"""
Expand Down Expand Up @@ -202,6 +202,19 @@ def sync_camera(self, camera_state: "VisorCameraState") -> None:
on object identity through :meth:`get_camera_state`.
"""

@abstractmethod
def set_projection(self, parallel: bool) -> None:
"""Set parallel projection on the camera record, then project it.

Writes ``parallel_projection`` on the existing record in place
(preserving :meth:`sync_camera`'s identity contract) before applying
to the pipeline camera, so a raising VTK setter still leaves the
record holding what was asked. A ``None`` record is not seeded: the
pipeline write still happens, but the value is lost until the next
:meth:`reset_camera` imports it. Does not re-serialise; that is the
coordinator's job.
"""

@abstractmethod
def serialize_camera_state(self) -> None:
"""Make the state served to the client current for the camera.
Expand All @@ -220,9 +233,18 @@ def serialize_camera_state(self) -> None:
"""

# ------------------------------------------------------------------------
# Widget control (cross-section, bounding box)
# Widget control (cross-section, bounding box, edges)
# ------------------------------------------------------------------------

@abstractmethod
def set_edges_visible(self, visible: bool) -> None:
"""Show or hide edges on every part in the scene.

Scene-wide, not per-node: edges are a single global toggle and the
per-part surface that once mirrored it had no reader, no sender and
no model field.
"""

@abstractmethod
def set_cross_section_visibility(self, visible: bool) -> None:
"""Show or hide the cross-section clipping plane."""
Expand Down
Loading
Loading