Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7c19f79
feat: server-tracked widget toggles with triggers, coordinator and de…
LKasianAnsys Sep 18, 2026
ce25308
feat: logging triggers on backend
LKasianAnsys Sep 18, 2026
0fb5901
feat: set_projection trigger writes the camera record
LKasianAnsys Sep 18, 2026
3470968
feat: update tests after extra debug logging added
LKasianAnsys Sep 18, 2026
cf25627
feat: client reports widget toggles, seeds the projection flag, loads…
LKasianAnsys Sep 18, 2026
813dc5c
feat: trigger arrival line carries the parsed payload
LKasianAnsys Sep 21, 2026
763506c
chore: adding changelog file 137.added.md [dependabot-skip]
pyansys-ci-bot Sep 22, 2026
2ba0f7d
chore: adding changelog file 137.added.md [dependabot-skip]
pyansys-ci-bot Sep 22, 2026
3c1fed3
prettier fix
LKasianAnsys Sep 22, 2026
dbe93d8
rename ScenePartStateApi -> SceneMutationApi and clean up docs
LKasianAnsys Sep 23, 2026
9844980
clean up docs
LKasianAnsys Sep 23, 2026
eb2237c
rename _part_state_api -> _mutation_api
LKasianAnsys Sep 24, 2026
490b545
fix naming in tests
LKasianAnsys Sep 24, 2026
d0ac961
Merge branch 'main' into feat/3.3a-server-tracked-widget-toggles
LKasianAnsys Sep 28, 2026
4c30abf
feat: fold per-part edge visibility into the global toggle
LKasianAnsys Sep 21, 2026
bd7f442
chore: adding changelog file 138.added.md [dependabot-skip]
pyansys-ci-bot Sep 22, 2026
902effd
feat: server-authoritative cross-section plane
LKasianAnsys Sep 22, 2026
c03e220
feat: report the cross-section plane at the end of drag
LKasianAnsys Sep 22, 2026
d09e8ad
chore: adding changelog file 139.added.md [dependabot-skip]
pyansys-ci-bot Sep 22, 2026
5234c19
prettier fixes
LKasianAnsys Sep 22, 2026
dd423b7
move finalize_scene to apply_state instead of when adding datasets
LKasianAnsys Sep 23, 2026
99ce651
rename ScenePartStateApi -> SceneMutationApi and clean up docs
LKasianAnsys Sep 23, 2026
1ffcdd2
clean up docstring
LKasianAnsys Sep 24, 2026
b9222fd
rename part_state_api to mutation_api
LKasianAnsys Sep 24, 2026
8671114
Merge branch 'main' into feat/3.3c-server-authoritative-cross-section…
LKasianAnsys Sep 29, 2026
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