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/139.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Remote rendering 3.3c] server-authoritative cross-section plane
26 changes: 26 additions & 0 deletions src/ansys/visor/viewer/app/trame/local_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
SetCrossSectionVisibilityPayload,
SetEdgesVisiblePayload,
SetProjectionPayload,
SyncCrossSectionPlanePayload,
)

logger = VisorDefaultLogger(__name__)
Expand Down Expand Up @@ -71,6 +72,10 @@ 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: ...


# ----------------------------------------------------------------------
# Trigger payload models
Expand Down Expand Up @@ -203,6 +208,7 @@ class LocalApp:
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_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 Down Expand Up @@ -541,6 +547,26 @@ def set_projection(self, payload) -> 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)

def set_only_cookie(self, key: str, value: str):
"""
Sets a cookie on the server. NOTE: there is a limitation
Expand Down
3 changes: 0 additions & 3 deletions src/ansys/visor/viewer/app/visor_vtk.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,6 @@ def _load_datasets_from_state(self, state: "PersistedViewerStateV1") -> None:
if dataset is not None:
dataset.mark_clean()

if self._scene.dataset_count > 0:
self._scene.finalize_scene(skip_reset_camera=True)

""" Protected Methods (async start and stop for FastAPI) """
@require_server_off
@validate_input_metadata_types
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,17 @@
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.

``SyncCrossSectionPlanePayload`` is not a toggle either. It is the plane
the client's drag settled on, reported at end of interaction, and it
carries two three-component vectors rather than a boolean. It lives here
rather than beside the per-part models for the same reason the four above
do: it is scene-wide and carries no ``nodeId``.
"""

from pydantic import BaseModel, ConfigDict
from typing import List

from pydantic import BaseModel, ConfigDict, Field


class SetCrossSectionVisibilityPayload(BaseModel):
Expand Down Expand Up @@ -47,3 +55,13 @@ class SetProjectionPayload(BaseModel):
model_config = ConfigDict(populate_by_name=True)

parallel: bool


class SyncCrossSectionPlanePayload(BaseModel):
"""Payload of the ``sync_cross_section_plane`` trigger."""

model_config = ConfigDict(populate_by_name=True)

origin: List[float] = Field(min_length=3, max_length=3)
normal: List[float] = Field(min_length=3, max_length=3)

21 changes: 21 additions & 0 deletions src/ansys/visor/viewer/renderer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
from vtkmodules.vtkCommonDataModel import vtkDataObject

from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
from ansys.visor.viewer.models.common.visor_cross_section_state import (
VisorCrossSectionState,
)
from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import RendererAnnotation
from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraphPartNode

