Skip to content

Commit 522711c

Browse files
feat: [Remote rendering 3.3a] server-authoritative widget toggles and projection (#137)
Co-authored-by: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com>
1 parent c8a4e71 commit 522711c

28 files changed

Lines changed: 2302 additions & 53 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[Remote rendering 3.3a] server-authoritative widget toggles and projection

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

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,31 @@
1313
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
1414
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
1515
from ansys.visor.viewer.models.runtime.requests.sync_camera_payload import SyncCameraPayload
16+
from ansys.visor.viewer.models.runtime.requests.widget_state_payloads import (
17+
SetBoundingBoxVisibilityPayload,
18+
SetCrossSectionVisibilityPayload,
19+
SetEdgesVisiblePayload,
20+
SetProjectionPayload,
21+
)
1622

1723
logger = VisorDefaultLogger(__name__)
1824

1925
# Disable VTK warning display
2026
vtkObject.GlobalWarningDisplayOff()
2127

2228

23-
class ScenePartStateApi(Protocol):
24-
"""Structural type of the per-part coordinator surface LocalApp calls.
29+
class SceneMutationApi(Protocol):
30+
"""Structural type of the coordinator surface LocalApp calls.
2531
2632
Typing only: there is no ``runtime_checkable`` decoration and no
2733
``isinstance`` check anywhere against it. Declaring it here rather than
2834
importing the scene keeps this module free of any scene type, so the
2935
injected object remains LocalApp's only route to the scene.
3036
31-
``sync_camera`` is not per-part, and it is declared here anyway: the one
32-
production injection site passes the whole scene coordinator, so a second
33-
protocol would be the same object under a second name.
37+
Covers both per-part mutations (visibility, opacity, colour, selection)
38+
and scene-wide ones (camera sync, the widget-state toggles, projection,
39+
cross-section plane). The one production injection site passes the
40+
whole scene coordinator, so this protocol describes that whole surface.
3441
"""
3542

3643
def set_part_visibility(self, node_id: int, visible: bool) -> None: ...
@@ -56,6 +63,14 @@ def clear_part_color_variable(self, node_id: int) -> None: ...
5663

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

66+
def set_cross_section_visibility(self, visible: bool) -> None: ...
67+
68+
def set_edges_visible(self, visible: bool) -> None: ...
69+
70+
def set_bounding_box_visibility(self, visible: bool) -> None: ...
71+
72+
def set_projection(self, parallel: bool) -> None: ...
73+
5974

6075
# ----------------------------------------------------------------------
6176
# Trigger payload models
@@ -184,6 +199,10 @@ class LocalApp:
184199
set_part_color_variable: colours one part by a scalar variable
185200
clear_part_color_variable: stops colouring one part by a scalar variable
186201
sync_camera: records a settled camera reported by the frontend
202+
set_cross_section_visibility: shows or hides the cross-section plane
203+
set_edges_visible: shows or hides edges on every part
204+
set_bounding_box_visibility: shows or hides the bounding-box outline
205+
set_projection: sets parallel or perspective projection on the camera record
187206
set_only_cookie: sets a cookie on the server (note: Trame server only allows a single cookie header)
188207
Protected Methods:
189208
_cleanup(): Cleans up the active actor in the visualization pipeline.
@@ -196,7 +215,7 @@ def __init__(
196215
standalone: bool = True,
197216
trame_logger: Logger | None = None,
198217
pick_geometry=None,
199-
scene_part_state_api: ScenePartStateApi | None = None,
218+
scene_mutation_api: SceneMutationApi | None = None,
200219
):
201220
self.server = server
202221
# Callable to get the scene details in JSON format
@@ -205,10 +224,10 @@ def __init__(
205224
self._handle_save_state_response = handle_save_state_response
206225
# Callable for sub-geometry picking (optional)
207226
self._pick_geometry = pick_geometry
208-
# Per-part visual state coordinator (see ScenePartStateApi). The one
227+
# Per-part visual state coordinator (see SceneMutationApi). The one
209228
# production construction site always supplies it; it is optional so
210229
# that the class stays constructible without a scene.
211-
self._scene_part_state_api = scene_part_state_api
230+
self._scene_mutation_api = scene_mutation_api
212231
# logger for logging trame server lifecycle info
213232
self.__trame_logger = trame_logger
214233

@@ -333,18 +352,19 @@ def perf_report_server_update(self, payload: dict):
333352
# body.
334353
# ------------------------------------------------------------------
335354

336-
def _part_state_api(self, trigger_name: str) -> ScenePartStateApi | None:
355+
def _mutation_api(self, trigger_name: str, payload: BaseModel) -> SceneMutationApi | None:
337356
"""Return the injected coordinator, or ``None`` after logging."""
338-
if self._scene_part_state_api is None:
357+
logger.debug("[trigger] %s arrived: %s.", trigger_name, payload)
358+
if self._scene_mutation_api is None:
339359
logger.debug("%s: no scene part-state API injected; ignoring.", trigger_name)
340360
return None
341-
return self._scene_part_state_api
361+
return self._scene_mutation_api
342362

343363
@trigger("set_part_visibility")
344364
@parse_payload(SetPartVisibilityPayload)
345365
def set_part_visibility(self, payload) -> None:
346366
"""Frontend -> Backend: set whether one part is visible."""
347-
api = self._part_state_api("set_part_visibility")
367+
api = self._mutation_api("set_part_visibility", payload)
348368
if api is None:
349369
return
350370
api.set_part_visibility(payload.node_id, payload.visible)
@@ -357,7 +377,7 @@ def set_part_opacity(self, payload) -> None:
357377
An opacity outside ``[0.0, 1.0]`` fails validation and is a logged
358378
no-op; it does not reach VTK to be clamped.
359379
"""
360-
api = self._part_state_api("set_part_opacity")
380+
api = self._mutation_api("set_part_opacity", payload)
361381
if api is None:
362382
return
363383
api.set_part_opacity(payload.node_id, payload.opacity)
@@ -372,7 +392,7 @@ def set_part_diffuse_color(self, payload) -> None:
372392
or a colour that is not exactly three components, is a logged
373393
no-op -- nothing is delegated, so nothing is written to the store.
374394
"""
375-
api = self._part_state_api("set_part_diffuse_color")
395+
api = self._mutation_api("set_part_diffuse_color", payload)
376396
if api is None:
377397
return
378398
api.set_part_diffuse_color(payload.node_id, payload.diffuse_rgb)
@@ -385,7 +405,7 @@ def set_part_selected(self, payload) -> None:
385405
No colour crosses this trigger: the server reads the part's stored
386406
diffuse colour from its own record.
387407
"""
388-
api = self._part_state_api("set_part_selected")
408+
api = self._mutation_api("set_part_selected", payload)
389409
if api is None:
390410
return
391411
api.set_part_selected(payload.node_id, payload.selected)
@@ -403,7 +423,7 @@ def set_part_color_variable(self, payload) -> None:
403423
name. ``variableId`` is forwarded verbatim and is never parsed by
404424
the server.
405425
"""
406-
api = self._part_state_api("set_part_color_variable")
426+
api = self._mutation_api("set_part_color_variable", payload)
407427
if api is None:
408428
return
409429
api.set_part_color_variable(
@@ -420,7 +440,7 @@ def set_part_color_variable(self, payload) -> None:
420440
@parse_payload(ClearPartColorVariablePayload)
421441
def clear_part_color_variable(self, payload) -> None:
422442
"""Frontend -> Backend: stop colouring one part by a scalar variable."""
423-
api = self._part_state_api("clear_part_color_variable")
443+
api = self._mutation_api("clear_part_color_variable", payload)
424444
if api is None:
425445
return
426446
api.clear_part_color_variable(payload.node_id)
@@ -457,7 +477,7 @@ def sync_camera(self, payload) -> None:
457477
if payload.origin != "gesture":
458478
logger.debug("sync_camera: origin=%s; dropping.", payload.origin)
459479
return
460-
api = self._part_state_api("sync_camera")
480+
api = self._mutation_api("sync_camera", payload)
461481
if api is None:
462482
return
463483
logger.debug(
@@ -467,6 +487,60 @@ def sync_camera(self, payload) -> None:
467487
)
468488
api.sync_camera(payload.camera)
469489

490+
# ------------------------------------------------------------------
491+
# Widget-state triggers
492+
#
493+
# Frontend -> Backend. One trigger per server-tracked toggle. Each
494+
# carries the absolute target value, not a delta, so a redundant
495+
# message is indistinguishable from a no-op one, and both are fine.
496+
# No ``origin`` field: unlike the camera, a toggle echo is idempotent.
497+
#
498+
# ``set_projection`` lives here too: it is delivered the same way, but
499+
# it writes the camera record's projection field rather than a toggle
500+
# of its own.
501+
# ------------------------------------------------------------------
502+
503+
@trigger("set_cross_section_visibility")
504+
@parse_payload(SetCrossSectionVisibilityPayload)
505+
def set_cross_section_visibility(self, payload) -> None:
506+
"""Frontend -> Backend: show or hide the cross-section plane."""
507+
api = self._mutation_api("set_cross_section_visibility", payload)
508+
if api is None:
509+
return
510+
api.set_cross_section_visibility(payload.visible)
511+
512+
@trigger("set_edges_visible")
513+
@parse_payload(SetEdgesVisiblePayload)
514+
def set_edges_visible(self, payload) -> None:
515+
"""Frontend -> Backend: show or hide edges on every part."""
516+
api = self._mutation_api("set_edges_visible", payload)
517+
if api is None:
518+
return
519+
api.set_edges_visible(payload.visible)
520+
521+
@trigger("set_bounding_box_visibility")
522+
@parse_payload(SetBoundingBoxVisibilityPayload)
523+
def set_bounding_box_visibility(self, payload) -> None:
524+
"""Frontend -> Backend: show or hide the bounding-box outline."""
525+
api = self._mutation_api("set_bounding_box_visibility", payload)
526+
if api is None:
527+
return
528+
api.set_bounding_box_visibility(payload.visible)
529+
530+
@trigger("set_projection")
531+
@parse_payload(SetProjectionPayload)
532+
def set_projection(self, payload) -> None:
533+
"""Frontend -> Backend: set parallel or perspective projection.
534+
535+
The projection is the camera record's field, not a toggle of its
536+
own: the coordinator writes the record and re-serialises the
537+
camera in one critical section.
538+
"""
539+
api = self._mutation_api("set_projection", payload)
540+
if api is None:
541+
return
542+
api.set_projection(payload.parallel)
543+
470544
def set_only_cookie(self, key: str, value: str):
471545
"""
472546
Sets a cookie on the server. NOTE: there is a limitation

‎src/ansys/visor/viewer/app/visor_vtk_local.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) ->
5252
world_x,
5353
world_y,
5454
world_z: self._scene.pick_geometry(actor_wasm_id, cell_id, mode, world_x, world_y, world_z),
55-
# The scene satisfies LocalApp's ScenePartStateApi protocol
55+
# The scene satisfies LocalApp's SceneMutationApi protocol
5656
# structurally: the six per-part coordinator methods carry exactly
5757
# the names and signatures the triggers call.
58-
scene_part_state_api=self._scene,
58+
scene_mutation_api=self._scene,
5959
)
6060

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Models for the widget-state trigger payloads.
2+
3+
One model per server-tracked widget toggle, plus the projection.
4+
5+
Projection is not a fourth toggle. It has no store field on the scene: it
6+
is the camera record's ``parallel_projection``, written through the
7+
renderer and derived back out in ``get_state``, so that the projection has
8+
exactly one source.
9+
10+
These live here rather than inline in ``local_app.py`` beside the six
11+
per-part payload models, whose own block comment scopes itself to
12+
per-part triggers carrying camelCase aliases. These are neither. The
13+
precedent is ``sync_camera_payload.py``, the one existing non-per-part
14+
trigger, whose model lives in this package.
15+
"""
16+
17+
from pydantic import BaseModel, ConfigDict
18+
19+
20+
class SetCrossSectionVisibilityPayload(BaseModel):
21+
"""Payload of the ``set_cross_section_visibility`` trigger."""
22+
23+
model_config = ConfigDict(populate_by_name=True)
24+
25+
visible: bool
26+
27+
28+
class SetEdgesVisiblePayload(BaseModel):
29+
"""Payload of the ``set_edges_visible`` trigger."""
30+
31+
model_config = ConfigDict(populate_by_name=True)
32+
33+
visible: bool
34+
35+
36+
class SetBoundingBoxVisibilityPayload(BaseModel):
37+
"""Payload of the ``set_bounding_box_visibility`` trigger."""
38+
39+
model_config = ConfigDict(populate_by_name=True)
40+
41+
visible: bool
42+
43+
44+
class SetProjectionPayload(BaseModel):
45+
"""Payload of the ``set_projection`` trigger."""
46+
47+
model_config = ConfigDict(populate_by_name=True)
48+
49+
parallel: bool

‎src/ansys/visor/viewer/models/runtime/visor_scene_details.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ def from_components(
3131
dataset_states: Dict[int, RuntimeDatasetState],
3232
scene_graph_state: SceneGraphNodeInfo | None = None,
3333
renderer_annotation: RendererAnnotation | None = None,
34+
orthographic_enabled: bool | None = None,
35+
cross_section_enabled: bool | None = None,
36+
edges_enabled: bool | None = None,
37+
bounding_box_enabled: bool | None = None,
3438
) -> "VisorSceneDetails":
3539
"""Construct an instance from components."""
3640
vtk_info = RuntimeVTKInfo(
@@ -41,6 +45,10 @@ def from_components(
4145
dark_mode=dark_mode,
4246
unit=unit,
4347
dataset_states=dataset_states,
48+
orthographic_enabled=orthographic_enabled,
49+
cross_section_enabled=cross_section_enabled,
50+
edges_enabled=edges_enabled,
51+
bounding_box_enabled=bounding_box_enabled,
4452
)
4553
return cls(
4654
app_state=app_state,

‎src/ansys/visor/viewer/renderer/base.py‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
without touching scene coordination.
99
1010
Contract covers node lifecycle, per-part visual mutations,
11-
camera, widget control (cross-section, bounding box), widget fan-out (scene
12-
bounds, actor count), picking, and render/flush. Not covered yet:
11+
camera, widget control (cross-section, bounding box, edges), widget fan-out
12+
(scene bounds, actor count), picking, and render/flush. Not covered yet:
1313
state-authority hooks, trigger-facing methods, round-trip additions.
1414
1515
"""
@@ -202,6 +202,19 @@ def sync_camera(self, camera_state: "VisorCameraState") -> None:
202202
on object identity through :meth:`get_camera_state`.
203203
"""
204204

205+
@abstractmethod
206+
def set_projection(self, parallel: bool) -> None:
207+
"""Set parallel projection on the camera record, then project it.
208+
209+
Writes ``parallel_projection`` on the existing record in place
210+
(preserving :meth:`sync_camera`'s identity contract) before applying
211+
to the pipeline camera, so a raising VTK setter still leaves the
212+
record holding what was asked. A ``None`` record is not seeded: the
213+
pipeline write still happens, but the value is lost until the next
214+
:meth:`reset_camera` imports it. Does not re-serialise; that is the
215+
coordinator's job.
216+
"""
217+
205218
@abstractmethod
206219
def serialize_camera_state(self) -> None:
207220
"""Make the state served to the client current for the camera.
@@ -220,9 +233,18 @@ def serialize_camera_state(self) -> None:
220233
"""
221234

222235
# ------------------------------------------------------------------------
223-
# Widget control (cross-section, bounding box)
236+
# Widget control (cross-section, bounding box, edges)
224237
# ------------------------------------------------------------------------
225238

239+
@abstractmethod
240+
def set_edges_visible(self, visible: bool) -> None:
241+
"""Show or hide edges on every part in the scene.
242+
243+
Scene-wide, not per-node: edges are a single global toggle and the
244+
per-part surface that once mirrored it had no reader, no sender and
245+
no model field.
246+
"""
247+
226248
@abstractmethod
227249
def set_cross_section_visibility(self, visible: bool) -> None:
228250
"""Show or hide the cross-section clipping plane."""

0 commit comments

Comments
 (0)