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/167.maintenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Remote rendering 3.6c] Reserialize actor state inside the renderer apply methods
63 changes: 33 additions & 30 deletions src/ansys/visor/viewer/renderer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,29 @@ def deregister_all(self) -> None:

@abstractmethod
def apply_visibility(self, node_id: int, visible: bool) -> None:
"""Set actor visibility for *node_id*."""
"""Set actor visibility for *node_id*.

On return, the state this renderer serves to the client reflects the
write, and the client is not notified.
"""

@abstractmethod
def apply_opacity(self, node_id: int, opacity: float) -> None:
"""Set actor opacity for *node_id*."""
"""Set actor opacity for *node_id*.

On return, the state this renderer serves to the client reflects the
write, and the client is not notified.
"""

@abstractmethod
def apply_diffuse_color(
self, node_id: int, r: float, g: float, b: float
) -> None:
"""Set the actor-property diffuse colour for *node_id*."""
"""Set the actor-property diffuse colour for *node_id*.

On return, the state this renderer serves to the client reflects the
write, and the client is not notified.
"""

@abstractmethod
def apply_selected(
Expand All @@ -118,6 +130,9 @@ def apply_selected(
*diffuse_rgb* is the part's stored diffuse colour, used when
deselecting to restore the property DiffuseColor alongside resetting
Ambient/Diffuse.

On return, the state this renderer serves to the client reflects the
write, and the client is not notified.
"""

@abstractmethod
Expand Down Expand Up @@ -173,6 +188,9 @@ def reset_camera(self, bounds: list[float]) -> None:
An implementation with no pipeline camera leaves the record at its
previous value rather than clearing it, so that a reset cannot destroy
a camera the frontend reported.

On return, the state this renderer serves to the client reflects the
write, and the client is not notified.
"""

@abstractmethod
Expand All @@ -189,6 +207,9 @@ def sync_camera(self, camera_state: "VisorCameraState") -> None:

The record stores the object as given, without copying: callers rely
on object identity through :meth:`get_camera_state`.

On return, the state this renderer serves to the client reflects the
write, and the client is not notified.
"""

@abstractmethod
Expand All @@ -200,24 +221,10 @@ def set_projection(self, parallel: bool) -> None:
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.

The camera alone. Writing the pipeline camera
makes the server correct: it does not make the state the client is
served correct, and the two are separate steps that can each silently
do nothing.
:meth:`reset_camera` imports it.

**Serialize only; do not notify.** Pushing to the client is
:meth:`flush_wasm_state`'s job and carries a rebuild race that the
load path deliberately refuses.

No-op on a renderer that serves the client no VTK object state.
On return, the state this renderer serves to the client reflects the
write, and the client is not notified.
"""

# ------------------------------------------------------------------------
Expand All @@ -231,6 +238,9 @@ def set_edges_visible(self, visible: bool) -> None:
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.

On return, the state this renderer serves to the client reflects the
write, and the client is not notified.
"""

@abstractmethod
Expand All @@ -244,6 +254,9 @@ def sync_cross_section_plane(
"""
Sync the cross-section plane origin and normal from the frontend back
to server-side VTK objects.

On return, the state this renderer serves to the client reflects the
write, and the client is not notified.
"""

@abstractmethod
Expand All @@ -253,16 +266,6 @@ def get_cross_section_plane(self) -> "VisorCrossSectionState | None":
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:
Expand Down
96 changes: 76 additions & 20 deletions src/ansys/visor/viewer/renderer/local_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from trame_server import Server
from trame_vtklocal.widgets.vtklocal import LocalView
from vtkmodules.vtkCommonCore import vtkObject
from vtkmodules.vtkCommonDataModel import vtkDataObject
from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera
from vtkmodules.vtkRenderingCore import (
Expand Down Expand Up @@ -59,8 +60,8 @@ class VisorLocalRenderer(IRenderer):
_orientation_widget: VisorOrientationWidget
_cross_section_widget: VisorCrossSectionWidget
_bounding_box_widget: VisorBoundingBox
# Registered pipelines keyed by scene-graph node id. Sole owner as of
# Increment I5; mutated exclusively via register_node / deregister_node.
# Registered pipelines keyed by scene-graph node id. Sole owner;
# mutated exclusively via register_node / deregister_node.
_pipelines: dict[int, VtkNodePipeline]
_last_camera_state: Optional["VisorCameraState"]
_last_cross_section_state: Optional["VisorCrossSectionState"]
Expand Down Expand Up @@ -166,6 +167,10 @@ def apply_visibility(self, node_id: int, visible: bool) -> None:
Resolves the pipeline and delegates to
:meth:`VtkNodePipeline.set_visibility`. An unknown *node_id* is a
logged no-op, never a raise.

Ends with :meth:`_serialize_vtk_object` on the actor, after the
write, so the state served to the client carries the new visibility.
Does not notify the client.
"""
pipe = self._pipelines.get(node_id)
if pipe is None:
Expand All @@ -174,13 +179,18 @@ def apply_visibility(self, node_id: int, visible: bool) -> None:
)
return
pipe.set_visibility(visible)
self._serialize_vtk_object(pipe.actor)

def apply_opacity(self, node_id: int, opacity: float) -> None:
"""See :meth:`IRenderer.apply_opacity`.

Resolves the pipeline and delegates to
:meth:`VtkNodePipeline.set_opacity`. An unknown *node_id* is a
logged no-op, never a raise.

Ends with :meth:`_serialize_vtk_object` on the actor's property,
after the write, so the state served to the client carries the new
opacity. Does not notify the client.
"""
pipe = self._pipelines.get(node_id)
if pipe is None:
Expand All @@ -189,6 +199,7 @@ def apply_opacity(self, node_id: int, opacity: float) -> None:
)
return
pipe.set_opacity(opacity)
self._serialize_vtk_object(pipe.actor.GetProperty())