Expand Down Expand Up @@ -254,6 +257,24 @@ def sync_cross_section_plane(
to server-side VTK objects.
"""

@abstractmethod
def get_cross_section_plane(self) -> "VisorCrossSectionState | None":
"""
Return the cross-section plane record, or ``None`` if nothing has
written one yet.
"""

@abstractmethod
def serialize_cross_section_state(self) -> None:
"""Make the state served to the client current for the plane.

Names its ids explicitly and never relies on a render following.

**Serialize only; do not notify.**

No-op on a renderer that serves the client no VTK object state.
"""

@abstractmethod
def set_bounding_box_visibility(self, visible: bool) -> None:
"""Show or hide the bounding-box outline."""
Expand Down
78 changes: 76 additions & 2 deletions src/ansys/visor/viewer/renderer/local_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from ansys.visor.viewer.core.visor_colors import VisorColors
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.common.visor_cross_section_state import VisorCrossSectionState
from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import (
WasmNodeHandles,
WasmRendererAnnotation,
Expand Down Expand Up @@ -62,12 +63,14 @@ class VisorLocalRenderer(IRenderer):
# Increment I5; mutated exclusively via register_node / deregister_node.
_pipelines: dict[int, VtkNodePipeline]
_last_camera_state: Optional["VisorCameraState"]
_last_cross_section_state: Optional["VisorCrossSectionState"]

def __init__(self, server: Server):
"""Build the full local-mode VTK infrastructure and seed wasm state."""
self._server = server
self._pipelines = {}
self._last_camera_state = None
self._last_cross_section_state = None

self._vtk_renderer = self._initialize_vtk_renderer()
self._render_window = self._initialize_render_window()
Expand Down Expand Up @@ -401,7 +404,47 @@ def set_cross_section_visibility(self, visible: bool) -> None:
def sync_cross_section_plane(
self, origin: list[float], normal: list[float]
) -> None:
"""No-op in Story 1.2. Phase 3 populates."""
"""See :meth:`IRenderer.sync_cross_section_plane`.

Writes the record, then both VTK objects. ``set_origin`` and
``set_normal`` each write the plane *and* the representation, so the
clip function and the draggable handle cannot diverge on this path --
which is the defect the client's own set/get asymmetry has.
"""
self._last_cross_section_state = VisorCrossSectionState(
origin=list(origin), normal=list(normal)
)
self._cross_section_widget.set_origin(origin)
self._cross_section_widget.set_normal(normal)

def get_cross_section_plane(self) -> Optional["VisorCrossSectionState"]:
"""See :meth:`IRenderer.get_cross_section_plane`."""
return self._last_cross_section_state

def serialize_cross_section_state(self) -> None:
"""See :meth:`IRenderer.serialize_cross_section_state`.

Two ids, named one at a time, each derived the way
:meth:`serialize_camera_state` derives the camera's: ``GetId`` on the
object itself, not the id ``register_vtk_object`` returned. Deriving
it this way means the equivalence of those two id spaces never has to
be established.

The plane is the clip function every pipeline holds; the
representation is the visible handle and is also what the client
reads on save. A plane-only re-serialise leaves the client saving a
stale handle.

The widget id is deliberately not named: ``set_origin`` and
``set_normal`` do not write the widget, whose changing state is
enablement. See MC-6.
"""
self._object_manager.UpdateStateFromObject(
self._object_manager.GetId(self._cross_section_widget.plane)
)
self._object_manager.UpdateStateFromObject(
self._object_manager.GetId(self._cross_section_widget.plane_representation)
)

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

def update_bounds(self, bounds: list[float]) -> None:
"""See :meth:`IRenderer.update_bounds`."""
"""See :meth:`IRenderer.update_bounds`.

Seeds the cross-section record from the values the widget's own
``update_bounds`` just wrote through ``set_origin``/``set_normal``, so
the record is never ``None`` once a scene has been populated and
``get_state`` can assign it unconditionally, as it does the camera's.

**The seed runs only when there is no record.** This method is not
reached once per scene: ``set_part_visibility`` fans out to
``_update_widget_bounds``, so a part toggle or a dataset add reaches
here too. Seeding unconditionally would therefore discard a plane the
user had dragged, on the next toggle, and the widget's own
``update_bounds`` would already have overwritten both VTK objects with
the default plane. With a record present this writes the record
*back* to the widget instead -- after the widget's own call, never
before -- and leaves the record itself untouched.

Ordering is load-bearing in both branches. Seeded before the widget
call, the record holds the previous plane and is one populate behind;
written back before it, the widget's defaults win and the drag is
lost. Either compiles, and either passes any test that does not
assert the order.
"""
self._cross_section_widget.update_bounds(bounds)
self._bounding_box_widget.update_bounds(bounds)
if self._last_cross_section_state is None:
plane = self._cross_section_widget.plane
self._last_cross_section_state = VisorCrossSectionState(
origin=list(plane.GetOrigin()),
normal=list(plane.GetNormal()),
)
else:
self._cross_section_widget.set_origin(self._last_cross_section_state.origin)
self._cross_section_widget.set_normal(self._last_cross_section_state.normal)

def update_actor_count(self, count: int) -> None:
"""See :meth:`IRenderer.update_actor_count`."""
Expand Down
37 changes: 31 additions & 6 deletions src/ansys/visor/viewer/renderer/null_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@
annotated return the simplest valid empty value for that type; all others are
``pass``. No VTK imports, no local view, no side effects.

One exception to "no state": the camera record, ``_last_camera_state``. The
record half of the :class:`IRenderer` camera contract is not optional on any
implementation -- only the projection half is, and here it is a no-op because
there is no pipeline camera to project onto.
One exception to "no state": the camera record, ``_last_camera_state``, and
the cross-section plane record, ``_last_cross_section_state``. The record
half of the :class:`IRenderer` camera and plane contracts is not optional on
any implementation -- only the projection half is, and here it is a no-op
because there is no pipeline camera and no widget to project onto.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Optional

from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState
from ansys.visor.viewer.renderer.base import IRenderer

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

_last_camera_state: Optional["VisorCameraState"]
_last_cross_section_state: Optional["VisorCrossSectionState"]

def __init__(self) -> None:
"""Initialize the camera record."""
"""Initialize the camera and cross-section records."""
self._last_camera_state = None
self._last_cross_section_state = None

# ------------------------------------------------------------------
# Wire contract
Expand Down Expand Up @@ -159,7 +163,28 @@ def set_cross_section_visibility(self, visible: bool) -> None:
def sync_cross_section_plane(
self, origin: list[float], normal: list[float]
) -> None:
pass
"""See :meth:`IRenderer.sync_cross_section_plane`.

Record only: there are no VTK widget objects to project onto. The
record takes a copy of each list, as :class:`VisorLocalRenderer` does.
"""
self._last_cross_section_state = VisorCrossSectionState(
origin=list(origin), normal=list(normal)
)

def get_cross_section_plane(self) -> "VisorCrossSectionState | None":
"""See :meth:`IRenderer.get_cross_section_plane`.

``None`` unless :meth:`sync_cross_section_plane` wrote one: there is
no widget here for :meth:`update_bounds` to seed a record from.
"""
return self._last_cross_section_state

def serialize_cross_section_state(self) -> None:
"""See :meth:`IRenderer.serialize_cross_section_state`.

No-op: this renderer serves the client no VTK object state.
"""

def set_bounding_box_visibility(self, visible: bool) -> None:
pass
Expand Down
Loading
Loading