Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
38c5e9e
add per-part write path to the dataset registry
LKasianAnsys Aug 28, 2026
ac71d50
remove story/increment reference in comment
LKasianAnsys Aug 28, 2026
ee0b843
chore: adding changelog file 50.added.md [dependabot-skip]
pyansys-ci-bot Aug 28, 2026
ff688aa
chore: adding changelog file 50.added.md [dependabot-skip]
pyansys-ci-bot Aug 28, 2026
ccc2f0f
implement visibility, opacity and diffuse-color applies on the local …
LKasianAnsys Aug 28, 2026
c62294f
move selection and color-variable applies onto VtkNodePipeline
LKasianAnsys Aug 28, 2026
361c20e
chore: adding changelog file 51.added.md [dependabot-skip]
pyansys-ci-bot Aug 28, 2026
0c50362
move remaining three vtk calls to node pipeline
LKasianAnsys Aug 31, 2026
174ee07
register per-part trigger handlers and serialize VTK access with a lock
LKasianAnsys Aug 28, 2026
34b27f0
replace hand-written payloads in LocalApp with Pydantic models and de…
LKasianAnsys Aug 28, 2026
77f9a9d
comment out wasm flush
LKasianAnsys Aug 28, 2026
44233a4
chore: adding changelog file 52.added.md [dependabot-skip]
pyansys-ci-bot Aug 28, 2026
85b6630
remove TODOs and update comment on flush_wasm_state
LKasianAnsys Aug 31, 2026
786211f
Merge branch 'main' into feat/3.1a-registry-per-part-write-path
LKasianAnsys Aug 31, 2026
3cc3e94
Merge branch 'feat/3.1a-registry-per-part-write-path' into feat/3.1b-…
LKasianAnsys Aug 31, 2026
0477060
Merge branch 'feat/3.1b-per-part-apply-logic' into feat/3.1c-triggers…
LKasianAnsys Aug 31, 2026
310fb42
Merge branch 'main' into feat/3.1b-per-part-apply-logic
LKasianAnsys Sep 1, 2026
ce953c0
Merge branch 'main' into feat/3.1b-per-part-apply-logic
LKasianAnsys Sep 1, 2026
1e0b372
Merge branch 'main' into feat/3.1b-per-part-apply-logic
LKasianAnsys Sep 2, 2026
28b33ab
Merge branch 'main' into feat/3.1b-per-part-apply-logic
LKasianAnsys Sep 2, 2026
2903ac7
Merge branch 'feat/3.1b-per-part-apply-logic' into feat/3.1c-triggers…
LKasianAnsys Sep 2, 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/51.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remote rendering 3.1b - implement per-part apply logic on the renderer and node pipeline
1 change: 1 addition & 0 deletions doc/changelog.d/52.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remote rendering 3.1c - register per-part triggers, serialize VTK access with lock, trigger payload validation
262 changes: 262 additions & 0 deletions src/ansys/visor/viewer/app/trame/local_app.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import functools
import urllib.parse
from logging import Logger
from typing import List, Optional, Protocol

from pydantic import BaseModel, Field, ValidationError
from trame.app.core import Server
from trame.decorators import TrameApp, trigger
from vtk import vtkObject

from ansys.visor.viewer.config import settings
from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger

logger = VisorDefaultLogger(__name__)
Expand All @@ -14,6 +18,142 @@
vtkObject.GlobalWarningDisplayOff()


class ScenePartStateApi(Protocol):
"""Structural type of the per-part coordinator surface LocalApp calls.

Typing only: there is no ``runtime_checkable`` decoration and no
``isinstance`` check anywhere against it. Declaring it here rather than
importing the scene keeps this module free of any scene type, so the
injected object remains LocalApp's only route to the scene.
"""

def set_part_visibility(self, node_id: int, visible: bool) -> None: ...

def set_part_opacity(self, node_id: int, opacity: float) -> None: ...

def set_part_diffuse_color(self, node_id: int, diffuse_rgb: Optional[List[float]]) -> None: ...

def set_part_selected(self, node_id: int, selected: bool) -> None: ...

def set_part_color_variable(
self,
node_id: int,
variable_id: str,
association: VisorVtkVariableType,
array_name: str,
component: int,
min_val: float,
max_val: float,
) -> None: ...

def clear_part_color_variable(self, node_id: int) -> None: ...


# ----------------------------------------------------------------------
# Trigger payload models
#
# One model per per-part trigger. Field names are snake_case; the
# camelCase wire keys the client sends are carried as pydantic aliases.
## ----------------------------------------------------------------------


class SetPartVisibilityPayload(BaseModel):
"""Payload of the ``set_part_visibility`` trigger."""

