Skip to content

Commit 3cbf2d6

Browse files
feat: [Remote rendering 3.3c] server-authoritative cross-section plane (#139)
Co-authored-by: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com>
1 parent c383696 commit 3cbf2d6

19 files changed

Lines changed: 1205 additions & 28 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[Remote rendering 3.3c] server-authoritative cross-section plane

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
SetCrossSectionVisibilityPayload,
1919
SetEdgesVisiblePayload,
2020
SetProjectionPayload,
21+
SyncCrossSectionPlanePayload,
2122
)
2223

2324
logger = VisorDefaultLogger(__name__)
@@ -71,6 +72,10 @@ def set_bounding_box_visibility(self, visible: bool) -> None: ...
7172

7273
def set_projection(self, parallel: bool) -> None: ...
7374

75+
def sync_cross_section_plane(
76+
self, origin: List[float], normal: List[float]
77+
) -> None: ...
78+
7479

7580
# ----------------------------------------------------------------------
7681
# Trigger payload models
@@ -203,6 +208,7 @@ class LocalApp:
203208
set_edges_visible: shows or hides edges on every part
204209
set_bounding_box_visibility: shows or hides the bounding-box outline
205210
set_projection: sets parallel or perspective projection on the camera record
211+
sync_cross_section_plane: records a settled cross-section plane reported by the frontend
206212
set_only_cookie: sets a cookie on the server (note: Trame server only allows a single cookie header)
207213
Protected Methods:
208214
_cleanup(): Cleans up the active actor in the visualization pipeline.
@@ -541,6 +547,26 @@ def set_projection(self, payload) -> None:
541547
return
542548
api.set_projection(payload.parallel)
543549

550+
@trigger("sync_cross_section_plane")
551+
@parse_payload(SyncCrossSectionPlanePayload)
552+
def sync_cross_section_plane(self, payload) -> None:
553+
"""Frontend -> Backend: a settled drag reports the cross-section plane.
554+
555+
Not a toggle: it carries the origin and the normal the widget settled
556+
on, both required and both exactly three components. A half-plane --
557+
a missing vector, or one of the wrong length -- fails validation and
558+
is a logged no-op at this boundary rather than a half-applied plane,
559+
which is ``sync_camera``'s stated posture for the same reason.
560+
561+
The coordinator writes the record and both server VTK objects and
562+
re-serialises them in one critical section; nothing is pushed from
563+
here.
564+
"""
565+
api = self._mutation_api("sync_cross_section_plane", payload)
566+
if api is None:
567+
return
568+
api.sync_cross_section_plane(payload.origin, payload.normal)
569+
544570
def set_only_cookie(self, key: str, value: str):
545571
"""
546572
Sets a cookie on the server. NOTE: there is a limitation

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -459,9 +459,6 @@ def _load_datasets_from_state(self, state: "PersistedViewerStateV1") -> None:
459459
if dataset is not None:
460460
dataset.mark_clean()
461461

462-
if self._scene.dataset_count > 0:
463-
self._scene.finalize_scene(skip_reset_camera=True)
464-
465462
""" Protected Methods (async start and stop for FastAPI) """
466463
@require_server_off
467464
@validate_input_metadata_types

‎src/ansys/visor/viewer/models/runtime/requests/widget_state_payloads.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,17 @@
1212
per-part triggers carrying camelCase aliases. These are neither. The
1313
precedent is ``sync_camera_payload.py``, the one existing non-per-part
1414
trigger, whose model lives in this package.
15+
16+
``SyncCrossSectionPlanePayload`` is not a toggle either. It is the plane
17+
the client's drag settled on, reported at end of interaction, and it
18+
carries two three-component vectors rather than a boolean. It lives here
19+
rather than beside the per-part models for the same reason the four above
20+
do: it is scene-wide and carries no ``nodeId``.
1521
"""
1622

17-
from pydantic import BaseModel, ConfigDict
23+
from typing import List
24+
25+
from pydantic import BaseModel, ConfigDict, Field
1826

1927

2028
class SetCrossSectionVisibilityPayload(BaseModel):
@@ -47,3 +55,13 @@ class SetProjectionPayload(BaseModel):
4755
model_config = ConfigDict(populate_by_name=True)
4856

4957
parallel: bool
58+
59+
60+
class SyncCrossSectionPlanePayload(BaseModel):
61+
"""Payload of the ``sync_cross_section_plane`` trigger."""
62+
63+
model_config = ConfigDict(populate_by_name=True)
64+
65+
origin: List[float] = Field(min_length=3, max_length=3)
66+
normal: List[float] = Field(min_length=3, max_length=3)
67+

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
from vtkmodules.vtkCommonDataModel import vtkDataObject
2424

