From 38c5e9e4cbe52c8693b11608cd850385716845e1 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 28 Aug 2026 12:11:51 -0700 Subject: [PATCH 01/13] add per-part write path to the dataset registry --- .../viewer/vtk/datasets/visor_dataset.py | 14 +- .../vtk/datasets/visor_dataset_registry.py | 180 ++++++++++++++++- .../datasets/test_visor_dataset_registry.py | 190 ++++++++++++++++++ 3 files changed, 382 insertions(+), 2 deletions(-) diff --git a/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py b/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py index 8ba4b8d3..b91ff35a 100644 --- a/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py +++ b/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py @@ -88,7 +88,19 @@ def list_variables(self) -> List[VisorPartVariables]: return result def set_state(self, new_state: PersistedDatasetState) -> None: - """Set the state of the dataset.""" + """ + Set the state of the dataset from persisted, name-keyed part state. + + This is the persisted-state conversion path: it converts + new_state.parts (keyed by part name) into this dataset's runtime + state (keyed by part ID) via persisted_to_runtime_state. + + Note: a second, id-keyed replacement path also exists, on the + registry rather than here: VisorDatasetRegistry.replace_part_states + replaces a dataset's .state directly with an already-runtime, + id-keyed RuntimeDatasetState, without going through this method or + its name-to-id conversion. + """ self.state = self.persisted_to_runtime_state(new_state.parts) def mark_clean(self) -> None: diff --git a/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py b/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py index 0b7e4d90..1ada79a5 100644 --- a/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py +++ b/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py @@ -6,7 +6,10 @@ from ansys.visor.viewer.core.metadata import ExtendedMetadata from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.core.visor_types import VisorDatasetType -from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimeDatasetState +from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( + RuntimeDatasetState, + RuntimePartProperties, +) from ansys.visor.viewer.vtk.datasets.visor_dataset import VisorDataset from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables from ansys.visor.viewer.vtk.variables.visor_variable_update import VisorVariableUpdate @@ -106,6 +109,181 @@ def remove(self, dataset_id: int) -> None: if dataset_id in self.datasets: self.datasets.pop(dataset_id) + # ------------------------------------------------------------------ + # Per-part state: write path + # ------------------------------------------------------------------ + + def find_dataset_id_for_part(self, part_id: int) -> int | None: + """ + Find the ID of the dataset that owns the given part. + + Args: + part_id (int): The scene-graph node ID identifying the part. + + Returns: + int | None: The dataset ID that owns the part, or None if no + dataset in the registry has this part_id in its PartIndex. + """ + for dataset_id, dataset in self.datasets.items(): + if part_id in dataset.part_index.part_ids: + return dataset_id + return None + + def get_part_state(self, part_id: int) -> RuntimePartProperties | None: + """ + Get the current runtime state record for a single part. + + This is a read-only lookup: unlike the setters, it does not upsert + a record for a part that has no state yet. + + Args: + part_id (int): The scene-graph node ID identifying the part. + + Returns: + RuntimePartProperties | None: The live state record for the + part, or None if part_id resolves to no dataset, or the owning + dataset has no recorded state for it. + """ + dataset_id = self.find_dataset_id_for_part(part_id) + if dataset_id is None: + return None + return self.datasets[dataset_id].state.part_states.get(part_id) + + def _get_or_create_part_state(self, part_id: int) -> RuntimePartProperties | None: + """ + Resolve the live part-state record for part_id, upserting if needed. + + If the owning dataset has no part_states entry yet for part_id (e.g. + a freshly added dataset with no persisted part state), a + RuntimePartProperties(id=part_id) record is created and inserted + into the dataset's part_states dict first. This is mandatory: a + freshly added dataset has an empty part_states dict, so without + this upsert every setter would silently no-op for it. + + Returns: + RuntimePartProperties | None: The live state record, or None if + part_id resolves to no dataset. + """ + dataset_id = self.find_dataset_id_for_part(part_id) + if dataset_id is None: + return None + dataset = self.datasets[dataset_id] + part_state = dataset.state.part_states.get(part_id) + if part_state is None: + part_state = RuntimePartProperties(id=part_id) + dataset.state.part_states[part_id] = part_state + return part_state + + def set_part_visibility(self, part_id: int, visible: bool) -> bool: + """ + Set whether a part is visible. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.visible = visible + return True + + def set_part_opacity(self, part_id: int, opacity: float) -> bool: + """ + Set a part's opacity. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.opacity = opacity + return True + + def set_part_diffuse_color(self, part_id: int, diffuse_rgb: list[float] | None) -> bool: + """ + Set a part's custom diffuse colour, or clear it with None. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.diffuse_rgb = diffuse_rgb + return True + + def set_part_selected(self, part_id: int, selected: bool) -> bool: + """ + Set whether a part is selected. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.selected = selected + return True + + def set_part_color_variable(self, part_id: int, variable_id: str, component: int | None) -> bool: + """ + Set the variable a part is coloured by, and its component. + + variable_id and component are set together, atomically, in this one + call, mirroring clear_part_color_variable's atomic clear. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.spectrum_id = variable_id + part_state.spectrum_component = component + return True + + def clear_part_color_variable(self, part_id: int) -> bool: + """ + Clear the variable a part is coloured by. + + Sets spectrum_id and spectrum_component to None together, in one + call — the compound class is only ever set or cleared atomically, + never field by field. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.spectrum_id = None + part_state.spectrum_component = None + return True + + def replace_part_states(self, dataset_states: Dict[int, RuntimeDatasetState]) -> None: + """ + Replace the runtime state of registered datasets in bulk. + + For each dataset ID present in both dataset_states and the + registry, that dataset's .state is replaced directly with the + supplied RuntimeDatasetState (already-runtime, id-keyed input). + Dataset IDs in dataset_states that are not present in the registry + are skipped silently; processing continues for the remaining + entries. Never raises. + """ + for dataset_id, runtime_state in dataset_states.items(): + dataset = self.datasets.get(dataset_id) + if dataset is None: + continue + dataset.state = runtime_state + def _get_unique_dataset_name(self, name: str) -> str: """ Rename the dataset by appending a suffix to ensure uniqueness. diff --git a/tests/unit/vtk/datasets/test_visor_dataset_registry.py b/tests/unit/vtk/datasets/test_visor_dataset_registry.py index d55ee2d6..15296e9e 100644 --- a/tests/unit/vtk/datasets/test_visor_dataset_registry.py +++ b/tests/unit/vtk/datasets/test_visor_dataset_registry.py @@ -3,6 +3,10 @@ import pytest +from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( + RuntimeDatasetState, + RuntimePartProperties, +) from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry @@ -23,6 +27,19 @@ def make_mock_dataset(name="ds", info_dict=None, parts=None, variables=None): return mock +def make_part_dataset(dataset_id, part_ids, part_states=None): + """ + Build a dataset stand-in with a real PartIndex.part_ids list and a real + RuntimeDatasetState (not MagicMock), so the per-part write path can + mutate and be read back through actual object identity. + """ + 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 + + def test_count_returns_number_of_datasets(registry): """Verify that count returns the number of registered datasets.""" assert registry.count == 0 @@ -180,3 +197,176 @@ def test_update_unit_matches_keeps_unit(registry): registry._update_unit(metadata) assert registry.unit == "m" + + +# ================================================================== # +# Per-part write path (Story 3.1, increment I1) +# ================================================================== # + +def test_find_dataset_id_for_part_selects_between_multiple_datasets(registry): + """Verify the correct dataset id is returned when several datasets are registered.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10, 11]) + ds_b = make_part_dataset(dataset_id=2, part_ids=[20, 21]) + registry.datasets = {1: ds_a, 2: ds_b} + + assert registry.find_dataset_id_for_part(20) == 2 + assert registry.find_dataset_id_for_part(11) == 1 + + +def test_find_dataset_id_for_part_not_found_returns_none(registry): + """Verify an unknown part_id resolves to no dataset.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10, 11]) + registry.datasets = {1: ds_a} + + assert registry.find_dataset_id_for_part(999999) is None + + +def test_get_part_state_returns_existing_record(registry): + """Verify get_part_state returns the recorded state for a known part.""" + existing = RuntimePartProperties(id=10, opacity=0.7) + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: existing}) + registry.datasets = {1: ds_a} + + result = registry.get_part_state(10) + assert result is existing + assert result.opacity == 0.7 + + +def test_get_part_state_known_dataset_missing_part_returns_none(registry): + """Verify get_part_state does not upsert: a part with no record returns None.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10]) + registry.datasets = {1: ds_a} + + assert registry.get_part_state(10) is None + assert ds_a.state.part_states == {} + + +def test_get_part_state_unknown_part_id_returns_none(registry): + """Verify get_part_state returns None, without raising, for an unknown part_id.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10]) + registry.datasets = {1: ds_a} + + assert registry.get_part_state(999999) is None + + +def test_set_part_visibility_mutates_record_and_rejects_unknown_part(registry): + """Verify set_part_visibility writes True/False, and False + no raise for unknown part_id.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: RuntimePartProperties(id=10)}) + registry.datasets = {1: ds_a} + + assert registry.set_part_visibility(10, False) is True + assert registry.get_part_state(10).visible is False + + assert registry.set_part_visibility(999999, True) is False + + +def test_set_part_opacity_mutates_record_and_rejects_unknown_part(registry): + """Verify set_part_opacity writes the value, and False + no raise for unknown part_id.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: RuntimePartProperties(id=10)}) + registry.datasets = {1: ds_a} + + assert registry.set_part_opacity(10, 0.4) is True + assert registry.get_part_state(10).opacity == 0.4 + + assert registry.set_part_opacity(999999, 0.4) is False + + +def test_set_part_diffuse_color_mutates_and_clears(registry): + """Verify set_part_diffuse_color writes an RGB list, clears with None, and rejects unknown parts.""" + ds_a = make_part_dataset( + dataset_id=1, part_ids=[10], + part_states={10: RuntimePartProperties(id=10, diffuse_rgb=[1.0, 0.0, 0.0])}, + ) + registry.datasets = {1: ds_a} + + assert registry.set_part_diffuse_color(10, [0.0, 1.0, 0.0]) is True + assert registry.get_part_state(10).diffuse_rgb == [0.0, 1.0, 0.0] + + assert registry.set_part_diffuse_color(10, None) is True + assert registry.get_part_state(10).diffuse_rgb is None + + assert registry.set_part_diffuse_color(999999, [1.0, 1.0, 1.0]) is False + + +def test_set_part_selected_mutates_record_and_rejects_unknown_part(registry): + """Verify set_part_selected writes the value, and False + no raise for unknown part_id.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: RuntimePartProperties(id=10)}) + registry.datasets = {1: ds_a} + + assert registry.set_part_selected(10, True) is True + assert registry.get_part_state(10).selected is True + + assert registry.set_part_selected(999999, True) is False + + +def test_set_part_color_variable_sets_id_and_component_together(registry): + """Verify set_part_color_variable sets spectrum_id and spectrum_component in one call.""" + seed = RuntimePartProperties(id=10) + assert seed.spectrum_id is None + assert seed.spectrum_component is None + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: seed}) + registry.datasets = {1: ds_a} + + assert registry.set_part_color_variable(10, "POINT::pressure::1", 2) is True + + result = registry.get_part_state(10) + assert result.spectrum_id == "POINT::pressure::1" + assert result.spectrum_component == 2 + + assert registry.set_part_color_variable(999999, "POINT::x::1", 0) is False + + +def test_clear_part_color_variable_clears_id_and_component_together(registry): + """Verify clear_part_color_variable clears spectrum_id and spectrum_component in one call.""" + seed = RuntimePartProperties(id=10, spectrum_id="POINT::pressure::1", spectrum_component=2) + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: seed}) + registry.datasets = {1: ds_a} + + assert registry.clear_part_color_variable(10) is True + + result = registry.get_part_state(10) + assert result.spectrum_id is None + assert result.spectrum_component is None + + assert registry.clear_part_color_variable(999999) is False + + +def test_setter_upserts_part_state_when_part_id_known_but_absent_from_part_states(registry): + """ + Verify the upsert path: a part_id present in PartIndex.part_ids but absent + from part_states gets a RuntimePartProperties(id=part_id) created on first + write, rather than silently no-op-ing. + """ + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={}) + registry.datasets = {1: ds_a} + assert ds_a.state.part_states == {} + + assert registry.set_part_opacity(10, 0.6) is True + + created = ds_a.state.part_states.get(10) + assert created is not None + assert isinstance(created, RuntimePartProperties) + assert created.id == 10 + assert created.opacity == 0.6 + + +def test_replace_part_states_replaces_known_and_skips_unknown_without_aborting(registry): + """ + Verify replace_part_states replaces the state of a known dataset id and + silently skips an unknown dataset id in the same call, without aborting + partway (the known replacement still lands). + """ + ds_a = make_part_dataset(dataset_id=1, part_ids=[10]) + original_state = ds_a.state + registry.datasets = {1: ds_a} + + new_state_for_known = RuntimeDatasetState(id=1, part_states={10: RuntimePartProperties(id=10, opacity=0.9)}) + new_state_for_unknown = RuntimeDatasetState(id=999, part_states={}) + + registry.replace_part_states({1: new_state_for_known, 999: new_state_for_unknown}) + + assert ds_a.state is new_state_for_known + assert ds_a.state is not original_state + assert 999 not in registry.datasets + + From ac71d50afe0ec18df6e323b1d079a49cf318e00b Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 28 Aug 2026 12:44:15 -0700 Subject: [PATCH 02/13] remove story/increment reference in comment --- tests/unit/vtk/datasets/test_visor_dataset_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/vtk/datasets/test_visor_dataset_registry.py b/tests/unit/vtk/datasets/test_visor_dataset_registry.py index 15296e9e..19047df9 100644 --- a/tests/unit/vtk/datasets/test_visor_dataset_registry.py +++ b/tests/unit/vtk/datasets/test_visor_dataset_registry.py @@ -200,7 +200,7 @@ def test_update_unit_matches_keeps_unit(registry): # ================================================================== # -# Per-part write path (Story 3.1, increment I1) +# Per-part write path # ================================================================== # def test_find_dataset_id_for_part_selects_between_multiple_datasets(registry): From ee0b843048017803824b01dd20354cff01e7082d Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:46:24 +0000 Subject: [PATCH 03/13] chore: adding changelog file 50.added.md [dependabot-skip] --- doc/changelog.d/50.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/50.added.md diff --git a/doc/changelog.d/50.added.md b/doc/changelog.d/50.added.md new file mode 100644 index 00000000..947c8ced --- /dev/null +++ b/doc/changelog.d/50.added.md @@ -0,0 +1 @@ +Remote rendering 3.1 - add per-part write path to the dataset registry From ff688aa122dd96fe2b38e8ea8dc8d951e7cc4457 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:02:36 +0000 Subject: [PATCH 04/13] chore: adding changelog file 50.added.md [dependabot-skip] --- doc/changelog.d/50.added.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.d/50.added.md b/doc/changelog.d/50.added.md index 947c8ced..86f36f04 100644 --- a/doc/changelog.d/50.added.md +++ b/doc/changelog.d/50.added.md @@ -1 +1 @@ -Remote rendering 3.1 - add per-part write path to the dataset registry +Remote rendering 3.1a - add per-part write path to the dataset registry From ccc2f0f78fa4df1ad843f126276b25c4185925a7 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 28 Aug 2026 12:18:46 -0700 Subject: [PATCH 05/13] implement visibility, opacity and diffuse-color applies on the local renderer --- .../visor/viewer/renderer/local_renderer.py | 39 +++- tests/unit/renderer/test_local_renderer.py | 167 ++++++++++++++++-- 2 files changed, 191 insertions(+), 15 deletions(-) diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index 4d07003a..b334fa3d 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -158,15 +158,48 @@ 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`. + + Mutates the actor itself, not its property. 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.actor.SetVisibility(1 if visible else 0) def apply_opacity(self, node_id: int, opacity: float) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.apply_opacity`. + + Mutates the actor's property. 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.actor.GetProperty().SetOpacity(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`. + + Mutates the actor property's diffuse colour only. 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.actor.GetProperty().SetDiffuseColor(r, g, b) def apply_edge_visibility(self, node_id: int, edge_visible: bool) -> None: """No-op in Story 1.2. Phase 3 populates.""" diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index c0bd33ae..038959ee 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -6,8 +6,9 @@ 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 and diffuse colour + mutate the resolved pipeline's VTK objects; the remaining methods are + still no-ops and are asserted to accept their arguments and return None. 4. Camera round-trip -- reset_camera, sync_camera / get_camera_state. 5. Render / flush delegation. 6. pick_geometry -- vertex, edge, face modes. @@ -18,6 +19,7 @@ from unittest.mock import MagicMock, patch import pytest +from vtkmodules.vtkRenderingCore import vtkActor from ansys.visor.viewer.renderer.base import IRenderer from ansys.visor.viewer.renderer.local_renderer import VisorLocalRenderer @@ -92,6 +94,20 @@ def GetNextActor(self): # noqa: N802 return a +class _DummyPipeline: + """VtkNodePipeline stand-in holding a *real* vtkActor. + + The actor is real so that a test can read the mutated value back off + the VTK object (``GetVisibility``, ``GetProperty().GetOpacity()``, + ``GetProperty().GetDiffuseColor()``) rather than merely recording that + some call was made -- a MagicMock would pass even if the production + code mutated the wrong object or the wrong field. + """ + + def __init__(self): + self.actor = vtkActor() + + # --------------------------------------------------------------------------- # Fixture: VisorLocalRenderer with all VTK infrastructure mocked # --------------------------------------------------------------------------- @@ -336,16 +352,7 @@ 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 - - def test_apply_diffuse_color(self, renderer): - assert renderer.apply_diffuse_color(1, 1.0, 0.0, 0.0) is None + """The methods still un-implemented accept their arguments and return None.""" def test_apply_edge_visibility(self, renderer): assert renderer.apply_edge_visibility(1, False) is None @@ -369,6 +376,142 @@ def test_refresh_color_variable_range(self, renderer): ) +# =========================================================================== +# 3b. Inline apply bodies: visibility, opacity, diffuse colour +# +# Each mutation is read back off a real vtkActor, and each miss branch +# (unknown node id) is asserted separately. +# =========================================================================== + +class TestInlineApplyBodies: + + # ------------------------------------------------------------------ + # visibility -- mutates the actor itself + # ------------------------------------------------------------------ + + def test_apply_visibility_shows_actor(self, renderer): + """apply_visibility(True) leaves the actor's visibility flag set.""" + pipe = _DummyPipeline() + pipe.actor.SetVisibility(0) + renderer._pipelines[4] = pipe + + renderer.apply_visibility(4, True) + + assert pipe.actor.GetVisibility() == 1 + + def test_apply_visibility_hides_actor(self, renderer): + """apply_visibility(False) leaves the actor's visibility flag clear.""" + pipe = _DummyPipeline() + pipe.actor.SetVisibility(1) + renderer._pipelines[4] = pipe + + renderer.apply_visibility(4, False) + + assert pipe.actor.GetVisibility() == 0 + + def test_apply_visibility_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, mutates nothing.""" + pipe = _DummyPipeline() + pipe.actor.SetVisibility(1) + 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() + assert pipe.actor.GetVisibility() == 1 + + # ------------------------------------------------------------------ + # opacity -- mutates the actor's property + # ------------------------------------------------------------------ + + def test_apply_opacity_sets_property_opacity(self, renderer): + """apply_opacity writes the requested value onto the actor property.""" + pipe = _DummyPipeline() + renderer._pipelines[4] = pipe + + renderer.apply_opacity(4, 0.25) + + assert pipe.actor.GetProperty().GetOpacity() == pytest.approx(0.25) + + def test_apply_opacity_does_not_touch_visibility_or_diffuse_color(self, renderer): + """Opacity lands on the property's opacity field and nothing else.""" + pipe = _DummyPipeline() + pipe.actor.SetVisibility(0) + pipe.actor.GetProperty().SetDiffuseColor(0.25, 0.5, 0.75) + renderer._pipelines[4] = pipe + + renderer.apply_opacity(4, 0.25) + + assert pipe.actor.GetVisibility() == 0 + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( + (0.25, 0.5, 0.75) + ) + + def test_apply_opacity_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, mutates nothing.""" + pipe = _DummyPipeline() + pipe.actor.GetProperty().SetOpacity(0.75) + 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() + assert pipe.actor.GetProperty().GetOpacity() == pytest.approx(0.75) + + # ------------------------------------------------------------------ + # diffuse colour -- mutates the actor property's DiffuseColor + # ------------------------------------------------------------------ + + def test_apply_diffuse_color_sets_property_diffuse_color(self, renderer): + """apply_diffuse_color writes r, g, b onto the property's diffuse colour.""" + pipe = _DummyPipeline() + renderer._pipelines[4] = pipe + + renderer.apply_diffuse_color(4, 1.0, 0.0, 0.0) + + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( + (1.0, 0.0, 0.0) + ) + + def test_apply_diffuse_color_does_not_touch_ambient_color_or_opacity( + self, renderer + ): + """SetDiffuseColor, not SetColor or SetAmbientColor, and opacity is left alone.""" + pipe = _DummyPipeline() + pipe.actor.GetProperty().SetAmbientColor(0.25, 0.5, 0.75) + pipe.actor.GetProperty().SetOpacity(0.75) + renderer._pipelines[4] = pipe + + renderer.apply_diffuse_color(4, 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) + + def test_apply_diffuse_color_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, mutates nothing.""" + pipe = _DummyPipeline() + pipe.actor.GetProperty().SetDiffuseColor(0.25, 0.5, 0.75) + 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() + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( + (0.25, 0.5, 0.75) + ) + + # =========================================================================== # 4. Camera # =========================================================================== From c62294fcc98389e9adeab99d62f74ee61326658b Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 28 Aug 2026 12:19:37 -0700 Subject: [PATCH 06/13] move selection and color-variable applies onto VtkNodePipeline --- .../visor/viewer/renderer/local_renderer.py | 49 +++- src/ansys/visor/viewer/vtk/node_pipeline.py | 97 ++++++++ tests/unit/renderer/test_local_renderer.py | 127 ++++++++-- tests/unit/vtk/test_node_pipeline.py | 226 +++++++++++++++++- 4 files changed, 481 insertions(+), 18 deletions(-) diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index b334fa3d..47913992 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -207,7 +207,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, @@ -219,10 +233,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..1e97dcc1 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,102 @@ 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. + """ + 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_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/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index 038959ee..8fc9bfb2 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -7,8 +7,10 @@ 2. Interface conformance -- NullRenderer and VisorLocalRenderer both satisfy IRenderer's abstract contract. 3. Per-part visual mutations -- visibility, opacity and diffuse colour - mutate the resolved pipeline's VTK objects; the remaining methods are - still no-ops and are asserted to accept their arguments and return None. + mutate the resolved pipeline's VTK objects directly; selection and + colour variable 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. @@ -21,6 +23,7 @@ import pytest from vtkmodules.vtkRenderingCore import vtkActor +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 @@ -102,10 +105,18 @@ class _DummyPipeline: ``GetProperty().GetDiffuseColor()``) rather than merely recording that some call was made -- a MagicMock would pass even if the production code mutated the wrong object or the wrong field. + + The three delegated mutations are MagicMocks, because from this module + the behaviour under test is *that the renderer resolved and delegated*. + Their VTK effects are asserted against real objects in + ``tests/unit/vtk/test_node_pipeline.py``. """ def __init__(self): self.actor = vtkActor() + self.set_selected = MagicMock(name="set_selected") + self.set_color_variable = MagicMock(name="set_color_variable") + self.clear_color_variable = MagicMock(name="clear_color_variable") # --------------------------------------------------------------------------- @@ -352,22 +363,15 @@ def test_deregister_all_empties_registry_and_detaches_all_actors(self, renderer) # =========================================================================== class TestPerPartMutations: - """The methods still un-implemented accept their arguments and return None.""" + """The two permanently un-implemented methods accept their arguments. + + 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): - assert ( - renderer.apply_color_variable(1, "sp-1", "POINT", "pressure", -1, 0.0, 1.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 ( @@ -512,6 +516,101 @@ def test_apply_diffuse_color_unknown_node_id_is_logged_no_op(self, renderer): ) +# =========================================================================== +# 3c. Delegated apply bodies: 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_selected + # ------------------------------------------------------------------ + + def test_apply_selected_delegates_to_pipeline(self, renderer): + """Selection state and the given colour are passed straight through.""" + pipe = _DummyPipeline() + 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 = _DummyPipeline() + 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 = _DummyPipeline() + 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 = _DummyPipeline() + 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 = _DummyPipeline() + 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 = _DummyPipeline() + 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/test_node_pipeline.py b/tests/unit/vtk/test_node_pipeline.py index 8443ca7a..96115c51 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,193 @@ 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_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) + ) + + From 361c20ec373e9d8755db50808e25f4d70d0f3aa9 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:51:28 +0000 Subject: [PATCH 07/13] chore: adding changelog file 51.added.md [dependabot-skip] --- doc/changelog.d/51.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/51.added.md 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 From 0c503624220fca2ade48f6f713052b8a271dc6d2 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Mon, 31 Aug 2026 07:14:07 -0700 Subject: [PATCH 08/13] move remaining three vtk calls to node pipeline --- .../visor/viewer/renderer/local_renderer.py | 21 ++- src/ansys/visor/viewer/vtk/node_pipeline.py | 43 +++++ tests/unit/renderer/test_local_renderer.py | 163 +++++------------- tests/unit/vtk/test_node_pipeline.py | 80 +++++++++ 4 files changed, 177 insertions(+), 130 deletions(-) diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index 47913992..e0dd8670 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -160,8 +160,9 @@ def deregister_all(self) -> None: def apply_visibility(self, node_id: int, visible: bool) -> None: """See :meth:`IRenderer.apply_visibility`. - Mutates the actor itself, not its property. An unknown *node_id* - is a logged no-op, never a raise. + 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: @@ -169,13 +170,14 @@ def apply_visibility(self, node_id: int, visible: bool) -> None: "apply_visibility: no pipeline for node %s; skipping.", node_id ) return - pipe.actor.SetVisibility(1 if visible else 0) + pipe.set_visibility(visible) def apply_opacity(self, node_id: int, opacity: float) -> None: """See :meth:`IRenderer.apply_opacity`. - Mutates the actor's property. An unknown *node_id* is a logged - no-op, never a raise. + 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: @@ -183,15 +185,16 @@ def apply_opacity(self, node_id: int, opacity: float) -> None: "apply_opacity: no pipeline for node %s; skipping.", node_id ) return - pipe.actor.GetProperty().SetOpacity(opacity) + pipe.set_opacity(opacity) def apply_diffuse_color( self, node_id: int, r: float, g: float, b: float ) -> None: """See :meth:`IRenderer.apply_diffuse_color`. - Mutates the actor property's diffuse colour only. An unknown - *node_id* is a logged no-op, never a raise. + 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: @@ -199,7 +202,7 @@ def apply_diffuse_color( "apply_diffuse_color: no pipeline for node %s; skipping.", node_id ) return - pipe.actor.GetProperty().SetDiffuseColor(r, g, b) + 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.""" diff --git a/src/ansys/visor/viewer/vtk/node_pipeline.py b/src/ansys/visor/viewer/vtk/node_pipeline.py index 1e97dcc1..fa611e0b 100644 --- a/src/ansys/visor/viewer/vtk/node_pipeline.py +++ b/src/ansys/visor/viewer/vtk/node_pipeline.py @@ -127,6 +127,9 @@ def set_selected(self, selected: bool, diffuse_rgb: list[float]) -> None: 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: @@ -138,6 +141,46 @@ def set_selected(self, selected: bool, diffuse_rgb: list[float]) -> None: 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, diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index 8fc9bfb2..ac4c9266 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -6,11 +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 -- visibility, opacity and diffuse colour - mutate the resolved pipeline's VTK objects directly; selection and - colour variable 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. +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. @@ -21,7 +21,6 @@ from unittest.mock import MagicMock, patch import pytest -from vtkmodules.vtkRenderingCore import vtkActor from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.renderer.base import IRenderer @@ -97,27 +96,6 @@ def GetNextActor(self): # noqa: N802 return a -class _DummyPipeline: - """VtkNodePipeline stand-in holding a *real* vtkActor. - - The actor is real so that a test can read the mutated value back off - the VTK object (``GetVisibility``, ``GetProperty().GetOpacity()``, - ``GetProperty().GetDiffuseColor()``) rather than merely recording that - some call was made -- a MagicMock would pass even if the production - code mutated the wrong object or the wrong field. - - The three delegated mutations are MagicMocks, because from this module - the behaviour under test is *that the renderer resolved and delegated*. - Their VTK effects are asserted against real objects in - ``tests/unit/vtk/test_node_pipeline.py``. - """ - - def __init__(self): - self.actor = vtkActor() - self.set_selected = MagicMock(name="set_selected") - self.set_color_variable = MagicMock(name="set_color_variable") - self.clear_color_variable = MagicMock(name="clear_color_variable") - # --------------------------------------------------------------------------- # Fixture: VisorLocalRenderer with all VTK infrastructure mocked @@ -381,42 +359,33 @@ def test_refresh_color_variable_range(self, renderer): # =========================================================================== -# 3b. Inline apply bodies: visibility, opacity, diffuse colour +# 3b. Delegated apply bodies: visibility, opacity, diffuse colour, +# selection, colour variable # -# Each mutation is read back off a real vtkActor, and each miss branch -# (unknown node id) is asserted separately. +# 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 TestInlineApplyBodies: +class TestDelegatedApplyBodies: # ------------------------------------------------------------------ - # visibility -- mutates the actor itself + # apply_visibility # ------------------------------------------------------------------ - def test_apply_visibility_shows_actor(self, renderer): - """apply_visibility(True) leaves the actor's visibility flag set.""" - pipe = _DummyPipeline() - pipe.actor.SetVisibility(0) + 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) - assert pipe.actor.GetVisibility() == 1 - - def test_apply_visibility_hides_actor(self, renderer): - """apply_visibility(False) leaves the actor's visibility flag clear.""" - pipe = _DummyPipeline() - pipe.actor.SetVisibility(1) - renderer._pipelines[4] = pipe - - renderer.apply_visibility(4, False) - - assert pipe.actor.GetVisibility() == 0 + 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, mutates nothing.""" - pipe = _DummyPipeline() - pipe.actor.SetVisibility(1) + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") renderer._pipelines[4] = pipe with patch( @@ -425,39 +394,24 @@ def test_apply_visibility_unknown_node_id_is_logged_no_op(self, renderer): renderer.apply_visibility(9999, False) # must not raise mock_logger.debug.assert_called_once() - assert pipe.actor.GetVisibility() == 1 + pipe.set_visibility.assert_not_called() # ------------------------------------------------------------------ - # opacity -- mutates the actor's property + # apply_opacity # ------------------------------------------------------------------ - def test_apply_opacity_sets_property_opacity(self, renderer): - """apply_opacity writes the requested value onto the actor property.""" - pipe = _DummyPipeline() + 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) - assert pipe.actor.GetProperty().GetOpacity() == pytest.approx(0.25) - - def test_apply_opacity_does_not_touch_visibility_or_diffuse_color(self, renderer): - """Opacity lands on the property's opacity field and nothing else.""" - pipe = _DummyPipeline() - pipe.actor.SetVisibility(0) - pipe.actor.GetProperty().SetDiffuseColor(0.25, 0.5, 0.75) - renderer._pipelines[4] = pipe - - renderer.apply_opacity(4, 0.25) - - assert pipe.actor.GetVisibility() == 0 - assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( - (0.25, 0.5, 0.75) - ) + 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, mutates nothing.""" - pipe = _DummyPipeline() - pipe.actor.GetProperty().SetOpacity(0.75) + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") renderer._pipelines[4] = pipe with patch( @@ -466,43 +420,24 @@ def test_apply_opacity_unknown_node_id_is_logged_no_op(self, renderer): renderer.apply_opacity(9999, 0.25) # must not raise mock_logger.debug.assert_called_once() - assert pipe.actor.GetProperty().GetOpacity() == pytest.approx(0.75) + pipe.set_opacity.assert_not_called() # ------------------------------------------------------------------ - # diffuse colour -- mutates the actor property's DiffuseColor + # apply_diffuse_color # ------------------------------------------------------------------ - def test_apply_diffuse_color_sets_property_diffuse_color(self, renderer): - """apply_diffuse_color writes r, g, b onto the property's diffuse colour.""" - pipe = _DummyPipeline() + 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) - assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( - (1.0, 0.0, 0.0) - ) - - def test_apply_diffuse_color_does_not_touch_ambient_color_or_opacity( - self, renderer - ): - """SetDiffuseColor, not SetColor or SetAmbientColor, and opacity is left alone.""" - pipe = _DummyPipeline() - pipe.actor.GetProperty().SetAmbientColor(0.25, 0.5, 0.75) - pipe.actor.GetProperty().SetOpacity(0.75) - renderer._pipelines[4] = pipe - - renderer.apply_diffuse_color(4, 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) + 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, mutates nothing.""" - pipe = _DummyPipeline() - pipe.actor.GetProperty().SetDiffuseColor(0.25, 0.5, 0.75) + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") renderer._pipelines[4] = pipe with patch( @@ -511,21 +446,7 @@ def test_apply_diffuse_color_unknown_node_id_is_logged_no_op(self, renderer): renderer.apply_diffuse_color(9999, 1.0, 0.0, 0.0) # must not raise mock_logger.debug.assert_called_once() - assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( - (0.25, 0.5, 0.75) - ) - - -# =========================================================================== -# 3c. Delegated apply bodies: 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: + pipe.set_diffuse_color.assert_not_called() # ------------------------------------------------------------------ # apply_selected @@ -533,7 +454,7 @@ class TestDelegatedApplyBodies: def test_apply_selected_delegates_to_pipeline(self, renderer): """Selection state and the given colour are passed straight through.""" - pipe = _DummyPipeline() + pipe = MagicMock(name="pipeline") renderer._pipelines[4] = pipe renderer.apply_selected(4, True, [1.0, 0.0, 0.0]) @@ -542,7 +463,7 @@ def test_apply_selected_delegates_to_pipeline(self, renderer): 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 = _DummyPipeline() + pipe = MagicMock(name="pipeline") renderer._pipelines[4] = pipe with patch( @@ -559,7 +480,7 @@ def test_apply_selected_unknown_node_id_is_logged_no_op(self, renderer): def test_apply_color_variable_delegates_with_given_arguments(self, renderer): """The association is forwarded unchanged; spectrum_id is not forwarded.""" - pipe = _DummyPipeline() + pipe = MagicMock(name="pipeline") renderer._pipelines[4] = pipe renderer.apply_color_variable( @@ -572,7 +493,7 @@ def test_apply_color_variable_delegates_with_given_arguments(self, renderer): 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 = _DummyPipeline() + pipe = MagicMock(name="pipeline") renderer._pipelines[4] = pipe with patch( @@ -590,7 +511,7 @@ def test_apply_color_variable_unknown_node_id_is_logged_no_op(self, renderer): # ------------------------------------------------------------------ def test_clear_color_variable_delegates_to_pipeline(self, renderer): - pipe = _DummyPipeline() + pipe = MagicMock(name="pipeline") renderer._pipelines[4] = pipe renderer.clear_color_variable(4) @@ -599,7 +520,7 @@ def test_clear_color_variable_delegates_to_pipeline(self, renderer): 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 = _DummyPipeline() + pipe = MagicMock(name="pipeline") renderer._pipelines[4] = pipe with patch( diff --git a/tests/unit/vtk/test_node_pipeline.py b/tests/unit/vtk/test_node_pipeline.py index 96115c51..5a03955a 100644 --- a/tests/unit/vtk/test_node_pipeline.py +++ b/tests/unit/vtk/test_node_pipeline.py @@ -222,6 +222,86 @@ def test_set_selected_false_leaves_ambient_color_untouched(poly_dataset): ) +# --------------------------------------------------------------------------- +# 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 # --------------------------------------------------------------------------- From 174ee07fb6360598f7705369abe52e32eb5650eb Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 28 Aug 2026 12:23:40 -0700 Subject: [PATCH 09/13] register per-part trigger handlers and serialize VTK access with a lock --- src/ansys/visor/viewer/app/trame/local_app.py | 144 ++++ src/ansys/visor/viewer/app/visor_vtk_local.py | 4 + src/ansys/visor/viewer/vtk/scene/base.py | 357 +++++++--- tests/unit/app/test_local_app.py | 281 ++++++++ tests/unit/app/test_visor_vtk_local.py | 82 +++ tests/unit/vtk/scene/test_base.py | 662 +++++++++++++++++- tests/unit/vtk/scene/test_local_scene.py | 5 + 7 files changed, 1439 insertions(+), 96 deletions(-) create mode 100644 tests/unit/app/test_local_app.py diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index 8d2b545c..b865b9b3 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -1,11 +1,13 @@ import urllib.parse from logging import Logger +from typing import List, Optional, Protocol 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 +16,37 @@ 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: ... + + @TrameApp() class LocalApp: @@ -29,6 +62,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 +80,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 +89,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 +198,106 @@ 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. Required payload keys are read directly: a missing key raises + # KeyError on the trame event-loop thread, which is accepted because the + # only caller is the client written against this contract. + # ------------------------------------------------------------------ + + 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") + def set_part_visibility(self, payload: dict) -> 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["nodeId"], payload["visible"]) + + @trigger("set_part_opacity") + def set_part_opacity(self, payload: dict) -> None: + """Frontend -> Backend: set one part's opacity.""" + api = self._part_state_api("set_part_opacity") + if api is None: + return + api.set_part_opacity(payload["nodeId"], payload["opacity"]) + + @trigger("set_part_diffuse_color") + def set_part_diffuse_color(self, payload: dict) -> 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. + """ + api = self._part_state_api("set_part_diffuse_color") + if api is None: + return + api.set_part_diffuse_color(payload["nodeId"], payload["diffuseRgb"]) + + @trigger("set_part_selected") + def set_part_selected(self, payload: dict) -> 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["nodeId"], payload["selected"]) + + @trigger("set_part_color_variable") + def set_part_color_variable(self, payload: dict) -> None: + """Frontend -> Backend: colour one part by a scalar variable. + + ``association`` is parsed here, at the boundary, into a + :class:`VisorVtkVariableType` by exact value lookup -- never + upper-cased, never passed on as a bare string. A value that is not a + member 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 + try: + association = VisorVtkVariableType(payload["association"]) + except ValueError: + logger.warning( + "set_part_color_variable: %r is not a VisorVtkVariableType; ignoring.", + payload["association"], + ) + return + api.set_part_color_variable( + payload["nodeId"], + payload["variableId"], + association, + payload["arrayName"], + payload["component"], + payload["min"], + payload["max"], + ) + + @trigger("clear_part_color_variable") + def clear_part_color_variable(self, payload: dict) -> 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["nodeId"]) + 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/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index efcadb0a..344b198a 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,125 @@ 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, then push the mutated state to the wasm client with + # ``flush_wasm_state()``. An unresolvable node id is a logged no-op at + # every layer: nothing is applied and nothing is flushed. + # + # 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) + self._renderer.flush_wasm_state() + + 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) + self._renderer.flush_wasm_state() + + 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] + ) + self._renderer.flush_wasm_state() + + 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) + self._renderer.flush_wasm_state() + + 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 + ) + self._renderer.flush_wasm_state() + + 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) + self._renderer.flush_wasm_state() + # ------------------------------------------------------------------ # 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..d71e1315 --- /dev/null +++ b/tests/unit/app/test_local_app.py @@ -0,0 +1,281 @@ +"""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. + +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 + 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/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 39a3dc76..d17214c8 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,625 @@ 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) + + assert record["calls"] == 1 + 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) + + assert record["calls"] == 1 + 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]) + + assert record["calls"] == 1 + 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) + + assert record["calls"] == 1 + 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 + ) + + assert record["calls"] == 1 + 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) + + assert record["calls"] == 1 + 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 From 34b27f00b06c50d822de72aefa904cabc4cbdc14 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 28 Aug 2026 12:27:03 -0700 Subject: [PATCH 10/13] replace hand-written payloads in LocalApp with Pydantic models and decorator --- src/ansys/visor/viewer/app/trame/local_app.py | 202 ++++++++++++++---- tests/unit/app/test_local_app.py | 175 +++++++++++++++ 2 files changed, 335 insertions(+), 42 deletions(-) diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index b865b9b3..23abd24f 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -1,7 +1,9 @@ +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 @@ -47,6 +49,111 @@ def set_part_color_variable( 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: @@ -201,14 +308,20 @@ def perf_report_server_update(self, payload: dict): # ------------------------------------------------------------------ # 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. Required payload keys are read directly: a missing key raises - # KeyError on the trame event-loop thread, which is accepted because the - # only caller is the client written against this contract. + # 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: @@ -219,35 +332,45 @@ def _part_state_api(self, trigger_name: str) -> ScenePartStateApi | None: return self._scene_part_state_api @trigger("set_part_visibility") - def set_part_visibility(self, payload: dict) -> None: + @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["nodeId"], payload["visible"]) + api.set_part_visibility(payload.node_id, payload.visible) @trigger("set_part_opacity") - def set_part_opacity(self, payload: dict) -> None: - """Frontend -> Backend: set one part's 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["nodeId"], payload["opacity"]) + api.set_part_opacity(payload.node_id, payload.opacity) @trigger("set_part_diffuse_color") - def set_part_diffuse_color(self, payload: dict) -> None: + @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. + 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["nodeId"], payload["diffuseRgb"]) + api.set_part_diffuse_color(payload.node_id, payload.diffuse_rgb) @trigger("set_part_selected") - def set_part_selected(self, payload: dict) -> None: + @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 @@ -256,47 +379,42 @@ def set_part_selected(self, payload: dict) -> None: api = self._part_state_api("set_part_selected") if api is None: return - api.set_part_selected(payload["nodeId"], payload["selected"]) + api.set_part_selected(payload.node_id, payload.selected) @trigger("set_part_color_variable") - def set_part_color_variable(self, payload: dict) -> None: + @parse_payload(SetPartColorVariablePayload) + def set_part_color_variable(self, payload) -> None: """Frontend -> Backend: colour one part by a scalar variable. - ``association`` is parsed here, at the boundary, into a - :class:`VisorVtkVariableType` by exact value lookup -- never - upper-cased, never passed on as a bare string. A value that is not a - member 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. + ``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 - try: - association = VisorVtkVariableType(payload["association"]) - except ValueError: - logger.warning( - "set_part_color_variable: %r is not a VisorVtkVariableType; ignoring.", - payload["association"], - ) - return api.set_part_color_variable( - payload["nodeId"], - payload["variableId"], - association, - payload["arrayName"], - payload["component"], - payload["min"], - payload["max"], + payload.node_id, + payload.variable_id, + payload.association, + payload.array_name, + payload.component, + payload.min_val, + payload.max_val, ) @trigger("clear_part_color_variable") - def clear_part_color_variable(self, payload: dict) -> None: + @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["nodeId"]) + api.clear_part_color_variable(payload.node_id) def set_only_cookie(self, key: str, value: str): """ diff --git a/tests/unit/app/test_local_app.py b/tests/unit/app/test_local_app.py index d71e1315..25ea4498 100644 --- a/tests/unit/app/test_local_app.py +++ b/tests/unit/app/test_local_app.py @@ -12,6 +12,18 @@ 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 @@ -279,3 +291,166 @@ def test_trigger_is_a_logged_no_op_when_no_coordinator_injected(app_without_api, 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 + + From 77f9a9dceb262adc83ec1a7ddddc03ccab562d43 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 28 Aug 2026 12:28:21 -0700 Subject: [PATCH 11/13] comment out wasm flush --- src/ansys/visor/viewer/vtk/scene/base.py | 18 ++++++---- tests/unit/vtk/scene/test_base.py | 42 ++++++++++++++---------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 344b198a..c5b157f7 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -400,7 +400,8 @@ def set_part_visibility(self, node_id: int, visible: bool) -> None: logger.debug("set_part_visibility: no dataset owns node %s; skipping.", node_id) return self._renderer.apply_visibility(node_id, visible) - self._renderer.flush_wasm_state() + # TODO: uncomment when adding round trips + # self._renderer.flush_wasm_state() def set_part_opacity(self, node_id: int, opacity: float) -> None: """Set the opacity of the part identified by *node_id*.""" @@ -409,7 +410,8 @@ def set_part_opacity(self, node_id: int, opacity: float) -> None: logger.debug("set_part_opacity: no dataset owns node %s; skipping.", node_id) return self._renderer.apply_opacity(node_id, opacity) - self._renderer.flush_wasm_state() + # TODO: uncomment when adding round trips + # self._renderer.flush_wasm_state() def set_part_diffuse_color(self, node_id: int, diffuse_rgb: list[float] | None) -> None: """ @@ -431,7 +433,8 @@ def set_part_diffuse_color(self, node_id: int, diffuse_rgb: list[float] | None) self._renderer.apply_diffuse_color( node_id, applied_rgb[0], applied_rgb[1], applied_rgb[2] ) - self._renderer.flush_wasm_state() + # TODO: uncomment when adding round trips + # self._renderer.flush_wasm_state() def set_part_selected(self, node_id: int, selected: bool) -> None: """ @@ -453,7 +456,8 @@ def set_part_selected(self, node_id: int, selected: bool) -> None: stored_rgb if stored_rgb is not None else list(VisorColors.DefaultMeshColor) ) self._renderer.apply_selected(node_id, selected, diffuse_rgb) - self._renderer.flush_wasm_state() + # TODO: uncomment when adding round trips + # self._renderer.flush_wasm_state() def set_part_color_variable( self, @@ -481,7 +485,8 @@ def set_part_color_variable( self._renderer.apply_color_variable( node_id, variable_id, association, array_name, component, min_val, max_val ) - self._renderer.flush_wasm_state() + # TODO: uncomment when adding round trips + # self._renderer.flush_wasm_state() def clear_part_color_variable(self, node_id: int) -> None: """ @@ -497,7 +502,8 @@ def clear_part_color_variable(self, node_id: int) -> None: ) return self._renderer.clear_color_variable(node_id) - self._renderer.flush_wasm_state() + # TODO: uncomment when adding round trips + # self._renderer.flush_wasm_state() # ------------------------------------------------------------------ # Internal helpers diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index d17214c8..922a0e13 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -235,9 +235,10 @@ def test_set_part_visibility_flushes_under_the_lock_after_the_apply(scene, pipel scene.set_part_visibility(NODE_ID, False) - assert record["calls"] == 1 - assert record["depth"] >= 1 - assert record["value"] == 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"] == 0 assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count assert scene._vtk_lock.depth == 0 @@ -267,9 +268,10 @@ def test_set_part_opacity_flushes_under_the_lock_after_the_apply(scene, pipeline scene.set_part_opacity(NODE_ID, 0.25) - assert record["calls"] == 1 - assert record["depth"] >= 1 - assert record["value"] == pytest.approx(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 @@ -318,9 +320,10 @@ def test_set_part_diffuse_color_flushes_under_the_lock_after_the_apply(scene, pi scene.set_part_diffuse_color(NODE_ID, [1.0, 0.0, 0.0]) - assert record["calls"] == 1 - assert record["depth"] >= 1 - assert record["value"] == pytest.approx((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 @@ -370,9 +373,10 @@ def test_set_part_selected_flushes_under_the_lock_after_the_apply(scene, pipelin scene.set_part_selected(NODE_ID, True) - assert record["calls"] == 1 - assert record["depth"] >= 1 - assert record["value"] == pytest.approx(0.5) + # 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 @@ -423,9 +427,10 @@ def test_set_part_color_variable_flushes_under_the_lock_after_the_apply(scene, p NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 ) - assert record["calls"] == 1 - assert record["depth"] >= 1 - assert record["value"] == "pressure" + # 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 @@ -462,9 +467,10 @@ def test_clear_part_color_variable_flushes_under_the_lock_after_the_apply(scene, scene.clear_part_color_variable(NODE_ID) - assert record["calls"] == 1 - assert record["depth"] >= 1 - assert record["value"] == 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"] == 0 assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count From 44233a44ffec0fada08add76548c6d44d610a069 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:02:19 +0000 Subject: [PATCH 12/13] chore: adding changelog file 52.added.md [dependabot-skip] --- doc/changelog.d/52.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/52.added.md 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 From 85b66307d832883bd986093545aaeaa56b6d577b Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Mon, 31 Aug 2026 08:02:08 -0700 Subject: [PATCH 13/13] remove TODOs and update comment on flush_wasm_state --- src/ansys/visor/viewer/vtk/scene/base.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index c5b157f7..a7cbd706 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -385,9 +385,11 @@ def pick_geometry(self, actor_wasm_id, cell_id, mode, world_x, world_y, world_z) # # 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, then push the mutated state to the wasm client with - # ``flush_wasm_state()``. An unresolvable node id is a logged no-op at - # every layer: nothing is applied and nothing is flushed. + # 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. @@ -400,8 +402,6 @@ def set_part_visibility(self, node_id: int, visible: bool) -> None: logger.debug("set_part_visibility: no dataset owns node %s; skipping.", node_id) return self._renderer.apply_visibility(node_id, visible) - # TODO: uncomment when adding round trips - # self._renderer.flush_wasm_state() def set_part_opacity(self, node_id: int, opacity: float) -> None: """Set the opacity of the part identified by *node_id*.""" @@ -410,8 +410,6 @@ def set_part_opacity(self, node_id: int, opacity: float) -> None: logger.debug("set_part_opacity: no dataset owns node %s; skipping.", node_id) return self._renderer.apply_opacity(node_id, opacity) - # TODO: uncomment when adding round trips - # self._renderer.flush_wasm_state() def set_part_diffuse_color(self, node_id: int, diffuse_rgb: list[float] | None) -> None: """ @@ -433,8 +431,6 @@ def set_part_diffuse_color(self, node_id: int, diffuse_rgb: list[float] | None) self._renderer.apply_diffuse_color( node_id, applied_rgb[0], applied_rgb[1], applied_rgb[2] ) - # TODO: uncomment when adding round trips - # self._renderer.flush_wasm_state() def set_part_selected(self, node_id: int, selected: bool) -> None: """ @@ -456,8 +452,6 @@ def set_part_selected(self, node_id: int, selected: bool) -> None: stored_rgb if stored_rgb is not None else list(VisorColors.DefaultMeshColor) ) self._renderer.apply_selected(node_id, selected, diffuse_rgb) - # TODO: uncomment when adding round trips - # self._renderer.flush_wasm_state() def set_part_color_variable( self, @@ -485,8 +479,6 @@ def set_part_color_variable( self._renderer.apply_color_variable( node_id, variable_id, association, array_name, component, min_val, max_val ) - # TODO: uncomment when adding round trips - # self._renderer.flush_wasm_state() def clear_part_color_variable(self, node_id: int) -> None: """ @@ -502,8 +494,6 @@ def clear_part_color_variable(self, node_id: int) -> None: ) return self._renderer.clear_color_variable(node_id) - # TODO: uncomment when adding round trips - # self._renderer.flush_wasm_state() # ------------------------------------------------------------------ # Internal helpers