From 38c5e9e4cbe52c8693b11608cd850385716845e1 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Fri, 28 Aug 2026 12:11:51 -0700 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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 # ---------------------------------------------------------------------------