2525
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
26+
from ansys.visor.viewer.models.common.visor_cross_section_state import (
27+
VisorCrossSectionState,
28+
)
2629
from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import RendererAnnotation
2730
from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraphPartNode
2831

@@ -254,6 +257,24 @@ def sync_cross_section_plane(
254257
to server-side VTK objects.
255258
"""
256259

260+
@abstractmethod
261+
def get_cross_section_plane(self) -> "VisorCrossSectionState | None":
262+
"""
263+
Return the cross-section plane record, or ``None`` if nothing has
264+
written one yet.
265+
"""
266+
267+
@abstractmethod
268+
def serialize_cross_section_state(self) -> None:
269+
"""Make the state served to the client current for the plane.
270+
271+
Names its ids explicitly and never relies on a render following.
272+
273+
**Serialize only; do not notify.**
274+
275+
No-op on a renderer that serves the client no VTK object state.
276+
"""
277+
257278
@abstractmethod
258279
def set_bounding_box_visibility(self, visible: bool) -> None:
259280
"""Show or hide the bounding-box outline."""

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

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from ansys.visor.viewer.core.visor_colors import VisorColors
3030
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
3131
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
32+
from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState
3233
from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import (
3334
WasmNodeHandles,
3435
WasmRendererAnnotation,
@@ -62,12 +63,14 @@ class VisorLocalRenderer(IRenderer):
6263
# Increment I5; mutated exclusively via register_node / deregister_node.
6364
_pipelines: dict[int, VtkNodePipeline]
6465
_last_camera_state: Optional["VisorCameraState"]
66+
_last_cross_section_state: Optional["VisorCrossSectionState"]
6567

6668
def __init__(self, server: Server):
6769
"""Build the full local-mode VTK infrastructure and seed wasm state."""
6870
self._server = server
6971
self._pipelines = {}
7072
self._last_camera_state = None
73+
self._last_cross_section_state = None
7174

7275
self._vtk_renderer = self._initialize_vtk_renderer()
7376
self._render_window = self._initialize_render_window()
@@ -401,7 +404,47 @@ def set_cross_section_visibility(self, visible: bool) -> None:
401404
def sync_cross_section_plane(
402405
self, origin: list[float], normal: list[float]
403406
) -> None:
404-
"""No-op in Story 1.2. Phase 3 populates."""
407+
"""See :meth:`IRenderer.sync_cross_section_plane`.
408+
409+
Writes the record, then both VTK objects. ``set_origin`` and
410+
``set_normal`` each write the plane *and* the representation, so the
411+
clip function and the draggable handle cannot diverge on this path --
412+
which is the defect the client's own set/get asymmetry has.
413+
"""
414+
self._last_cross_section_state = VisorCrossSectionState(
415+
origin=list(origin), normal=list(normal)
416+
)
417+
self._cross_section_widget.set_origin(origin)
418+
self._cross_section_widget.set_normal(normal)
419+
420+
def get_cross_section_plane(self) -> Optional["VisorCrossSectionState"]:
421+
"""See :meth:`IRenderer.get_cross_section_plane`."""
422+
return self._last_cross_section_state
423+
424+
def serialize_cross_section_state(self) -> None:
425+
"""See :meth:`IRenderer.serialize_cross_section_state`.
426+
427+
Two ids, named one at a time, each derived the way
428+
:meth:`serialize_camera_state` derives the camera's: ``GetId`` on the
429+
object itself, not the id ``register_vtk_object`` returned. Deriving
430+
it this way means the equivalence of those two id spaces never has to
431+
be established.
432+
433+
The plane is the clip function every pipeline holds; the
434+
representation is the visible handle and is also what the client
435+
reads on save. A plane-only re-serialise leaves the client saving a
436+
stale handle.
437+
438+
The widget id is deliberately not named: ``set_origin`` and
439+
``set_normal`` do not write the widget, whose changing state is
440+
enablement. See MC-6.
441+
"""
442+
self._object_manager.UpdateStateFromObject(
443+
self._object_manager.GetId(self._cross_section_widget.plane)
444+
)
445+
self._object_manager.UpdateStateFromObject(
446+
self._object_manager.GetId(self._cross_section_widget.plane_representation)
447+
)
405448

406449
def set_bounding_box_visibility(self, visible: bool) -> None:
407450
"""No-op in Story 1.2. Phase 3 populates."""
@@ -411,9 +454,40 @@ def set_bounding_box_visibility(self, visible: bool) -> None:
411454
# ------------------------------------------------------------------
412455

413456
def update_bounds(self, bounds: list[float]) -> None:
414-
"""See :meth:`IRenderer.update_bounds`."""
457+
"""See :meth:`IRenderer.update_bounds`.
458+
459+
Seeds the cross-section record from the values the widget's own
460+
``update_bounds`` just wrote through ``set_origin``/``set_normal``, so
461+
the record is never ``None`` once a scene has been populated and
462+
``get_state`` can assign it unconditionally, as it does the camera's.
463+
464+
**The seed runs only when there is no record.** This method is not
465+
reached once per scene: ``set_part_visibility`` fans out to
466+
``_update_widget_bounds``, so a part toggle or a dataset add reaches
467+
here too. Seeding unconditionally would therefore discard a plane the
468+
user had dragged, on the next toggle, and the widget's own
469+
``update_bounds`` would already have overwritten both VTK objects with
470+
the default plane. With a record present this writes the record
471+
*back* to the widget instead -- after the widget's own call, never
472+
before -- and leaves the record itself untouched.
473+
474+
Ordering is load-bearing in both branches. Seeded before the widget
475+
call, the record holds the previous plane and is one populate behind;
476+
written back before it, the widget's defaults win and the drag is
477+
lost. Either compiles, and either passes any test that does not
478+
assert the order.
479+
"""
415480
self._cross_section_widget.update_bounds(bounds)
416481
self._bounding_box_widget.update_bounds(bounds)
482+
if self._last_cross_section_state is None:
483+
plane = self._cross_section_widget.plane
484+
self._last_cross_section_state = VisorCrossSectionState(
485+
origin=list(plane.GetOrigin()),
486+
normal=list(plane.GetNormal()),
487+
)
488+
else:
489+
self._cross_section_widget.set_origin(self._last_cross_section_state.origin)
490+
self._cross_section_widget.set_normal(self._last_cross_section_state.normal)
417491

418492
def update_actor_count(self, count: int) -> None:
419493
"""See :meth:`IRenderer.update_actor_count`."""

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

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,19 @@
88
annotated return the simplest valid empty value for that type; all others are
99
``pass``. No VTK imports, no local view, no side effects.
1010
11-
One exception to "no state": the camera record, ``_last_camera_state``. The
12-
record half of the :class:`IRenderer` camera contract is not optional on any
13-
implementation -- only the projection half is, and here it is a no-op because
14-
there is no pipeline camera to project onto.
11+
One exception to "no state": the camera record, ``_last_camera_state``, and
12+
the cross-section plane record, ``_last_cross_section_state``. The record
13+
half of the :class:`IRenderer` camera and plane contracts is not optional on
14+
any implementation -- only the projection half is, and here it is a no-op
15+
because there is no pipeline camera and no widget to project onto.
1516
"""
1617

1718
from __future__ import annotations
1819

1920
from typing import TYPE_CHECKING, Optional
2021

2122
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
23+
from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState
2224
from ansys.visor.viewer.renderer.base import IRenderer
2325

2426
if TYPE_CHECKING:
@@ -34,10 +36,12 @@ class NullRenderer(IRenderer):
3436
"""Null-object implementation of :class:`IRenderer` for use in tests."""
3537

3638
_last_camera_state: Optional["VisorCameraState"]
39+
_last_cross_section_state: Optional["VisorCrossSectionState"]
3740

3841
def __init__(self) -> None:
39-
"""Initialize the camera record."""
42+
"""Initialize the camera and cross-section records."""
4043
self._last_camera_state = None
44+
self._last_cross_section_state = None
4145

4246
# ------------------------------------------------------------------
4347
# Wire contract
@@ -159,7 +163,28 @@ def set_cross_section_visibility(self, visible: bool) -> None:
159163
def sync_cross_section_plane(
160164
self, origin: list[float], normal: list[float]
161165
) -> None:
162-
pass
166+
"""See :meth:`IRenderer.sync_cross_section_plane`.
167+
168+
Record only: there are no VTK widget objects to project onto. The
169+
record takes a copy of each list, as :class:`VisorLocalRenderer` does.
170+
"""
171+
self._last_cross_section_state = VisorCrossSectionState(
172+
origin=list(origin), normal=list(normal)
173+
)
174+
175+
def get_cross_section_plane(self) -> "VisorCrossSectionState | None":
176+
"""See :meth:`IRenderer.get_cross_section_plane`.
177+
178+
``None`` unless :meth:`sync_cross_section_plane` wrote one: there is
179+
no widget here for :meth:`update_bounds` to seed a record from.
180+
"""
181+
return self._last_cross_section_state
182+
183+
def serialize_cross_section_state(self) -> None:
184+
"""See :meth:`IRenderer.serialize_cross_section_state`.
185+
186+
No-op: this renderer serves the client no VTK object state.
187+
"""
163188

164189
def set_bounding_box_visibility(self, visible: bool) -> None:
165190
pass

0 commit comments

Comments
 (0)