diff --git a/doc/changelog.d/51.added.md b/doc/changelog.d/51.added.md new file mode 100644 index 00000000..d9507670 --- /dev/null +++ b/doc/changelog.d/51.added.md @@ -0,0 +1 @@ +Remote rendering 3.1b - implement per-part apply logic on the renderer and node pipeline diff --git a/doc/changelog.d/52.added.md b/doc/changelog.d/52.added.md new file mode 100644 index 00000000..9d38fdcb --- /dev/null +++ b/doc/changelog.d/52.added.md @@ -0,0 +1 @@ +Remote rendering 3.1c - register per-part triggers, serialize VTK access with lock, trigger payload validation diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index 8d2b545c..23abd24f 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -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__) @@ -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: @@ -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. @@ -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 @@ -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 @@ -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 diff --git a/src/ansys/visor/viewer/app/visor_vtk_local.py b/src/ansys/visor/viewer/app/visor_vtk_local.py index 154aa70e..d61b4483 100644 --- a/src/ansys/visor/viewer/app/visor_vtk_local.py +++ b/src/ansys/visor/viewer/app/visor_vtk_local.py @@ -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, ) diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index 4d07003a..e0dd8670 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -158,15 +158,51 @@ def deregister_all(self) -> None: # ------------------------------------------------------------------ def apply_visibility(self, node_id: int, visible: bool) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.apply_visibility`. + + Resolves the pipeline and delegates to + :meth:`VtkNodePipeline.set_visibility`. An unknown *node_id* is a + logged no-op, never a raise. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "apply_visibility: no pipeline for node %s; skipping.", node_id + ) + return + pipe.set_visibility(visible) def apply_opacity(self, node_id: int, opacity: float) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """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. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "apply_opacity: no pipeline for node %s; skipping.", node_id + ) + return + pipe.set_opacity(opacity) def apply_diffuse_color( self, node_id: int, r: float, g: float, b: float ) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.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. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "apply_diffuse_color: no pipeline for node %s; skipping.", node_id + ) + return + pipe.set_diffuse_color(r, g, b) def apply_edge_visibility(self, node_id: int, edge_visible: bool) -> None: """No-op in Story 1.2. Phase 3 populates.""" @@ -174,7 +210,21 @@ def apply_edge_visibility(self, node_id: int, edge_visible: bool) -> None: def apply_selected( self, node_id: int, selected: bool, diffuse_rgb: list ) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.apply_selected`. + + Resolves the pipeline and delegates to + :meth:`VtkNodePipeline.set_selected`. *diffuse_rgb* is passed + 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. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "apply_selected: no pipeline for node %s; skipping.", node_id + ) + return + pipe.set_selected(selected, diffuse_rgb) def apply_color_variable( self, @@ -186,10 +236,39 @@ def apply_color_variable( min_val: float, max_val: float, ) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.apply_color_variable`. + + Resolves the pipeline and delegates to + :meth:`VtkNodePipeline.set_color_variable`. *array_type* must + already be a :class:`VisorVtkVariableType`; it is parsed at the + trigger boundary, never here, and the pipeline compares it by + identity, so any other value is a logged no-op there. + *spectrum_id* is not forwarded -- it is stored opaquely by the + registry and is not needed to configure the mapper. An unknown + *node_id* is a logged no-op, never a raise. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "apply_color_variable: no pipeline for node %s; skipping.", node_id + ) + return + pipe.set_color_variable(array_type, array_name, component, min_val, max_val) def clear_color_variable(self, node_id: int) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.clear_color_variable`. + + Resolves the pipeline and delegates to + :meth:`VtkNodePipeline.clear_color_variable`. An unknown + *node_id* is a logged no-op, never a raise. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "clear_color_variable: no pipeline for node %s; skipping.", node_id + ) + return + pipe.clear_color_variable() def refresh_color_variable_range( self, diff --git a/src/ansys/visor/viewer/vtk/node_pipeline.py b/src/ansys/visor/viewer/vtk/node_pipeline.py index 7adeb5ad..fa611e0b 100644 --- a/src/ansys/visor/viewer/vtk/node_pipeline.py +++ b/src/ansys/visor/viewer/vtk/node_pipeline.py @@ -27,6 +27,7 @@ from vtkmodules.vtkRenderingCore import vtkActor, vtkPolyDataMapper from ansys.visor.viewer.core.visor_colors import VisorColors +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger logger = VisorDefaultLogger(__name__) @@ -105,6 +106,145 @@ def update_input( algorithm = algorithm_filter(algorithm) self.mapper.SetInputConnection(algorithm.GetOutputPort()) + # ------------------------------------------------------------------ + # Per-part visual mutations + # + # These bodies live here rather than on the renderer because each is + # more than one VTK call, and the colour-variable body additionally + # reads a pipeline-internal object (``base_algorithm``). Identity + # resolution (node id -> pipeline) and the miss branch stay on the + # renderer, which is the only holder of the id -> pipeline map. + # ------------------------------------------------------------------ + + def set_selected(self, selected: bool, diffuse_rgb: list[float]) -> None: + """Apply or remove the selection highlight on this part. + + Parameters + ---------- + selected: + Target state. Absolute, never a toggle. + diffuse_rgb: + The part's diffuse colour, re-applied unconditionally on both + branches -- selection changes the ambient/diffuse lighting + terms, it does not replace the part's colour. + + Note: :meth:`set_diffuse_color` also writes ``SetDiffuseColor``; + this method is not a substitute for it and vice versa. + """ + prop = self.actor.GetProperty() + if selected: + prop.SetAmbientColor(0 / 255, 62 / 255, 111 / 255) + prop.SetDiffuse(0.5) + prop.SetAmbient(0.5) + else: + prop.SetDiffuse(1.0) + prop.SetAmbient(0.0) + prop.SetDiffuseColor(*diffuse_rgb) + + def set_visibility(self, visible: bool) -> None: + """Show or hide this part. + + Mutates the actor itself, not its property. + + Parameters + ---------- + visible: + Target state. Absolute, never a toggle. + """ + self.actor.SetVisibility(1 if visible else 0) + + def set_opacity(self, opacity: float) -> None: + """Set this part's opacity. + + Mutates the actor's property. + + Parameters + ---------- + opacity: + The opacity value to apply, passed through unchanged. + """ + self.actor.GetProperty().SetOpacity(opacity) + + def set_diffuse_color(self, r: float, g: float, b: float) -> None: + """Set this part's diffuse colour. + + Mutates the actor property's diffuse colour only. + + Parameters + ---------- + r, g, b: + The diffuse colour components, passed through unchanged. + + Note: :meth:`set_selected` also writes ``SetDiffuseColor`` on both + of its branches; this method is not a substitute for it and vice + versa. + """ + self.actor.GetProperty().SetDiffuseColor(r, g, b) + + def set_color_variable( + self, + association: VisorVtkVariableType, + array_name: str, + component: int, + min_val: float, + max_val: float, + ) -> None: + """Colour this part by a scalar array, over an explicit range. + + Configures the mapper only. No lookup table is authored here: the + table belongs to a later story, and until then a reference resolves + against the client-held default table. + + ``association`` is compared by identity against + :class:`VisorVtkVariableType`; it is never parsed, upper-cased or + string-compared. A value that is neither member, and an array name + that does not exist on the input, are both logged no-ops that + mutate nothing. + """ + in_data = self.base_algorithm.GetInput() + if association is VisorVtkVariableType.POINT: + field = in_data.GetPointData() + elif association is VisorVtkVariableType.CELL: + field = in_data.GetCellData() + else: + logger.warning( + "set_color_variable: association %r is not a VisorVtkVariableType; " + "skipping.", + association, + ) + return + + if field.GetArray(array_name) is None: + logger.warning( + "set_color_variable: array %r not found for association %s; skipping.", + array_name, + association, + ) + return + + mapper = self.mapper + if association is VisorVtkVariableType.POINT: + mapper.SetScalarModeToUsePointFieldData() + else: + mapper.SetScalarModeToUseCellFieldData() + mapper.SelectColorArray(array_name) + mapper.SetArrayComponent(component) + mapper.SetScalarRange(min_val, max_val) + mapper.SetColorModeToMapScalars() + mapper.SetScalarVisibility(True) + # Makes the mapper honour the range set above rather than the + # lookup table's own range. + mapper.SetUseLookupTableScalarRange(0) + + def clear_color_variable(self) -> None: + """Stop colouring this part by a scalar array. + + One call. Does not touch a lookup table and does not restore a + diffuse colour -- the part's colour is whatever was last applied + to it. + """ + self.mapper.SetScalarVisibility(False) + # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index efcadb0a..a7cbd706 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -1,6 +1,7 @@ """VTK scene management for Visor Viewer.""" import json +import threading from abc import ABC, abstractmethod from typing import TYPE_CHECKING, List @@ -8,6 +9,8 @@ from ansys.visor.viewer.core.metadata import ExtendedMetadata from ansys.visor.viewer.core.perf_timer import PerfTimer +from ansys.visor.viewer.core.visor_colors import VisorColors +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.core.visor_types import VisorDatasetType from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 @@ -74,6 +77,16 @@ def __init__( self._scene_graph = None self._pipelines = {} + # Serialises every server-side VTK mutation and wasm push. + # + # @trigger handlers run on the trame server's daemon background-thread + # event loop, while the VTK objects they mutate are created and also + # mutated from the caller's (notebook/main) thread. Re-entrant because + # the locked paths nest: finalize_scene -> populate_scene -> + # update_widgets, finalize_scene -> render, and the per-part + # coordinator methods -> apply -> flush. + self._vtk_lock = threading.RLock() + self._dataset_registry = self._initialize_dataset_registry() if renderer is not None: @@ -151,16 +164,20 @@ def apply_state(self, state: PersistedViewerStateV1): Shared work (per-part state restoration) is done here; the renderer-specific final step is delegated to :meth:`_apply_runtime_state_to_render`. + + Holds ``_vtk_lock`` for the whole body: the delegated step mutates + VTK and pushes to the frontend. """ - # Apply UI settings - self.dark_mode = state.ui.dark_theme + with self._vtk_lock: + # Apply UI settings + self.dark_mode = state.ui.dark_theme - # Transform the frontend PersistedViewerStateV1 -> RuntimeAppState - runtime_app_state = self._state_mapper.persisted_to_runtime(state) + # Transform the frontend PersistedViewerStateV1 -> RuntimeAppState + runtime_app_state = self._state_mapper.persisted_to_runtime(state) - # Renderer-specific: flush VTK window and notify frontend (wasm), or - # push camera to vtkCamera (RCA), or no-op (headless). - self._apply_runtime_state_to_render(runtime_app_state) + # Renderer-specific: flush VTK window and notify frontend (wasm), or + # push camera to vtkCamera (RCA), or no-op (headless). + self._apply_runtime_state_to_render(runtime_app_state) def get_scene_details(self) -> VisorSceneDetails: """Return the VisorState.""" @@ -185,11 +202,15 @@ def handle_save_state_response(self, request_id: int, response: dict) -> None: self._frontend_bridge.resolve_save_state_response(request_id, response) def clear(self): - """Remove all actors from the renderer and reset the scene.""" - if self._scene_graph is not None: - self._renderer.deregister_all() - self._dataset_registry.clear() - self._scene_graph = None + """Remove all actors from the renderer and reset the scene. + + Holds ``_vtk_lock``: deregistering actors mutates the VTK renderer. + """ + with self._vtk_lock: + if self._scene_graph is not None: + self._renderer.deregister_all() + self._dataset_registry.clear() + self._scene_graph = None def populate_scene(self): """Update widgets to reflect the current scene contents. @@ -197,121 +218,158 @@ def populate_scene(self): Actor attach is done per-leaf inside :meth:`add_dataset` via :meth:`IRenderer.register_node`, so this method only refreshes the widget bounds and count. + + Holds ``_vtk_lock``: the widget refresh mutates VTK widget objects. """ - if self._scene_graph is None: - self._initialize_scene_graph() - self.update_widgets() + with self._vtk_lock: + if self._scene_graph is None: + self._initialize_scene_graph() + self.update_widgets() def finalize_scene(self, skip_reset_camera: bool = False): - """Populate the scene, reset the camera if applicable, then render.""" - self.populate_scene() + """Populate the scene, reset the camera if applicable, then render. + + Holds ``_vtk_lock`` across all three steps; the nested acquisitions in + :meth:`populate_scene`, :meth:`reset_camera` and :meth:`render` are + re-entrant on the same thread. + """ + with self._vtk_lock: + self.populate_scene() - if not skip_reset_camera: - # if there is one or zero datasets in the scene, reset the camera - if self._dataset_registry.count <= 1: - self.reset_camera() + if not skip_reset_camera: + # if there is one or zero datasets in the scene, reset the camera + if self._dataset_registry.count <= 1: + self.reset_camera() - self.render() + self.render() def update_widgets(self): - """Reset the widgets to fit the scene graph bounds.""" - if self._scene_graph is None: - msg = "Scene graph has not been initialized, cannot reset widgets and camera." - logger.error(msg) - raise RuntimeError(msg) + """Reset the widgets to fit the scene graph bounds. + + Holds ``_vtk_lock``: the widget bounds update mutates the + cross-section representation/plane and the bounding-box outline. + """ + with self._vtk_lock: + if self._scene_graph is None: + msg = "Scene graph has not been initialized, cannot reset widgets and camera." + logger.error(msg) + raise RuntimeError(msg) - self._update_widget_bounds() - self._update_actor_count() + self._update_widget_bounds() + self._update_actor_count() def add_dataset(self, input: VisorDatasetType, metadata: ExtendedMetadata) -> int: - """Set the input dataset and metadata for the scene graph.""" - if input is None: - msg = "Input dataset is None, cannot load dataset." - logger.error(msg) - raise ValueError(msg) + """Set the input dataset and metadata for the scene graph. - # Ensure unique dataset name - dataset_name = self._dataset_registry.get_sanitized_metadata_name(metadata) + Holds ``_vtk_lock``: node registration attaches actors to the VTK + renderer. + """ + with self._vtk_lock: + if input is None: + msg = "Input dataset is None, cannot load dataset." + logger.error(msg) + raise ValueError(msg) - # Initialize the scene graph if it does not exist - if self._scene_graph is None: - self._initialize_scene_graph() + # Ensure unique dataset name + dataset_name = self._dataset_registry.get_sanitized_metadata_name(metadata) - # Load the dataset into the scene graph - dataset_id = self._scene_graph.load_dataset(input, dataset_name) + # Initialize the scene graph if it does not exist + if self._scene_graph is None: + self._initialize_scene_graph() - # Register each leaf's pipeline with the renderer. - subtree = self._scene_graph.get_descendant_node(dataset_id, include_self=True) - if subtree is not None: - for leaf in subtree.get_descendant_part_nodes(include_self=True): - self._renderer.register_node(leaf, leaf.dataset) + # Load the dataset into the scene graph + dataset_id = self._scene_graph.load_dataset(input, dataset_name) - # Seed PartIndex with scene-graph node IDs so that part_id == scene-graph node ID, - # which is the contract the frontend relies on to apply per-part state (opacity etc.). - part_name_to_id = self._scene_graph.get_part_name_to_id_map(dataset_id) + # Register each leaf's pipeline with the renderer. + subtree = self._scene_graph.get_descendant_node(dataset_id, include_self=True) + if subtree is not None: + for leaf in subtree.get_descendant_part_nodes(include_self=True): + self._renderer.register_node(leaf, leaf.dataset) - self._dataset_registry.add(dataset_id, dataset_name, input, part_name_to_id, metadata) + # Seed PartIndex with scene-graph node IDs so that part_id == scene-graph node ID, + # which is the contract the frontend relies on to apply per-part state (opacity etc.). + part_name_to_id = self._scene_graph.get_part_name_to_id_map(dataset_id) - return dataset_id + self._dataset_registry.add(dataset_id, dataset_name, input, part_name_to_id, metadata) + + return dataset_id def remove_dataset(self, dataset_id: int): - """Remove a dataset from the scene graph.""" - if self._scene_graph is None: - msg = "Scene graph has not been initialized, cannot load dataset." - logger.error(msg) - raise RuntimeError(msg) + """Remove a dataset from the scene graph. - # Deregister each leaf's pipeline from the renderer before dropping - # the subtree from the scene graph. - node = self._scene_graph.get_descendant_node(dataset_id) - if node is None: - msg = f"Dataset with id {dataset_id} not found in scene graph." - logger.error(msg) - raise ValueError(msg) + Holds ``_vtk_lock``: node deregistration detaches actors from the VTK + renderer. + """ + with self._vtk_lock: + if self._scene_graph is None: + msg = "Scene graph has not been initialized, cannot load dataset." + logger.error(msg) + raise RuntimeError(msg) - for leaf in node.get_descendant_part_nodes(include_self=True): - self._renderer.deregister_node(leaf.id) + # Deregister each leaf's pipeline from the renderer before dropping + # the subtree from the scene graph. + node = self._scene_graph.get_descendant_node(dataset_id) + if node is None: + msg = f"Dataset with id {dataset_id} not found in scene graph." + logger.error(msg) + raise ValueError(msg) - # Remove dataset from the scene graph - self._scene_graph.remove_dataset(dataset_id) + for leaf in node.get_descendant_part_nodes(include_self=True): + self._renderer.deregister_node(leaf.id) - # Unregister the dataset - self._dataset_registry.remove(dataset_id) + # Remove dataset from the scene graph + self._scene_graph.remove_dataset(dataset_id) + + # Unregister the dataset + self._dataset_registry.remove(dataset_id) def list_variables_for_dataset(self, dataset_id: int) -> List[VisorPartVariables]: return self._dataset_registry.list_variables(dataset_id) def update_variables_for_dataset(self, dataset_id: int, variables: List[VisorVariableUpdate]) -> None: - """Update the variables for the dataset in the scene graph.""" - timer = PerfTimer("update_variables_for_dataset", logger, dataset=dataset_id) + """Update the variables for the dataset in the scene graph. - with timer.phase("dataset_registry.update"): - self._dataset_registry.update_variables(dataset_id, variables) + Holds ``_vtk_lock``: the registry update writes into VTK arrays in + place and the render pushes to wasm. + """ + with self._vtk_lock: + timer = PerfTimer("update_variables_for_dataset", logger, dataset=dataset_id) - # Refresh cached variable metadata on part nodes without re-wiring - # the VTK pipeline (which would call SetInputConnection + mark the - # mapper Modified, causing unnecessary re-serialisation of geometry - # that has not changed). - dataset_node = self._scene_graph.get_descendant_node(dataset_id) - with timer.phase("update_descendant_parts"): - dataset_node.refresh_descendant_variable_metadata(include_self=True) + with timer.phase("dataset_registry.update"): + self._dataset_registry.update_variables(dataset_id, variables) - with timer.phase("render"): - self.render() + # Refresh cached variable metadata on part nodes without re-wiring + # the VTK pipeline (which would call SetInputConnection + mark the + # mapper Modified, causing unnecessary re-serialisation of geometry + # that has not changed). + dataset_node = self._scene_graph.get_descendant_node(dataset_id) + with timer.phase("update_descendant_parts"): + dataset_node.refresh_descendant_variable_metadata(include_self=True) - timer.log() + with timer.phase("render"): + self.render() + + timer.log() def render(self): - """Delegate to the renderer backend.""" - self._renderer.render() + """Delegate to the renderer backend. + + Holds ``_vtk_lock``: renders the VTK window and pushes to wasm. + """ + with self._vtk_lock: + self._renderer.render() def reset_camera(self): - """Reset the camera to fit the scene graph bounds.""" - if self._scene_graph is None: - logger.debug("Scene graph not initialized; skipping camera reset.") - return + """Reset the camera to fit the scene graph bounds. + + Holds ``_vtk_lock``: mutates the VTK renderer's camera. + """ + with self._vtk_lock: + if self._scene_graph is None: + logger.debug("Scene graph not initialized; skipping camera reset.") + return - self._renderer.reset_camera(self._scene_graph.bounds) + self._renderer.reset_camera(self._scene_graph.bounds) def pick_geometry(self, actor_wasm_id, cell_id, mode, world_x, world_y, world_z) -> dict: """ @@ -322,6 +380,121 @@ def pick_geometry(self, actor_wasm_id, cell_id, mode, world_x, world_y, world_z) actor_wasm_id, cell_id, mode, (world_x, world_y, world_z) ) + # ========================================================================= + # Per-part visual state — coordinator surface + # + # Each method does both halves of its trigger, in this order and all under + # ``_vtk_lock``: write the registry record, apply to the server's VTK + # pipeline. Nothing is pushed to the client from here. The client applies + # its own change, and a push at this layer rebuilds the client, which + # re-delivers state and fires further triggers. Presenting a server-originated + # change is the renderer's, since it is not mode-agnostic. + # An unresolvable node id is a logged no-op at the apply layer. + # + # Every value that arrives here is absolute, never relative: the caller + # always supplies the target value, never a toggle or a delta. + # ========================================================================= + + def set_part_visibility(self, node_id: int, visible: bool) -> None: + """Set whether the part identified by *node_id* is visible.""" + with self._vtk_lock: + if not self._dataset_registry.set_part_visibility(node_id, visible): + logger.debug("set_part_visibility: no dataset owns node %s; skipping.", node_id) + return + self._renderer.apply_visibility(node_id, visible) + + def set_part_opacity(self, node_id: int, opacity: float) -> None: + """Set the opacity of the part identified by *node_id*.""" + with self._vtk_lock: + if not self._dataset_registry.set_part_opacity(node_id, opacity): + logger.debug("set_part_opacity: no dataset owns node %s; skipping.", node_id) + return + self._renderer.apply_opacity(node_id, opacity) + + def set_part_diffuse_color(self, node_id: int, diffuse_rgb: list[float] | None) -> None: + """ + Set the custom diffuse colour of the part identified by *node_id*, or + clear it with ``None``. + + The store records the absence as absence: ``diffuse_rgb=None`` is + written through as ``None``. The pipeline needs a concrete colour, so + the apply falls back to :attr:`VisorColors.DefaultMeshColor` — "no + custom colour" means "the scene-wide default". + """ + with self._vtk_lock: + if not self._dataset_registry.set_part_diffuse_color(node_id, diffuse_rgb): + logger.debug("set_part_diffuse_color: no dataset owns node %s; skipping.", node_id) + return + applied_rgb = ( + diffuse_rgb if diffuse_rgb is not None else list(VisorColors.DefaultMeshColor) + ) + self._renderer.apply_diffuse_color( + node_id, applied_rgb[0], applied_rgb[1], applied_rgb[2] + ) + + def set_part_selected(self, node_id: int, selected: bool) -> None: + """ + Select or deselect the part identified by *node_id*. + + No colour crosses the trigger for this class: the server reads the + part's stored ``diffuse_rgb`` from its own record and falls back to + :attr:`VisorColors.DefaultMeshColor` when it is ``None``. The record + is guaranteed to exist here — the setter above returned ``True``, + which means it either found the record or upserted one — so + ``get_part_state`` cannot return ``None`` at this point. + """ + with self._vtk_lock: + if not self._dataset_registry.set_part_selected(node_id, selected): + logger.debug("set_part_selected: no dataset owns node %s; skipping.", node_id) + return + stored_rgb = self._dataset_registry.get_part_state(node_id).diffuse_rgb + diffuse_rgb = ( + stored_rgb if stored_rgb is not None else list(VisorColors.DefaultMeshColor) + ) + self._renderer.apply_selected(node_id, selected, diffuse_rgb) + + 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: + """ + Colour the part identified by *node_id* by a scalar variable. + + *variable_id* is stored opaquely and is never parsed here; the + association and array name arrive as explicit arguments. *association* + is already a :class:`VisorVtkVariableType` — it is parsed at the + trigger boundary, never derived from a string here. The range travels + as a parameter only and is not persisted per part. + """ + with self._vtk_lock: + if not self._dataset_registry.set_part_color_variable(node_id, variable_id, component): + logger.debug("set_part_color_variable: no dataset owns node %s; skipping.", node_id) + return + self._renderer.apply_color_variable( + node_id, variable_id, association, array_name, component, min_val, max_val + ) + + def clear_part_color_variable(self, node_id: int) -> None: + """ + Stop colouring the part identified by *node_id* by a scalar variable. + + The variable reference is cleared atomically in the store (id and + component together), matching the atomic set. + """ + with self._vtk_lock: + if not self._dataset_registry.clear_part_color_variable(node_id): + logger.debug( + "clear_part_color_variable: no dataset owns node %s; skipping.", node_id + ) + return + self._renderer.clear_color_variable(node_id) + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ diff --git a/tests/unit/app/test_local_app.py b/tests/unit/app/test_local_app.py new file mode 100644 index 00000000..25ea4498 --- /dev/null +++ b/tests/unit/app/test_local_app.py @@ -0,0 +1,456 @@ +"""Unit tests for LocalApp's per-part visual-state triggers. + +Coverage targets +---------------- +1. Each of the six triggers unpacks its payload and delegates to the + identically-named method on the injected coordinator object, returning + ``None``. +2. ``association`` is parsed at this boundary into a ``VisorVtkVariableType`` + member -- by exact value lookup, never by upper-casing -- and a value that + is not a member is a logged no-op that delegates nothing. +3. ``variableId`` crosses opaquely: forwarded byte-identically, never parsed. +4. ``diffuseRgb`` of ``None`` is forwarded as ``None`` (a clear), and + ``set_part_selected`` carries no colour at all. +5. With no coordinator injected, every trigger is a logged no-op. +6. A malformed payload is a logged no-op and never reaches a handler body: + a missing required key, a wrong-typed value, a payload that is not a + mapping at all, a ``diffuseRgb`` that is not exactly three components, + or an opacity outside ``[0.0, 1.0]``. +7. An unknown extra key is ignored, not rejected: the rest of the payload + is still forwarded. +8. All six trigger names are still registered with the trame server after + the payload decorator wraps the handlers, and the registered callable + still accepts a raw ``dict``. +9. The two logged-no-op paths stay distinct: an invalid payload logs + ``warning`` and no ``debug``; a missing coordinator logs ``debug`` and + no ``warning``. + +The coordinator is a single MagicMock standing in for the injected object; +what each trigger does with the values it forwards is asserted in +tests/unit/vtk/scene/test_base.py against the registry and the VTK objects. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from ansys.visor.viewer.app.trame.local_app import LocalApp +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType + +TRIGGER_NAMES = [ + "set_part_visibility", + "set_part_opacity", + "set_part_diffuse_color", + "set_part_selected", + "set_part_color_variable", + "clear_part_color_variable", +] + +# One payload per trigger, written out literally rather than built from the +# production code's key names. +PAYLOADS = { + "set_part_visibility": {"nodeId": 7, "visible": False}, + "set_part_opacity": {"nodeId": 7, "opacity": 0.25}, + "set_part_diffuse_color": {"nodeId": 7, "diffuseRgb": [1.0, 0.0, 0.0]}, + "set_part_selected": {"nodeId": 7, "selected": True}, + "set_part_color_variable": { + "nodeId": 7, + "variableId": "POINT::pressure::1", + "association": "POINT", + "arrayName": "pressure", + "component": 0, + "min": 0.0, + "max": 49.0, + }, + "clear_part_color_variable": {"nodeId": 7}, +} + + +class MockController: + """Minimal trame controller stand-in that records added handlers.""" + + def __init__(self): + self.handlers = {} + self.add_call_count = 0 + + def add(self, event): + self.add_call_count += 1 + + def decorator(fn): + self.handlers[event] = fn + return fn + + return decorator + + +@pytest.fixture +def mock_server(): + """Provide a mock Trame server.""" + server = MagicMock() + server.controller = MockController() + server.http_headers.set_header = MagicMock() + server.name = "TestServer" + server._www = None + return server + + +@pytest.fixture +def api(): + """Stand-in for the injected per-part coordinator object.""" + return MagicMock(name="scene_part_state_api") + + +@pytest.fixture +def app(mock_server, api): + """LocalApp with a coordinator injected.""" + return LocalApp( + server=mock_server, + get_scene_details_json=MagicMock(), + handle_save_state_response=MagicMock(), + standalone=True, + scene_part_state_api=api, + ) + + +@pytest.fixture +def app_without_api(mock_server): + """LocalApp with no coordinator injected.""" + return LocalApp( + server=mock_server, + get_scene_details_json=MagicMock(), + handle_save_state_response=MagicMock(), + standalone=True, + ) + + +# =========================================================================== +# Delegation +# =========================================================================== + +def test_set_part_visibility_delegates_payload_values(app, api): + """nodeId and visible reach the coordinator unchanged.""" + app.set_part_visibility({"nodeId": 7, "visible": False}) + + api.set_part_visibility.assert_called_once_with(7, False) + + +def test_set_part_opacity_delegates_payload_values(app, api): + """nodeId and opacity reach the coordinator unchanged.""" + app.set_part_opacity({"nodeId": 7, "opacity": 0.25}) + + api.set_part_opacity.assert_called_once_with(7, 0.25) + + +def test_set_part_diffuse_color_delegates_payload_values(app, api): + """nodeId and diffuseRgb reach the coordinator unchanged.""" + app.set_part_diffuse_color({"nodeId": 7, "diffuseRgb": [1.0, 0.0, 0.0]}) + + api.set_part_diffuse_color.assert_called_once_with(7, [1.0, 0.0, 0.0]) + + +def test_set_part_diffuse_color_forwards_none_as_a_clear(app, api): + """A None colour is forwarded as None; no default is substituted here.""" + app.set_part_diffuse_color({"nodeId": 7, "diffuseRgb": None}) + + api.set_part_diffuse_color.assert_called_once_with(7, None) + + +def test_set_part_selected_delegates_payload_values(app, api): + """nodeId and selected reach the coordinator unchanged.""" + app.set_part_selected({"nodeId": 7, "selected": True}) + + api.set_part_selected.assert_called_once_with(7, True) + + +def test_set_part_selected_carries_no_colour(app, api): + """The selection trigger passes exactly two arguments -- no colour.""" + app.set_part_selected({"nodeId": 7, "selected": True}) + + call = api.set_part_selected.call_args + assert len(call.args) == 2 + assert call.kwargs == {} + + +def test_set_part_color_variable_delegates_payload_values(app, api): + """Every colour-variable field reaches the coordinator in contract order.""" + app.set_part_color_variable( + { + "nodeId": 7, + "variableId": "POINT::pressure::1", + "association": "POINT", + "arrayName": "pressure", + "component": 0, + "min": 0.0, + "max": 49.0, + } + ) + + api.set_part_color_variable.assert_called_once_with( + 7, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 + ) + + +def test_set_part_color_variable_parses_point_association_to_the_enum_member(app, api): + """'POINT' arrives as the POINT member itself, not as a string.""" + app.set_part_color_variable( + { + "nodeId": 7, + "variableId": "POINT::pressure::1", + "association": "POINT", + "arrayName": "pressure", + "component": 0, + "min": 0.0, + "max": 1.0, + } + ) + + forwarded = api.set_part_color_variable.call_args.args[2] + assert forwarded is VisorVtkVariableType.POINT + + +def test_set_part_color_variable_parses_cell_association_to_the_enum_member(app, api): + """'CELL' arrives as the CELL member itself, not as a string.""" + app.set_part_color_variable( + { + "nodeId": 7, + "variableId": "CELL::temperature::1", + "association": "CELL", + "arrayName": "temperature", + "component": 0, + "min": 0.0, + "max": 1.0, + } + ) + + forwarded = api.set_part_color_variable.call_args.args[2] + assert forwarded is VisorVtkVariableType.CELL + + +@pytest.mark.parametrize("association", ["point", "cell", "NODE", "", None]) +def test_set_part_color_variable_non_member_association_is_a_logged_no_op( + app, api, association +): + """A non-member association warns and delegates nothing -- no upper-casing.""" + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + app.set_part_color_variable( + { + "nodeId": 7, + "variableId": "POINT::pressure::1", + "association": association, + "arrayName": "pressure", + "component": 0, + "min": 0.0, + "max": 1.0, + } + ) + + api.set_part_color_variable.assert_not_called() + assert mock_logger.warning.call_count == 1 + + +def test_set_part_color_variable_forwards_variable_id_byte_identically(app, api): + """variableId crosses opaquely: not split, not rebuilt, not normalised.""" + variable_id = "CELL::Von Mises::3" + app.set_part_color_variable( + { + "nodeId": 7, + "variableId": variable_id, + "association": "CELL", + "arrayName": "Von Mises", + "component": 2, + "min": -1.5, + "max": 1.5, + } + ) + + assert api.set_part_color_variable.call_args.args[1] == "CELL::Von Mises::3" + + +def test_clear_part_color_variable_delegates_payload_values(app, api): + """nodeId reaches the coordinator unchanged.""" + app.clear_part_color_variable({"nodeId": 7}) + + api.clear_part_color_variable.assert_called_once_with(7) + + +# =========================================================================== +# Return value and the not-injected guard +# =========================================================================== + +@pytest.mark.parametrize("name", TRIGGER_NAMES) +def test_trigger_returns_none(app, name): + """No trigger returns the coordinator's return value.""" + assert getattr(app, name)(PAYLOADS[name]) is None + + +@pytest.mark.parametrize("name", TRIGGER_NAMES) +def test_trigger_is_a_logged_no_op_when_no_coordinator_injected(app_without_api, name): + """With nothing injected, each trigger logs and returns without raising.""" + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert getattr(app_without_api, name)(PAYLOADS[name]) is None + + assert mock_logger.debug.call_count == 1 + + +# =========================================================================== +# Payload validation +# =========================================================================== + +# One required key per trigger, removed to make an otherwise valid payload +# malformed. Written out per trigger rather than derived from the models. +MISSING_KEY = { + "set_part_visibility": "visible", + "set_part_opacity": "opacity", + "set_part_diffuse_color": "diffuseRgb", + "set_part_selected": "selected", + "set_part_color_variable": "arrayName", + "clear_part_color_variable": "nodeId", +} + + +@pytest.mark.parametrize("name", TRIGGER_NAMES) +def test_missing_required_key_is_a_logged_no_op(app, api, name): + """A payload short one required key delegates nothing and warns once.""" + payload = {k: v for k, v in PAYLOADS[name].items() if k != MISSING_KEY[name]} + + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert getattr(app, name)(payload) is None + + getattr(api, name).assert_not_called() + assert mock_logger.warning.call_count == 1 + + +def test_wrong_typed_bool_value_is_a_logged_no_op(app, api): + """2 is not a bool: pydantic accepts 0 and 1 only, so this fails.""" + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert app.set_part_visibility({"nodeId": 7, "visible": 2}) is None + + api.set_part_visibility.assert_not_called() + assert mock_logger.warning.call_count == 1 + + +def test_wrong_typed_int_value_is_a_logged_no_op(app, api): + """A non-numeric string is not coerced to int: 'x' fails nodeId.""" + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert app.set_part_visibility({"nodeId": "x", "visible": False}) is None + + api.set_part_visibility.assert_not_called() + assert mock_logger.warning.call_count == 1 + + +@pytest.mark.parametrize("payload", ["nope", 42, None, [1, 2, 3]]) +def test_non_dict_payload_is_a_logged_no_op_not_a_type_error(app, api, payload): + """A payload that is not a mapping is caught, not raised as TypeError. + + This is why the decorator uses ``model_validate`` rather than + ``Model(**payload)``: the latter raises TypeError on a non-mapping, + which would escape the guard and land on the trame daemon thread. + """ + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert app.set_part_visibility(payload) is None + + api.set_part_visibility.assert_not_called() + assert mock_logger.warning.call_count == 1 + + +def test_unknown_extra_key_is_forwarded_not_rejected(app, api): + """A key this server does not know about is dropped, not an error.""" + app.set_part_visibility({"nodeId": 7, "visible": False, "someFutureKey": 1}) + + api.set_part_visibility.assert_called_once_with(7, False) + + +def test_set_part_diffuse_color_absent_key_is_a_validation_failure(app, api): + """An absent diffuseRgb is malformed -- distinct from an explicit null.""" + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert app.set_part_diffuse_color({"nodeId": 7}) is None + + api.set_part_diffuse_color.assert_not_called() + assert mock_logger.warning.call_count == 1 + + +def test_set_part_diffuse_color_two_element_colour_is_a_logged_no_op(app, api): + """The torn write: a short colour must not reach the coordinator. + + The coordinator writes the store first and only then indexes the colour + at [0], [1] and [2] to apply it. A two-element list therefore left the + store holding a malformed colour, the VTK object untouched, and an + IndexError on the trame daemon thread. Rejecting it here means nothing + is delegated, so nothing is written. + """ + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert app.set_part_diffuse_color({"nodeId": 7, "diffuseRgb": [1.0, 0.0]}) is None + + api.set_part_diffuse_color.assert_not_called() + assert mock_logger.warning.call_count == 1 + + +def test_set_part_opacity_out_of_range_is_a_logged_no_op(app, api): + """An opacity outside [0.0, 1.0] never reaches VTK to be clamped.""" + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert app.set_part_opacity({"nodeId": 7, "opacity": 5.0}) is None + + api.set_part_opacity.assert_not_called() + assert mock_logger.warning.call_count == 1 + + +# =========================================================================== +# Trigger registration survives decoration +# =========================================================================== + +def _registered_triggers(mock_server): + """Recover {trigger name: registered callable} from the mock server. + + TrameApp's wrapped __init__ registers each handler as + ``server.trigger(name)(fn)``, so the i-th name passed to ``trigger`` + pairs with the i-th function passed to its return value. + """ + names = [call.args[0] for call in mock_server.trigger.call_args_list] + functions = [call.args[0] for call in mock_server.trigger.return_value.call_args_list] + return dict(zip(names, functions)) + + +@pytest.mark.parametrize("name", TRIGGER_NAMES) +def test_trigger_name_is_still_registered_after_decoration(app, mock_server, name): + """Wrapping the handlers does not lose the @trigger registration.""" + assert name in _registered_triggers(mock_server) + + +def test_the_registered_callable_still_accepts_a_raw_dict(app, api, mock_server): + """Dispatch reaches the coordinator through the wrapper, not past it.""" + registered = _registered_triggers(mock_server) + + registered["set_part_visibility"]({"nodeId": 7, "visible": False}) + + api.set_part_visibility.assert_called_once_with(7, False) + + +# =========================================================================== +# The warning and debug paths stay distinct +# =========================================================================== +# +# Note: the parse now runs before the API lookup, so a malformed payload +# reaching a LocalApp with no coordinator injected logs warning, where it +# would previously have logged debug. Both are logged no-ops. + +@pytest.mark.parametrize("name", TRIGGER_NAMES) +def test_invalid_payload_logs_warning_and_not_debug(app, api, name): + """The validation no-op is a warning and is not confused with the other.""" + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert getattr(app, name)({"junk": True}) is None + + getattr(api, name).assert_not_called() + assert mock_logger.warning.call_count == 1 + assert mock_logger.debug.call_count == 0 + + +@pytest.mark.parametrize("name", TRIGGER_NAMES) +def test_missing_coordinator_logs_debug_and_not_warning(app_without_api, name): + """The not-injected no-op is a debug and is not confused with the other.""" + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert getattr(app_without_api, name)(PAYLOADS[name]) is None + + assert mock_logger.debug.call_count == 1 + assert mock_logger.warning.call_count == 0 + + diff --git a/tests/unit/app/test_visor_vtk_local.py b/tests/unit/app/test_visor_vtk_local.py index a65221e1..434f7fb5 100644 --- a/tests/unit/app/test_visor_vtk_local.py +++ b/tests/unit/app/test_visor_vtk_local.py @@ -517,3 +517,85 @@ def test_load_state_passes_correct_metadata_to_add_dataset(tmp_path, iface): iface._file_io.build_metadata_for_load_state.assert_called_once_with("mesh", "mm", ds_state) iface._file_io.read_snapshot.assert_called_once_with(str(snapshot)) iface._scene.add_dataset.assert_called_once_with(mock_data, built_meta) + + +# ================================================================== # +# LocalApp injection boundary +# ================================================================== # + +PART_STATE_API_METHODS = [ + "set_part_visibility", + "set_part_opacity", + "set_part_diffuse_color", + "set_part_selected", + "set_part_color_variable", + "clear_part_color_variable", +] + + +@pytest.fixture +def local_app_call(): + """Build a VisorVTK with LocalApp patched, and return the LocalApp call. + + The scene is patched too, so the returned call records exactly what + _initialize_rendering passed to LocalApp. + """ + with patch("ansys.visor.viewer.app.visor_vtk.TrameServerManager") as mock_mgr, \ + patch("ansys.visor.viewer.app.visor_vtk_local.LocalApp") as mock_local_app, \ + patch("ansys.visor.viewer.app.visor_vtk_local.VisorLocalScene") as mock_scene: + mock_mgr_inst = MagicMock() + mock_mgr_inst.running = True + mock_mgr_inst._server = MagicMock() + mock_mgr.return_value = mock_mgr_inst + mock_scene_inst = MagicMock() + mock_scene_inst.datasets = {} + mock_scene.return_value = mock_scene_inst + + instance = VisorVTK() + + return mock_local_app.call_args, mock_scene_inst, instance + + +def test_local_app_receives_the_scene_as_the_part_state_api(local_app_call): + """The scene itself is injected, not a wrapper or a set of lambdas.""" + call, scene, instance = local_app_call + + assert call.kwargs["scene_part_state_api"] is scene + assert call.kwargs["scene_part_state_api"] is instance._scene + + +def test_local_app_still_receives_the_pre_existing_arguments(local_app_call): + """The boundary is extended, not broken: the earlier arguments survive.""" + call, scene, instance = local_app_call + + server, get_scene_details_json, handle_save_state_response, standalone = call.args + assert server is instance.server + assert callable(get_scene_details_json) + assert callable(handle_save_state_response) + assert standalone is True + assert callable(call.kwargs["pick_geometry"]) + assert "trame_logger" in call.kwargs + + +def test_the_pre_existing_lambdas_still_delegate_to_the_scene(local_app_call): + """The three original callables are untouched and still reach the scene.""" + call, scene, _instance = local_app_call + + _server, get_scene_details_json, handle_save_state_response, _standalone = call.args + get_scene_details_json() + handle_save_state_response(1, {"ok": True}) + call.kwargs["pick_geometry"](2, 3, "vertex", 0.0, 1.0, 2.0) + + scene.get_scene_details_json.assert_called_once_with() + scene.handle_save_state_response.assert_called_once_with(1, {"ok": True}) + scene.pick_geometry.assert_called_once_with(2, 3, "vertex", 0.0, 1.0, 2.0) + + +@pytest.mark.parametrize("name", PART_STATE_API_METHODS) +def test_local_scene_satisfies_the_part_state_protocol(name): + """VisorLocalScene structurally provides every method the triggers call.""" + from ansys.visor.viewer.vtk.scene.local_scene import VisorLocalScene + + assert callable(getattr(VisorLocalScene, name)) + + diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index c0bd33ae..ac4c9266 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -6,8 +6,11 @@ 1. Node lifecycle -- register_node / deregister_node (the non-trivial seam). 2. Interface conformance -- NullRenderer and VisorLocalRenderer both satisfy IRenderer's abstract contract. -3. Per-part visual mutations -- one focused test per property; - tests assert they accept their arguments and return None. +3. Per-part visual mutations -- visibility, opacity, diffuse colour, + selection and colour variable all resolve the pipeline and delegate to + VtkNodePipeline (their VTK effects are asserted in + tests/unit/vtk/test_node_pipeline.py); edge visibility and the + colour-variable range refresh stay no-ops. 4. Camera round-trip -- reset_camera, sync_camera / get_camera_state. 5. Render / flush delegation. 6. pick_geometry -- vertex, edge, face modes. @@ -19,6 +22,7 @@ import pytest +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.renderer.base import IRenderer from ansys.visor.viewer.renderer.local_renderer import VisorLocalRenderer from ansys.visor.viewer.renderer.null_renderer import NullRenderer @@ -92,6 +96,7 @@ def GetNextActor(self): # noqa: N802 return a + # --------------------------------------------------------------------------- # Fixture: VisorLocalRenderer with all VTK infrastructure mocked # --------------------------------------------------------------------------- @@ -336,38 +341,196 @@ def test_deregister_all_empties_registry_and_detaches_all_actors(self, renderer) # =========================================================================== class TestPerPartMutations: - """Each method accepts its contract arguments and returns None without raising.""" - - def test_apply_visibility(self, renderer): - assert renderer.apply_visibility(1, True) is None - - def test_apply_opacity(self, renderer): - assert renderer.apply_opacity(1, 0.5) is None + """The two permanently un-implemented methods accept their arguments. - def test_apply_diffuse_color(self, renderer): - assert renderer.apply_diffuse_color(1, 1.0, 0.0, 0.0) is None + Both stay no-ops beyond this story: edge visibility is a global display + toggle, and the colour-variable range is not held per part. + """ def test_apply_edge_visibility(self, renderer): assert renderer.apply_edge_visibility(1, False) is None - def test_apply_selected(self, renderer): - assert renderer.apply_selected(1, True, [1.0, 0.0, 0.0]) is None - def test_apply_color_variable(self, renderer): + def test_refresh_color_variable_range(self, renderer): assert ( - renderer.apply_color_variable(1, "sp-1", "POINT", "pressure", -1, 0.0, 1.0) + renderer.refresh_color_variable_range(1, "sp-1", "CELL", "temp", 0) is None ) - def test_clear_color_variable(self, renderer): - assert renderer.clear_color_variable(1) is None - def test_refresh_color_variable_range(self, renderer): - assert ( - renderer.refresh_color_variable_range(1, "sp-1", "CELL", "temp", 0) - is None +# =========================================================================== +# 3b. Delegated apply bodies: visibility, opacity, diffuse colour, +# selection, colour variable +# +# The VTK effects of these live on VtkNodePipeline and are asserted in +# tests/unit/vtk/test_node_pipeline.py. What is asserted here is that +# the renderer resolved the pipeline and delegated with the arguments +# it was given, and that an unknown node id is a logged no-op. +# =========================================================================== + +class TestDelegatedApplyBodies: + + # ------------------------------------------------------------------ + # apply_visibility + # ------------------------------------------------------------------ + + def test_apply_visibility_delegates_to_pipeline(self, renderer): + """The visibility flag is passed straight through.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.apply_visibility(4, True) + + pipe.set_visibility.assert_called_once_with(True) + + def test_apply_visibility_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.apply_visibility(9999, False) # must not raise + + mock_logger.debug.assert_called_once() + pipe.set_visibility.assert_not_called() + + # ------------------------------------------------------------------ + # apply_opacity + # ------------------------------------------------------------------ + + def test_apply_opacity_delegates_to_pipeline(self, renderer): + """The opacity value is passed straight through.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.apply_opacity(4, 0.25) + + pipe.set_opacity.assert_called_once_with(0.25) + + def test_apply_opacity_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.apply_opacity(9999, 0.25) # must not raise + + mock_logger.debug.assert_called_once() + pipe.set_opacity.assert_not_called() + + # ------------------------------------------------------------------ + # apply_diffuse_color + # ------------------------------------------------------------------ + + def test_apply_diffuse_color_delegates_to_pipeline(self, renderer): + """r, g, b are passed straight through.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.apply_diffuse_color(4, 1.0, 0.0, 0.0) + + pipe.set_diffuse_color.assert_called_once_with(1.0, 0.0, 0.0) + + def test_apply_diffuse_color_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.apply_diffuse_color(9999, 1.0, 0.0, 0.0) # must not raise + + mock_logger.debug.assert_called_once() + pipe.set_diffuse_color.assert_not_called() + + # ------------------------------------------------------------------ + # apply_selected + # ------------------------------------------------------------------ + + def test_apply_selected_delegates_to_pipeline(self, renderer): + """Selection state and the given colour are passed straight through.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.apply_selected(4, True, [1.0, 0.0, 0.0]) + + pipe.set_selected.assert_called_once_with(True, [1.0, 0.0, 0.0]) + + def test_apply_selected_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.apply_selected(9999, True, [1.0, 0.0, 0.0]) # must not raise + + mock_logger.debug.assert_called_once() + pipe.set_selected.assert_not_called() + + # ------------------------------------------------------------------ + # apply_color_variable + # ------------------------------------------------------------------ + + def test_apply_color_variable_delegates_with_given_arguments(self, renderer): + """The association is forwarded unchanged; spectrum_id is not forwarded.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.apply_color_variable( + 4, "sp-1", VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5 ) + pipe.set_color_variable.assert_called_once_with( + VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5 + ) + + def test_apply_color_variable_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.apply_color_variable( + 9999, "sp-1", VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5 + ) # must not raise + + mock_logger.debug.assert_called_once() + pipe.set_color_variable.assert_not_called() + + # ------------------------------------------------------------------ + # clear_color_variable + # ------------------------------------------------------------------ + + def test_clear_color_variable_delegates_to_pipeline(self, renderer): + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.clear_color_variable(4) + + pipe.clear_color_variable.assert_called_once_with() + + def test_clear_color_variable_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.clear_color_variable(9999) # must not raise + + mock_logger.debug.assert_called_once() + pipe.clear_color_variable.assert_not_called() + # =========================================================================== # 4. Camera diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 39a3dc76..922a0e13 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -1,14 +1,42 @@ -"""Tests for VisorSceneBase as an abstract class. +"""Tests for VisorSceneBase. -These tests only assert the abstract contract of VisorSceneBase: - 1. Direct instantiation raises TypeError. - 2. Exactly the expected set of abstract methods is declared. +Two groups: -Behavioural coverage of shared logic lives in test_local_scene.py, exercised -through the concrete VisorLocalScene subclass. +1. The abstract contract: direct instantiation raises TypeError, and exactly + the expected set of abstract methods is declared. +2. The per-part coordinator surface and the VTK lock. For each of the six + coordinator methods the two halves are asserted **separately** -- the + registry record (the store write) and the VTK object (the pipeline apply) + -- because either half can silently do nothing while the other succeeds. + The lock is asserted around every coordinator method and around all ten + existing public entry points that mutate VTK or push to wasm; a lock added + only to the trigger side would serialise nothing while looking correct. + +Behavioural coverage of the remaining shared logic lives in +test_local_scene.py, exercised through the concrete VisorLocalScene subclass. """ +import threading +from unittest.mock import MagicMock, patch + +import pytest +from vtkmodules.vtkCommonCore import vtkFloatArray +from vtkmodules.vtkCommonDataModel import vtkPolyData +from vtkmodules.vtkFiltersSources import vtkSphereSource + +from ansys.visor.viewer.core.visor_colors import VisorColors +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( + RuntimeDatasetState, + RuntimePartProperties, +) +from ansys.visor.viewer.renderer.local_renderer import VisorLocalRenderer +from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry +from ansys.visor.viewer.vtk.node_pipeline import VtkNodePipeline from ansys.visor.viewer.vtk.scene.base import VisorSceneBase +NODE_ID = 7 +UNKNOWN_NODE_ID = 999999 + def test_cannot_instantiate_directly(): """VisorSceneBase is abstract and must raise TypeError on direct instantiation.""" @@ -26,3 +54,631 @@ def test_abstract_methods(): "_apply_runtime_state_to_render", } + +# =========================================================================== +# Test doubles +# =========================================================================== + +class _ConcreteScene(VisorSceneBase): + """Smallest concrete VisorSceneBase: both abstract hooks are inert.""" + + async def _get_runtime_state_async(self, timeout: float): + return MagicMock(name="runtime_app_state") + + def _apply_runtime_state_to_render(self, runtime_app_state) -> None: + return None + + +class _LockSpy: + """Re-entrant lock that records its own acquisition depth. + + Wraps a real RLock so nesting still behaves, and exposes ``depth`` so a + test can assert the lock was *held* at the moment some inner call ran, + rather than merely acquired at some point. + """ + + def __init__(self): + self._lock = threading.RLock() + self.enter_count = 0 + self.exit_count = 0 + self.depth = 0 + self.max_depth = 0 + + def __enter__(self): + self._lock.acquire() + self.enter_count += 1 + self.depth += 1 + self.max_depth = max(self.max_depth, self.depth) + return self + + def __exit__(self, exc_type, exc, tb): + self.depth -= 1 + self.exit_count += 1 + self._lock.release() + return False + + +def _make_part_dataset(dataset_id: int, part_ids, part_states=None): + """Dataset stand-in with a real PartIndex.part_ids and a real state object. + + Real (not MagicMock) state, so a registry write can be read back through + object identity rather than through a recorded call. + """ + dataset = MagicMock() + dataset.part_index = MagicMock() + dataset.part_index.part_ids = list(part_ids) + dataset.state = RuntimeDatasetState(id=dataset_id, part_states=part_states or {}) + return dataset + + +@pytest.fixture +def array_dataset() -> vtkPolyData: + """Sphere output carrying one named point array and one named cell array.""" + src = vtkSphereSource() + src.Update() + dataset = src.GetOutput() + + pressure = vtkFloatArray() + pressure.SetName("pressure") + pressure.SetNumberOfComponents(1) + pressure.SetNumberOfTuples(dataset.GetNumberOfPoints()) + for i in range(dataset.GetNumberOfPoints()): + pressure.SetTuple1(i, float(i)) + dataset.GetPointData().AddArray(pressure) + + temperature = vtkFloatArray() + temperature.SetName("temperature") + temperature.SetNumberOfComponents(1) + temperature.SetNumberOfTuples(dataset.GetNumberOfCells()) + for i in range(dataset.GetNumberOfCells()): + temperature.SetTuple1(i, float(i)) + dataset.GetCellData().AddArray(temperature) + + return dataset + + +@pytest.fixture +def renderer(): + """Real VisorLocalRenderer with every VTK sub-system patched out. + + Real, so the apply bodies under test actually run against real VTK + objects; the sub-systems are patched so no render window, interactor, + LocalView or widget is created. + """ + mock_server = MagicMock() + mock_server.state = {} + + with ( + patch.object(VisorLocalRenderer, "_initialize_vtk_renderer", return_value=MagicMock()), + patch.object(VisorLocalRenderer, "_initialize_render_window", return_value=MagicMock()), + patch.object( + VisorLocalRenderer, "_initialize_render_window_interactor", return_value=MagicMock() + ), + patch.object(VisorLocalRenderer, "_initialize_local_view", return_value=MagicMock()), + patch.object(VisorLocalRenderer, "_initialize_orientation_widget", return_value=MagicMock()), + patch.object( + VisorLocalRenderer, "_initialize_cross_section_widget", return_value=MagicMock() + ), + patch.object( + VisorLocalRenderer, "_initialize_bounding_box_widget", return_value=MagicMock() + ), + ): + return VisorLocalRenderer(mock_server) + + +@pytest.fixture +def pipeline(renderer, array_dataset): + """A real VtkNodePipeline registered under NODE_ID.""" + pipe = VtkNodePipeline.from_dataset(array_dataset) + renderer._pipelines[NODE_ID] = pipe + return pipe + + +@pytest.fixture +def registry(): + """Registry holding one dataset that owns NODE_ID, with no part state yet.""" + reg = VisorDatasetRegistry() + reg.datasets = {1: _make_part_dataset(1, [NODE_ID])} + return reg + + +@pytest.fixture +def scene(renderer, registry, pipeline): + """Concrete scene wired to the real renderer, pipeline and registry.""" + s = _ConcreteScene(MagicMock(name="server"), dark_mode=False, renderer=renderer) + s._dataset_registry = registry + s._renderer.flush_wasm_state = MagicMock(name="flush_wasm_state") + return s + + +def _spy_flush(scene, read_back): + """Replace flush_wasm_state with a spy recording lock depth and VTK state. + + ``read_back`` is called at flush time and its value recorded, so a test + can assert the pipeline apply had *already* happened when the flush ran. + A flush ordered before the apply would push pre-mutation state, and the + client's own reapply would hide that in the browser. + """ + record = {"calls": 0, "depth": None, "value": None} + + def _flush(): + record["calls"] += 1 + record["depth"] = scene._vtk_lock.depth + record["value"] = read_back() + + scene._renderer.flush_wasm_state = _flush + return record + + +# =========================================================================== +# set_part_visibility +# =========================================================================== + +def test_set_part_visibility_writes_the_registry_record(scene, registry): + """Store half: the record carries the requested visibility.""" + scene.set_part_visibility(NODE_ID, False) + + assert registry.get_part_state(NODE_ID).visible is False + + +def test_set_part_visibility_applies_to_the_vtk_actor(scene, pipeline): + """Apply half: the actor's visibility is off.""" + scene.set_part_visibility(NODE_ID, False) + + assert pipeline.actor.GetVisibility() == 0 + + +def test_set_part_visibility_flushes_under_the_lock_after_the_apply(scene, pipeline): + """Lock held, and the actor already mutated, when the flush runs.""" + scene._vtk_lock = _LockSpy() + record = _spy_flush(scene, lambda: pipeline.actor.GetVisibility()) + + scene.set_part_visibility(NODE_ID, False) + + # TODO: change "calls" to 1 and uncomment "depth" and "value" when we add round trips + assert record["calls"] == 0 + # assert record["depth"] >= 1 + # assert record["value"] == 0 + assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count + assert scene._vtk_lock.depth == 0 + + +# =========================================================================== +# set_part_opacity +# =========================================================================== + +def test_set_part_opacity_writes_the_registry_record(scene, registry): + """Store half: the record carries the requested opacity.""" + scene.set_part_opacity(NODE_ID, 0.25) + + assert registry.get_part_state(NODE_ID).opacity == 0.25 + + +def test_set_part_opacity_applies_to_the_vtk_property(scene, pipeline): + """Apply half: the actor property's opacity is the requested value.""" + scene.set_part_opacity(NODE_ID, 0.25) + + assert pipeline.actor.GetProperty().GetOpacity() == pytest.approx(0.25) + + +def test_set_part_opacity_flushes_under_the_lock_after_the_apply(scene, pipeline): + """Lock held, and the property already mutated, when the flush runs.""" + scene._vtk_lock = _LockSpy() + record = _spy_flush(scene, lambda: pipeline.actor.GetProperty().GetOpacity()) + + scene.set_part_opacity(NODE_ID, 0.25) + + # TODO: change "calls" to 1 and uncomment "depth" and "value" when we add round trips + assert record["calls"] == 0 + # assert record["depth"] >= 1 + # assert record["value"] == pytest.approx(0.25) + assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count + + +# =========================================================================== +# set_part_diffuse_color +# =========================================================================== + +def test_set_part_diffuse_color_writes_the_registry_record(scene, registry): + """Store half: the record carries the requested colour.""" + scene.set_part_diffuse_color(NODE_ID, [1.0, 0.0, 0.0]) + + assert registry.get_part_state(NODE_ID).diffuse_rgb == [1.0, 0.0, 0.0] + + +def test_set_part_diffuse_color_applies_to_the_vtk_property(scene, pipeline): + """Apply half: the actor property's diffuse colour is the requested one.""" + scene.set_part_diffuse_color(NODE_ID, [1.0, 0.0, 0.0]) + + assert pipeline.actor.GetProperty().GetDiffuseColor() == pytest.approx((1.0, 0.0, 0.0)) + + +def test_set_part_diffuse_color_none_clears_the_registry_record(scene, registry): + """Store half of a clear: absence is stored as absence, not as a colour.""" + registry.set_part_diffuse_color(NODE_ID, [1.0, 0.0, 0.0]) + + scene.set_part_diffuse_color(NODE_ID, None) + + assert registry.get_part_state(NODE_ID).diffuse_rgb is None + + +def test_set_part_diffuse_color_none_applies_the_default_mesh_colour(scene, pipeline): + """Apply half of a clear: the pipeline falls back to the default colour.""" + scene.set_part_diffuse_color(NODE_ID, [1.0, 0.0, 0.0]) + + scene.set_part_diffuse_color(NODE_ID, None) + + assert pipeline.actor.GetProperty().GetDiffuseColor() == pytest.approx( + tuple(VisorColors.DefaultMeshColor) + ) + + +def test_set_part_diffuse_color_flushes_under_the_lock_after_the_apply(scene, pipeline): + """Lock held, and the colour already applied, when the flush runs.""" + scene._vtk_lock = _LockSpy() + record = _spy_flush(scene, lambda: pipeline.actor.GetProperty().GetDiffuseColor()) + + scene.set_part_diffuse_color(NODE_ID, [1.0, 0.0, 0.0]) + + # TODO: change "calls" to 1 and uncomment "depth" and "value" when we add round trips + assert record["calls"] == 0 + # assert record["depth"] >= 1 + # assert record["value"] == pytest.approx((1.0, 0.0, 0.0)) + assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count + + +# =========================================================================== +# set_part_selected +# =========================================================================== + +def test_set_part_selected_writes_the_registry_record(scene, registry): + """Store half: the record carries the requested selection state.""" + scene.set_part_selected(NODE_ID, True) + + assert registry.get_part_state(NODE_ID).selected is True + + +def test_set_part_selected_applies_the_highlight_to_the_vtk_property(scene, pipeline): + """Apply half: the selection lighting terms are on the actor property.""" + scene.set_part_selected(NODE_ID, True) + + prop = pipeline.actor.GetProperty() + assert prop.GetAmbient() == pytest.approx(0.5) + assert prop.GetDiffuse() == pytest.approx(0.5) + assert prop.GetAmbientColor() == pytest.approx((0.0, 62 / 255, 111 / 255)) + + +def test_set_part_selected_uses_the_stored_diffuse_colour(scene, registry, pipeline): + """The colour comes from the server's own record, not from the caller.""" + registry.set_part_diffuse_color(NODE_ID, [0.25, 0.5, 0.75]) + + scene.set_part_selected(NODE_ID, True) + + assert pipeline.actor.GetProperty().GetDiffuseColor() == pytest.approx((0.25, 0.5, 0.75)) + + +def test_set_part_selected_falls_back_to_the_default_mesh_colour(scene, pipeline): + """With no stored colour, the default constant is used.""" + scene.set_part_selected(NODE_ID, True) + + assert pipeline.actor.GetProperty().GetDiffuseColor() == pytest.approx( + tuple(VisorColors.DefaultMeshColor) + ) + + +def test_set_part_selected_flushes_under_the_lock_after_the_apply(scene, pipeline): + """Lock held, and the highlight already applied, when the flush runs.""" + scene._vtk_lock = _LockSpy() + record = _spy_flush(scene, lambda: pipeline.actor.GetProperty().GetAmbient()) + + scene.set_part_selected(NODE_ID, True) + + # TODO: change "calls" to 1 and uncomment "depth" and "value" when we add round trips + assert record["calls"] == 0 + # assert record["depth"] >= 1 + # assert record["value"] == pytest.approx(0.5) + assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count + + +# =========================================================================== +# set_part_color_variable +# =========================================================================== + +def test_set_part_color_variable_writes_the_registry_record(scene, registry): + """Store half: variable id and component are recorded together.""" + scene.set_part_color_variable( + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 + ) + + state = registry.get_part_state(NODE_ID) + assert state.spectrum_id == "POINT::pressure::1" + assert state.spectrum_component == 0 + + +def test_set_part_color_variable_applies_to_the_vtk_mapper(scene, pipeline): + """Apply half: the mapper selects the array and honours the given range.""" + scene.set_part_color_variable( + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 + ) + + mapper = pipeline.mapper + assert mapper.GetScalarVisibility() == 1 + assert mapper.GetArrayName() == "pressure" + assert mapper.GetArrayComponent() == 0 + assert mapper.GetScalarRange() == pytest.approx((0.0, 49.0)) + + +def test_set_part_color_variable_applies_a_cell_association(scene, pipeline): + """Apply half, cell branch: the cell array is selected.""" + scene.set_part_color_variable( + NODE_ID, "CELL::temperature::1", VisorVtkVariableType.CELL, "temperature", 0, 0.0, 95.0 + ) + + assert pipeline.mapper.GetArrayName() == "temperature" + assert pipeline.mapper.GetScalarVisibility() == 1 + + +def test_set_part_color_variable_flushes_under_the_lock_after_the_apply(scene, pipeline): + """Lock held, and the mapper already configured, when the flush runs.""" + scene._vtk_lock = _LockSpy() + record = _spy_flush(scene, lambda: pipeline.mapper.GetArrayName()) + + scene.set_part_color_variable( + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 + ) + + # TODO: change "calls" to 1 and uncomment "depth" and "value" when we add round trips + assert record["calls"] == 0 + # assert record["depth"] >= 1 + # assert record["value"] == "pressure" + assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count + + +# =========================================================================== +# clear_part_color_variable +# =========================================================================== + +def test_clear_part_color_variable_clears_the_registry_record(scene, registry): + """Store half: both fields are cleared together.""" + registry.set_part_color_variable(NODE_ID, "POINT::pressure::1", 0) + + scene.clear_part_color_variable(NODE_ID) + + state = registry.get_part_state(NODE_ID) + assert state.spectrum_id is None + assert state.spectrum_component is None + + +def test_clear_part_color_variable_disables_scalar_visibility_on_the_mapper(scene, pipeline): + """Apply half: scalar colouring is off on the mapper.""" + scene.set_part_color_variable( + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 + ) + + scene.clear_part_color_variable(NODE_ID) + + assert pipeline.mapper.GetScalarVisibility() == 0 + + +def test_clear_part_color_variable_flushes_under_the_lock_after_the_apply(scene, pipeline): + """Lock held, and scalar visibility already off, when the flush runs.""" + scene._vtk_lock = _LockSpy() + record = _spy_flush(scene, lambda: pipeline.mapper.GetScalarVisibility()) + + scene.clear_part_color_variable(NODE_ID) + + # TODO: change "calls" to 1 and uncomment "depth" and "value" when we add round trips + assert record["calls"] == 0 + # assert record["depth"] >= 1 + # assert record["value"] == 0 + assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count + + +# =========================================================================== +# Unknown node id: logged no-op at every layer +# =========================================================================== + +COORDINATOR_CALLS = { + "set_part_visibility": (UNKNOWN_NODE_ID, False), + "set_part_opacity": (UNKNOWN_NODE_ID, 0.25), + "set_part_diffuse_color": (UNKNOWN_NODE_ID, [1.0, 0.0, 0.0]), + "set_part_selected": (UNKNOWN_NODE_ID, True), + "set_part_color_variable": ( + UNKNOWN_NODE_ID, + "POINT::pressure::1", + VisorVtkVariableType.POINT, + "pressure", + 0, + 0.0, + 49.0, + ), + "clear_part_color_variable": (UNKNOWN_NODE_ID,), +} + + +@pytest.mark.parametrize("name", list(COORDINATOR_CALLS)) +def test_unknown_node_id_is_a_logged_no_op(scene, registry, pipeline, name): + """No raise, no store write, no VTK mutation and no flush.""" + scene._renderer.flush_wasm_state = MagicMock(name="flush_wasm_state") + pipeline.actor.SetVisibility(1) + pipeline.actor.GetProperty().SetOpacity(1.0) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + assert getattr(scene, name)(*COORDINATOR_CALLS[name]) is None + + assert mock_logger.debug.call_count == 1 + assert registry.get_part_state(UNKNOWN_NODE_ID) is None + assert pipeline.actor.GetVisibility() == 1 + assert pipeline.actor.GetProperty().GetOpacity() == pytest.approx(1.0) + scene._renderer.flush_wasm_state.assert_not_called() + + +def test_seeded_part_state_is_mutated_in_place(scene, registry): + """An existing record is mutated in place, not replaced.""" + seeded = RuntimePartProperties(id=NODE_ID) + registry.datasets[1].state.part_states[NODE_ID] = seeded + + scene.set_part_opacity(NODE_ID, 0.25) + + assert registry.get_part_state(NODE_ID) is seeded + + +# =========================================================================== +# The ten existing public entry points hold the lock +# =========================================================================== + +@pytest.fixture +def mocked_scene(renderer): + """Scene whose scene graph, registry and renderer are all MagicMocks. + + Lets the ten existing entry points run to completion without real VTK, so + the assertion is purely about the lock. + """ + s = _ConcreteScene(MagicMock(name="server"), dark_mode=False, renderer=renderer) + s._scene_graph = MagicMock(name="scene_graph") + s._dataset_registry = MagicMock(name="dataset_registry") + s._dataset_registry.count = 1 + s._renderer = MagicMock(name="renderer") + s._state_mapper = MagicMock(name="state_mapper") + s._vtk_lock = _LockSpy() + return s + + +def _depth_probe(mocked_scene, owner, attribute): + """Record the lock depth observed inside a delegated call.""" + observed = {} + + def _side_effect(*args, **kwargs): + observed["depth"] = mocked_scene._vtk_lock.depth + return MagicMock() + + getattr(owner, attribute).side_effect = _side_effect + return observed + + +def test_clear_holds_the_lock(mocked_scene): + """clear() deregisters actors with the lock held.""" + observed = _depth_probe(mocked_scene, mocked_scene._renderer, "deregister_all") + + mocked_scene.clear() + + assert observed["depth"] >= 1 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_add_dataset_holds_the_lock(mocked_scene): + """add_dataset() registers nodes with the lock held.""" + subtree = mocked_scene._scene_graph.get_descendant_node.return_value + subtree.get_descendant_part_nodes.return_value = [MagicMock(name="leaf")] + observed = _depth_probe(mocked_scene, mocked_scene._renderer, "register_node") + + mocked_scene.add_dataset(MagicMock(name="input"), MagicMock(name="metadata")) + + assert observed["depth"] >= 1 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_remove_dataset_holds_the_lock(mocked_scene): + """remove_dataset() deregisters nodes with the lock held.""" + node = mocked_scene._scene_graph.get_descendant_node.return_value + node.get_descendant_part_nodes.return_value = [MagicMock(name="leaf")] + observed = _depth_probe(mocked_scene, mocked_scene._renderer, "deregister_node") + + mocked_scene.remove_dataset(1) + + assert observed["depth"] >= 1 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_update_variables_for_dataset_holds_the_lock(mocked_scene): + """update_variables_for_dataset() updates arrays with the lock held.""" + observed = _depth_probe(mocked_scene, mocked_scene._dataset_registry, "update_variables") + + mocked_scene.update_variables_for_dataset(1, []) + + assert observed["depth"] >= 1 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_apply_state_holds_the_lock(mocked_scene): + """apply_state() runs the renderer-specific step with the lock held.""" + observed = {} + mocked_scene._apply_runtime_state_to_render = lambda state: observed.update( + depth=mocked_scene._vtk_lock.depth + ) + + mocked_scene.apply_state(MagicMock(name="persisted_state")) + + assert observed["depth"] >= 1 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_render_holds_the_lock(mocked_scene): + """render() renders and pushes to wasm with the lock held.""" + observed = _depth_probe(mocked_scene, mocked_scene._renderer, "render") + + mocked_scene.render() + + assert observed["depth"] >= 1 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_update_widgets_holds_the_lock(mocked_scene): + """update_widgets() mutates the widget VTK objects with the lock held.""" + observed = _depth_probe(mocked_scene, mocked_scene._renderer, "update_bounds") + + mocked_scene.update_widgets() + + assert observed["depth"] >= 1 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_populate_scene_holds_the_lock(mocked_scene): + """populate_scene() reaches the widget update with the lock held.""" + observed = _depth_probe(mocked_scene, mocked_scene._renderer, "update_actor_count") + + mocked_scene.populate_scene() + + assert observed["depth"] >= 1 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_finalize_scene_holds_the_lock(mocked_scene): + """finalize_scene() reaches the render with the lock held.""" + observed = _depth_probe(mocked_scene, mocked_scene._renderer, "render") + + mocked_scene.finalize_scene() + + assert observed["depth"] >= 1 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_reset_camera_holds_the_lock(mocked_scene): + """reset_camera() mutates the VTK camera with the lock held.""" + observed = _depth_probe(mocked_scene, mocked_scene._renderer, "reset_camera") + + mocked_scene.reset_camera() + + assert observed["depth"] >= 1 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_nested_entry_points_reacquire_the_lock(mocked_scene): + """finalize_scene -> populate_scene / render nests; the RLock allows it.""" + mocked_scene.finalize_scene() + + assert mocked_scene._vtk_lock.max_depth >= 2 + assert mocked_scene._vtk_lock.depth == 0 + assert mocked_scene._vtk_lock.enter_count == mocked_scene._vtk_lock.exit_count + + +def test_the_scene_lock_is_reentrant(): + """The scene's own lock can be acquired twice on one thread.""" + scene = _ConcreteScene(MagicMock(name="server"), renderer=MagicMock(name="renderer")) + + with scene._vtk_lock: + with scene._vtk_lock: + assert scene._vtk_lock is not None + + + diff --git a/tests/unit/vtk/scene/test_local_scene.py b/tests/unit/vtk/scene/test_local_scene.py index 6ae337ea..5f86c071 100644 --- a/tests/unit/vtk/scene/test_local_scene.py +++ b/tests/unit/vtk/scene/test_local_scene.py @@ -1,4 +1,5 @@ import json +import threading from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -720,6 +721,10 @@ def __init__(self): # do not call super().__init__ to avoid VTK setup # set only the attributes used by finalize_scene self._dataset_registry = SimpleNamespace(count=0) + # DummyScene skips super().__init__, so it mirrors VisorSceneBase.__init__ state by + # hand; any state added to the base constructor must be mirrored here too. RLock, + # not Lock, to match production re-entrancy. + self._vtk_lock = threading.RLock() self._populate_called = False self._reset_called = False self._render_called = False diff --git a/tests/unit/vtk/test_node_pipeline.py b/tests/unit/vtk/test_node_pipeline.py index 8443ca7a..5a03955a 100644 --- a/tests/unit/vtk/test_node_pipeline.py +++ b/tests/unit/vtk/test_node_pipeline.py @@ -1,8 +1,9 @@ """Unit tests for VtkNodePipeline.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest +from vtkmodules.vtkCommonCore import vtkFloatArray from vtkmodules.vtkCommonDataModel import vtkPlane, vtkPolyData, vtkUnstructuredGrid from vtkmodules.vtkFiltersCore import vtkAppendPolyData from vtkmodules.vtkFiltersGeometry import vtkGeometryFilter @@ -10,6 +11,7 @@ from vtkmodules.vtkRenderingCore import vtkActor, vtkPolyDataMapper from ansys.visor.viewer.core.visor_colors import VisorColors +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.vtk.node_pipeline import VtkNodePipeline @@ -26,6 +28,38 @@ def unstructured_dataset() -> vtkUnstructuredGrid: return vtkUnstructuredGrid() +@pytest.fixture +def array_dataset() -> vtkPolyData: + """Sphere output carrying one named point array and one named cell array. + + Each array is sized from the dataset's own counts -- the point array to + ``GetNumberOfPoints()`` and the cell array to ``GetNumberOfCells()`` -- so + the arrays are valid for the association they are attached to. For the + default vtkSphereSource that is 50 points and 96 cells. + """ + src = vtkSphereSource() + src.Update() + dataset = src.GetOutput() + + pressure = vtkFloatArray() + pressure.SetName("pressure") + pressure.SetNumberOfComponents(1) + pressure.SetNumberOfTuples(dataset.GetNumberOfPoints()) + for i in range(dataset.GetNumberOfPoints()): + pressure.SetTuple1(i, float(i)) + dataset.GetPointData().AddArray(pressure) + + temperature = vtkFloatArray() + temperature.SetName("temperature") + temperature.SetNumberOfComponents(1) + temperature.SetNumberOfTuples(dataset.GetNumberOfCells()) + for i in range(dataset.GetNumberOfCells()): + temperature.SetTuple1(i, float(i)) + dataset.GetCellData().AddArray(temperature) + + return dataset + + # --------------------------------------------------------------------------- # from_dataset # --------------------------------------------------------------------------- @@ -132,3 +166,273 @@ def algorithm_filter(base): assert pipe.mapper.GetInputConnection(0, 0).GetProducer() is wrapper +# --------------------------------------------------------------------------- +# set_selected +# +# The selection highlight colour is ambient RGB 0, 62, 111 out of 255. The +# expected values below are written as decimal literals so that the test does +# not restate the production expression. +# --------------------------------------------------------------------------- + +SELECTION_AMBIENT_COLOR = (0.0, 0.24313725490196078, 0.43529411764705883) + + +def test_set_selected_true_sets_selection_ambient_color_and_lighting(poly_dataset): + pipe = VtkNodePipeline.from_dataset(poly_dataset) + + pipe.set_selected(True, [0.1, 0.2, 0.3]) + + prop = pipe.actor.GetProperty() + assert prop.GetAmbientColor() == pytest.approx(SELECTION_AMBIENT_COLOR) + assert prop.GetDiffuse() == pytest.approx(0.5) + assert prop.GetAmbient() == pytest.approx(0.5) + + +def test_set_selected_true_still_applies_the_given_diffuse_color(poly_dataset): + """Selecting changes the lighting terms; it does not replace the colour.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + + pipe.set_selected(True, [1.0, 0.0, 0.0]) + + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx((1.0, 0.0, 0.0)) + + +def test_set_selected_false_restores_lighting_defaults(poly_dataset): + pipe = VtkNodePipeline.from_dataset(poly_dataset) + pipe.set_selected(True, [1.0, 0.0, 0.0]) + + pipe.set_selected(False, [0.0, 1.0, 0.0]) + + prop = pipe.actor.GetProperty() + assert prop.GetDiffuse() == pytest.approx(1.0) + assert prop.GetAmbient() == pytest.approx(0.0) + assert prop.GetDiffuseColor() == pytest.approx((0.0, 1.0, 0.0)) + + +def test_set_selected_false_leaves_ambient_color_untouched(poly_dataset): + """Deselecting resets the ambient *term*, not the ambient colour.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + # Seeded ambient colour: a distinctive value neither branch writes. + pipe.actor.GetProperty().SetAmbientColor(0.11, 0.22, 0.33) + + pipe.set_selected(False, [0.0, 1.0, 0.0]) + + assert pipe.actor.GetProperty().GetAmbientColor() == pytest.approx( + (0.11, 0.22, 0.33) + ) + + +# --------------------------------------------------------------------------- +# set_visibility +# --------------------------------------------------------------------------- + +def test_set_visibility_true_shows_actor(poly_dataset): + """set_visibility(True) leaves the actor's visibility flag set.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + pipe.actor.SetVisibility(0) + + pipe.set_visibility(True) + + assert pipe.actor.GetVisibility() == 1 + + +def test_set_visibility_false_hides_actor(poly_dataset): + """set_visibility(False) leaves the actor's visibility flag clear.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + pipe.actor.SetVisibility(1) + + pipe.set_visibility(False) + + assert pipe.actor.GetVisibility() == 0 + + +# --------------------------------------------------------------------------- +# set_opacity +# --------------------------------------------------------------------------- + +def test_set_opacity_sets_property_opacity(poly_dataset): + """set_opacity writes the requested value onto the actor property.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + + pipe.set_opacity(0.25) + + assert pipe.actor.GetProperty().GetOpacity() == pytest.approx(0.25) + + +def test_set_opacity_does_not_touch_visibility_or_diffuse_color(poly_dataset): + """Opacity lands on the property's opacity field and nothing else.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + pipe.actor.SetVisibility(0) + pipe.actor.GetProperty().SetDiffuseColor(0.25, 0.5, 0.75) + + pipe.set_opacity(0.25) + + assert pipe.actor.GetVisibility() == 0 + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( + (0.25, 0.5, 0.75) + ) + + +# --------------------------------------------------------------------------- +# set_diffuse_color +# --------------------------------------------------------------------------- + +def test_set_diffuse_color_sets_property_diffuse_color(poly_dataset): + """set_diffuse_color writes r, g, b onto the property's diffuse colour.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + + pipe.set_diffuse_color(1.0, 0.0, 0.0) + + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( + (1.0, 0.0, 0.0) + ) + + +def test_set_diffuse_color_does_not_touch_ambient_color_or_opacity(poly_dataset): + """SetDiffuseColor, not SetColor or SetAmbientColor, and opacity is left alone.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + pipe.actor.GetProperty().SetAmbientColor(0.25, 0.5, 0.75) + pipe.actor.GetProperty().SetOpacity(0.75) + + pipe.set_diffuse_color(1.0, 0.0, 0.0) + + assert pipe.actor.GetProperty().GetAmbientColor() == pytest.approx( + (0.25, 0.5, 0.75) + ) + assert pipe.actor.GetProperty().GetOpacity() == pytest.approx(0.75) + + +# --------------------------------------------------------------------------- +# set_color_variable +# --------------------------------------------------------------------------- + +def test_set_color_variable_point_uses_point_field_data_and_selects_array( + array_dataset, +): + pipe = VtkNodePipeline.from_dataset(array_dataset) + + pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 0, 0.0, 7.5) + + assert pipe.mapper.GetScalarModeAsString() == "UsePointFieldData" + assert pipe.mapper.GetArrayName() == "pressure" + + +def test_set_color_variable_cell_uses_cell_field_data(array_dataset): + pipe = VtkNodePipeline.from_dataset(array_dataset) + + pipe.set_color_variable(VisorVtkVariableType.CELL, "temperature", 0, 0.0, 7.5) + + assert pipe.mapper.GetScalarModeAsString() == "UseCellFieldData" + assert pipe.mapper.GetArrayName() == "temperature" + + +def test_set_color_variable_sets_the_array_component(array_dataset): + """Component selection lives on the mapper, not on a lookup table.""" + pipe = VtkNodePipeline.from_dataset(array_dataset) + + pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5) + + assert pipe.mapper.GetArrayComponent() == 1 + + +def test_set_color_variable_sets_exact_scalar_range(array_dataset): + """The mapper honours the passed range, not a table's own range. + + If SetUseLookupTableScalarRange(0) were omitted the mapper would defer to + the lookup table and the range read back would not be what was passed. + """ + pipe = VtkNodePipeline.from_dataset(array_dataset) + + pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 0, 0.0, 7.5) + + assert pipe.mapper.GetScalarRange() == pytest.approx((0.0, 7.5)) + assert pipe.mapper.GetUseLookupTableScalarRange() == 0 + + +def test_set_color_variable_enables_scalar_visibility_and_map_scalars(array_dataset): + pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded scalar visibility: OFF, so an enabling call is visible. + pipe.mapper.SetScalarVisibility(False) + + pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 0, 0.0, 7.5) + + assert pipe.mapper.GetScalarVisibility() == 1 + assert pipe.mapper.GetColorModeAsString() == "MapScalars" + + +def test_set_color_variable_unknown_point_array_warns_and_mutates_nothing( + array_dataset, +): + pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded state: scalar visibility OFF and a sentinel array name. If the + # body wrongly proceeded, both would change. + pipe.mapper.SetScalarVisibility(False) + pipe.mapper.SelectColorArray("seeded-sentinel-array") + + with patch("ansys.visor.viewer.vtk.node_pipeline.logger") as mock_logger: + pipe.set_color_variable(VisorVtkVariableType.POINT, "no-such-array", 0, 0.0, 7.5) + + mock_logger.warning.assert_called_once() + assert pipe.mapper.GetScalarVisibility() == 0 + assert pipe.mapper.GetArrayName() == "seeded-sentinel-array" + + +def test_set_color_variable_unknown_cell_array_warns_and_mutates_nothing(array_dataset): + pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded state, as above. "pressure" exists but only as a *point* array, + # so the cell-side lookup must miss. + pipe.mapper.SetScalarVisibility(False) + pipe.mapper.SelectColorArray("seeded-sentinel-array") + + with patch("ansys.visor.viewer.vtk.node_pipeline.logger") as mock_logger: + pipe.set_color_variable(VisorVtkVariableType.CELL, "pressure", 0, 0.0, 7.5) + + mock_logger.warning.assert_called_once() + assert pipe.mapper.GetScalarVisibility() == 0 + assert pipe.mapper.GetArrayName() == "seeded-sentinel-array" + + +def test_set_color_variable_non_enum_association_warns_and_mutates_nothing( + array_dataset, +): + """A bare string is not a VisorVtkVariableType and must not pick a branch.""" + pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded state: scalar visibility OFF and a sentinel array name. + pipe.mapper.SetScalarVisibility(False) + pipe.mapper.SelectColorArray("seeded-sentinel-array") + + with patch("ansys.visor.viewer.vtk.node_pipeline.logger") as mock_logger: + pipe.set_color_variable("POINT", "pressure", 0, 0.0, 7.5) + + mock_logger.warning.assert_called_once() + assert pipe.mapper.GetScalarVisibility() == 0 + assert pipe.mapper.GetArrayName() == "seeded-sentinel-array" + + +# --------------------------------------------------------------------------- +# clear_color_variable +# --------------------------------------------------------------------------- + +def test_clear_color_variable_disables_scalar_visibility(array_dataset): + pipe = VtkNodePipeline.from_dataset(array_dataset) + pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 0, 0.0, 7.5) + assert pipe.mapper.GetScalarVisibility() == 1 + + pipe.clear_color_variable() + + assert pipe.mapper.GetScalarVisibility() == 0 + + +def test_clear_color_variable_leaves_diffuse_color_untouched(array_dataset): + """Clearing does not restore or replace the part's diffuse colour.""" + pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded diffuse colour: distinctive, and not the default mesh colour. + pipe.actor.GetProperty().SetDiffuseColor(0.11, 0.22, 0.33) + + pipe.clear_color_variable() + + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( + (0.11, 0.22, 0.33) + ) + +