node_id: int = Field(alias="nodeId")
visible: bool


class SetPartOpacityPayload(BaseModel):
"""Payload of the ``set_part_opacity`` trigger."""

node_id: int = Field(alias="nodeId")
opacity: float = Field(ge=0.0, le=1.0)


class SetPartDiffuseColorPayload(BaseModel):
"""Payload of the ``set_part_diffuse_color`` trigger."""

node_id: int = Field(alias="nodeId")
diffuse_rgb: Optional[List[float]] = Field(min_length=3, max_length=3, alias="diffuseRgb")


class SetPartSelectedPayload(BaseModel):
"""Payload of the ``set_part_selected`` trigger.

No colour crosses this trigger: the server reads the part's stored
diffuse colour from its own record.
"""

node_id: int = Field(alias="nodeId")
selected: bool


class SetPartColorVariablePayload(BaseModel):
"""Payload of the ``set_part_color_variable`` trigger."""

node_id: int = Field(alias="nodeId")
variable_id: str = Field(alias="variableId")
association: VisorVtkVariableType
array_name: str = Field(alias="arrayName")
component: int
min_val: float = Field(alias="min")
max_val: float = Field(alias="max")


class ClearPartColorVariablePayload(BaseModel):
"""Payload of the ``clear_part_color_variable`` trigger."""

node_id: int = Field(alias="nodeId")


def parse_payload(model: type[BaseModel]):
"""Validate a trigger payload into *model*, or make the call a logged no-op.

The wrapped handler receives the parsed model in place of the raw
``dict``. A payload that does not validate never reaches the handler
body: it is logged at warning and the trigger returns ``None``.

Posture. This applies the same logged-no-op posture the whole per-part
path uses for an unresolvable node id, extended to a malformed payload.
The reasoning transfers because it is about the *thread*, not about the
kind of badness: trigger handlers run on trame's daemon event-loop
thread, where a raise surfaces to no caller who can act on it. Before
this decorator the handlers indexed their payloads directly and a
missing key raised ``KeyError`` there.

``model_validate``, never ``model(**payload)``. A payload that is not
a mapping at all -- a bare string, a number, a list -- raises
``TypeError`` from ``**`` but a well-formed ``ValidationError`` from
``model_validate``. Only the latter lets one guard catch every
malformed shape instead of most of them.

Decorator order. ``@trigger(...)`` goes **outermost**, above this one.
Both orders happen to work: trame's ``@trigger`` stamps
``_trame_trigger_names`` on whatever function it is handed, and
``functools.wraps`` copies ``__dict__`` outward, so an inner
``@trigger`` is still found by ``TrameApp``'s registration loop.
Outermost is the order whose correctness does not depend on that
copying behaviour, so it is the one that is correct by design rather
than by accident.
"""

def decorate(handler):
@functools.wraps(handler)
def wrapper(self, payload):
try:
parsed = model.model_validate(payload)
except ValidationError as exc:
logger.warning(
"%s: invalid payload; ignoring. %s", handler.__name__, exc
)
return None
return handler(self, parsed)

return wrapper

return decorate


@TrameApp()
class LocalApp:
Expand All @@ -29,6 +169,12 @@ class LocalApp:
pick_geometry: picks the geometry for rendering
perf_report_wasm: reports the performance of the wasm update cycle
perf_report_server_update: reports the performance of the server update cycle
set_part_visibility: sets whether one part is visible
set_part_opacity: sets one part's opacity
set_part_diffuse_color: sets or clears one part's custom diffuse colour
set_part_selected: selects or deselects one part
set_part_color_variable: colours one part by a scalar variable
clear_part_color_variable: stops colouring one part by a scalar variable
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 All @@ -41,6 +187,7 @@ def __init__(
standalone: bool = True,
trame_logger: Logger | None = None,
pick_geometry=None,
scene_part_state_api: ScenePartStateApi | None = None,
):
self.server = server
# Callable to get the scene details in JSON format
Expand All @@ -49,6 +196,10 @@ def __init__(
self._handle_save_state_response = handle_save_state_response
# Callable for sub-geometry picking (optional)
self._pick_geometry = pick_geometry
# Per-part visual state coordinator (see ScenePartStateApi). The one
# production construction site always supplies it; it is optional so
# that the class stays constructible without a scene.
self._scene_part_state_api = scene_part_state_api
# logger for logging trame server lifecycle info
self.__trame_logger = trame_logger

Expand Down Expand Up @@ -154,6 +305,117 @@ def perf_report_server_update(self, payload: dict):
f" | handler={payload.get('handlerMs', 0):.2f}ms"
)