def apply_diffuse_color(
self, node_id: int, r: float, g: float, b: float
Expand All @@ -198,6 +209,10 @@ def apply_diffuse_color(
Resolves the pipeline and delegates to
:meth:`VtkNodePipeline.set_diffuse_color`. An unknown *node_id*
is a logged no-op, never a raise.

Ends with :meth:`_serialize_vtk_object` on the actor's property,
after the write, so the state served to the client carries the new
colour. Does not notify the client.
"""
pipe = self._pipelines.get(node_id)
if pipe is None:
Expand All @@ -206,6 +221,7 @@ def apply_diffuse_color(
)
return
pipe.set_diffuse_color(r, g, b)
self._serialize_vtk_object(pipe.actor.GetProperty())

def apply_selected(
self, node_id: int, selected: bool, diffuse_rgb: list
Expand All @@ -217,6 +233,10 @@ def apply_selected(
through as given; supplying a default when the part has no stored
colour is the coordinator's job, not the renderer's. An unknown
*node_id* is a logged no-op, never a raise.

Ends with :meth:`_serialize_vtk_object` on the actor's property,
after the write, so the state served to the client carries the new
lighting terms and colour. Does not notify the client.
"""
pipe = self._pipelines.get(node_id)
if pipe is None:
Expand All @@ -225,6 +245,7 @@ def apply_selected(
)
return
pipe.set_selected(selected, diffuse_rgb)
self._serialize_vtk_object(pipe.actor.GetProperty())

def apply_color_variable(
self,
Expand Down Expand Up @@ -289,34 +310,53 @@ def _serialize_part_state(self, node_id: int) -> None:
*node_id* is a logged no-op.

Names the mapper's id alone, derived with ``GetId`` on the object as
:meth:`serialize_camera_state` derives the camera's. An id of ``0``
is one the store has never held (the ROOT sentinel); naming it
degrades to an error-logged no-op in VTK, so it is skipped here with
a debug line and the next full serialization carries the mapper.
:meth:`_serialize_camera_state` derives the camera's; the id lookup
and the unregistered-object skip are :meth:`_serialize_vtk_object`'s.
"""
pipe = self._pipelines.get(node_id)
if pipe is None:
logger.debug(
"_serialize_part_state: no pipeline for node %s; skipping.", node_id
)
return
mapper_id = self._object_manager.GetId(pipe.mapper)
if mapper_id == 0:
self._serialize_vtk_object(pipe.mapper)

def _serialize_vtk_object(self, vtk_object: vtkObject) -> None:
"""Re-serialize one VTK object into the state served to the client.

``vtklocal`` advertises each object's modification time off the live
object but serves its content from a serialization cache, so a write
without this publishes a new version number against the old content.
**Serialize only; do not notify.**

An id of ``0`` is one the store has never held (the ROOT sentinel);
naming it degrades to an error-logged no-op in VTK, so it is skipped
here with a debug line and the next full serialization carries the
object.
"""
object_id = self._object_manager.GetId(vtk_object)
if object_id == 0:
logger.debug(
"_serialize_part_state: mapper of node %s is not registered yet; skipping.",
node_id,
"_serialize_vtk_object: %s is not registered yet; skipping.",
vtk_object.GetClassName(),
)
return
self._object_manager.UpdateStateFromObject(mapper_id)
self._object_manager.UpdateStateFromObject(object_id)

# ------------------------------------------------------------------
# IRenderer: camera
# ------------------------------------------------------------------

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

Ends with :meth:`_serialize_camera_state`, after the reset, so the
state served to the client carries the reset camera. Does not
notify the client.
"""
self._vtk_renderer.ResetCamera(bounds)
self._last_camera_state = self._read_pipeline_camera()
self._serialize_camera_state()

def get_camera_state(self) -> Optional["VisorCameraState"]:
"""See :meth:`IRenderer.get_camera_state`."""
Expand All @@ -326,16 +366,20 @@ def sync_camera(self, camera_state: "VisorCameraState") -> None:
"""See :meth:`IRenderer.sync_camera`.

Stores before projecting, so a raising VTK setter still leaves the
record holding what the earlier caller asked for.
record holding what the earlier caller asked for. Ends with
:meth:`_serialize_camera_state`, after the projection. Does not
notify the client.
"""
self._last_camera_state = camera_state
self._apply_to_pipeline_camera(camera_state)
self._serialize_camera_state()

def set_projection(self, parallel: bool) -> None:
"""See :meth:`IRenderer.set_projection`.

Record first, pipeline second, for the reason :meth:`sync_camera`
gives.
gives. Ends with :meth:`_serialize_camera_state`, after the pipeline
write. Does not notify the client.
"""
if self._last_camera_state is not None:
self._last_camera_state.parallel_projection = parallel
Expand All @@ -344,9 +388,10 @@ def set_projection(self, parallel: bool) -> None:
"set_projection: no camera record; applying to the pipeline only."
)
self._vtk_renderer.GetActiveCamera().SetParallelProjection(parallel)
self._serialize_camera_state()

def serialize_camera_state(self) -> None:
"""See :meth:`IRenderer.serialize_camera_state`.
def _serialize_camera_state(self) -> None:
"""Make the state served to the client current for the camera.

``vtklocal`` advertises each object's modification time off the live
VTK object but serves state out of a serialization cache, so a write
Expand Down Expand Up @@ -423,9 +468,15 @@ def set_edges_visible(self, visible: bool) -> None:
Fans out over every registered pipeline. Scene-wide, so there is no
node id to resolve and no logged-no-op branch: a scene with no
pipelines is a no-op by iteration, not by guard.

Each pipeline's property is re-serialized with
:meth:`_serialize_vtk_object` right after its own write, so the state
served to the client carries the new edge flag on every part. Does
not notify the client.
"""
for pipe in self._pipelines.values():
pipe.set_edge_visibility(visible)
self._serialize_vtk_object(pipe.actor.GetProperty())

def set_cross_section_visibility(self, visible: bool) -> None:
"""No-op in Story 1.2. Phase 3 populates."""
Expand All @@ -439,22 +490,27 @@ def sync_cross_section_plane(
``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.

Ends with :meth:`_serialize_cross_section_state`, after both writes,
so the state served to the client carries the new plane. Does not
notify the client.
"""
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)
self._serialize_cross_section_state()

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`.
def _serialize_cross_section_state(self) -> None:
"""Make the state served to the client current for the plane.

Two ids, named one at a time, each derived the way
:meth:`serialize_camera_state` derives the camera's: ``GetId`` on the
: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.
Expand All @@ -466,7 +522,7 @@ def serialize_cross_section_state(self) -> None:

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.
enablement.
"""
self._object_manager.UpdateStateFromObject(
self._object_manager.GetId(self._cross_section_widget.plane)
Expand Down
Loading
Loading