# ------------------------------------------------------------------
# Per-part visual state triggers
#
# Frontend -> Backend. Each takes a single ``payload: dict`` argument
# and returns ``None``; each delegates to the identically-named method
# on the injected coordinator. Every payload carries the absolute
# target value, never a toggle or a delta, so a message the client
# suppresses as redundant is indistinguishable from one that set a
# value a part already had.
#
# Payloads are validated at this boundary by ``@parse_payload``, which
# hands the handler a parsed model instead of the raw dict. A payload
# that does not validate -- a missing key, a wrong-typed value, an
# association that is not an enum member, a diffuse colour that is not
# three components, an opacity outside [0, 1], or a payload that is not
# a mapping at all -- is a logged no-op and never reaches a handler
# body.
# ------------------------------------------------------------------

def _part_state_api(self, trigger_name: str) -> ScenePartStateApi | None:
"""Return the injected coordinator, or ``None`` after logging."""
if self._scene_part_state_api is None:
logger.debug("%s: no scene part-state API injected; ignoring.", trigger_name)
return None
return self._scene_part_state_api

@trigger("set_part_visibility")
@parse_payload(SetPartVisibilityPayload)
def set_part_visibility(self, payload) -> None:
"""Frontend -> Backend: set whether one part is visible."""
api = self._part_state_api("set_part_visibility")
if api is None:
return
api.set_part_visibility(payload.node_id, payload.visible)

@trigger("set_part_opacity")
@parse_payload(SetPartOpacityPayload)
def set_part_opacity(self, payload) -> None:
"""Frontend -> Backend: set one part's opacity.

An opacity outside ``[0.0, 1.0]`` fails validation and is a logged
no-op; it does not reach VTK to be clamped.
"""
api = self._part_state_api("set_part_opacity")
if api is None:
return
api.set_part_opacity(payload.node_id, payload.opacity)

@trigger("set_part_diffuse_color")
@parse_payload(SetPartDiffuseColorPayload)
def set_part_diffuse_color(self, payload) -> None:
"""Frontend -> Backend: set one part's custom diffuse colour.

``diffuseRgb`` of ``None`` clears the custom colour and is forwarded
as ``None``; no default colour is substituted here. An absent key,
or a colour that is not exactly three components, is a logged
no-op -- nothing is delegated, so nothing is written to the store.
"""
api = self._part_state_api("set_part_diffuse_color")
if api is None:
return
api.set_part_diffuse_color(payload.node_id, payload.diffuse_rgb)

@trigger("set_part_selected")
@parse_payload(SetPartSelectedPayload)
def set_part_selected(self, payload) -> None:
"""Frontend -> Backend: select or deselect one part.

No colour crosses this trigger: the server reads the part's stored
diffuse colour from its own record.
"""
api = self._part_state_api("set_part_selected")
if api is None:
return
api.set_part_selected(payload.node_id, payload.selected)

@trigger("set_part_color_variable")
@parse_payload(SetPartColorVariablePayload)
def set_part_color_variable(self, payload) -> None:
"""Frontend -> Backend: colour one part by a scalar variable.

``association`` is resolved at this boundary into a
:class:`VisorVtkVariableType` by the payload model, which matches by
exact value -- never upper-cased, never passed on as a bare string.
A value that is not a member fails validation and is a logged
no-op, matching the posture the pipeline takes on an unknown array
name. ``variableId`` is forwarded verbatim and is never parsed by
the server.
"""
api = self._part_state_api("set_part_color_variable")
if api is None:
return
api.set_part_color_variable(
payload.node_id,
payload.variable_id,
payload.association,
payload.array_name,
payload.component,
payload.min_val,
payload.max_val,
)

@trigger("clear_part_color_variable")
@parse_payload(ClearPartColorVariablePayload)
def clear_part_color_variable(self, payload) -> None:
"""Frontend -> Backend: stop colouring one part by a scalar variable."""
api = self._part_state_api("clear_part_color_variable")
if api is None:
return
api.clear_part_color_variable(payload.node_id)

def set_only_cookie(self, key: str, value: str):
"""
Sets a cookie on the server. NOTE: there is a limitation
Expand Down
4 changes: 4 additions & 0 deletions src/ansys/visor/viewer/app/visor_vtk_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,9 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) ->
world_x,
world_y,
world_z: self._scene.pick_geometry(actor_wasm_id, cell_id, mode, world_x, world_y, world_z),
# The scene satisfies LocalApp's ScenePartStateApi protocol
# structurally: the six per-part coordinator methods carry exactly
# the names and signatures the triggers call.
scene_part_state_api=self._scene,
)

Loading
Loading