diff --git a/doc/changelog.d/51.added.md b/doc/changelog.d/51.added.md new file mode 100644 index 00000000..d9507670 --- /dev/null +++ b/doc/changelog.d/51.added.md @@ -0,0 +1 @@ +Remote rendering 3.1b - implement per-part apply logic on the renderer and node pipeline diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index 4d07003a..e0dd8670 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -158,15 +158,51 @@ def deregister_all(self) -> None: # ------------------------------------------------------------------ def apply_visibility(self, node_id: int, visible: bool) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.apply_visibility`. + + Resolves the pipeline and delegates to + :meth:`VtkNodePipeline.set_visibility`. An unknown *node_id* is a + logged no-op, never a raise. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "apply_visibility: no pipeline for node %s; skipping.", node_id + ) + return + pipe.set_visibility(visible) def apply_opacity(self, node_id: int, opacity: float) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.apply_opacity`. + + Resolves the pipeline and delegates to + :meth:`VtkNodePipeline.set_opacity`. An unknown *node_id* is a + logged no-op, never a raise. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "apply_opacity: no pipeline for node %s; skipping.", node_id + ) + return + pipe.set_opacity(opacity) def apply_diffuse_color( self, node_id: int, r: float, g: float, b: float ) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.apply_diffuse_color`. + + Resolves the pipeline and delegates to + :meth:`VtkNodePipeline.set_diffuse_color`. An unknown *node_id* + is a logged no-op, never a raise. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "apply_diffuse_color: no pipeline for node %s; skipping.", node_id + ) + return + pipe.set_diffuse_color(r, g, b) def apply_edge_visibility(self, node_id: int, edge_visible: bool) -> None: """No-op in Story 1.2. Phase 3 populates.""" @@ -174,7 +210,21 @@ def apply_edge_visibility(self, node_id: int, edge_visible: bool) -> None: def apply_selected( self, node_id: int, selected: bool, diffuse_rgb: list ) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.apply_selected`. + + Resolves the pipeline and delegates to + :meth:`VtkNodePipeline.set_selected`. *diffuse_rgb* is passed + through as given; supplying a default when the part has no stored + colour is the coordinator's job, not the renderer's. An unknown + *node_id* is a logged no-op, never a raise. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "apply_selected: no pipeline for node %s; skipping.", node_id + ) + return + pipe.set_selected(selected, diffuse_rgb) def apply_color_variable( self, @@ -186,10 +236,39 @@ def apply_color_variable( min_val: float, max_val: float, ) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.apply_color_variable`. + + Resolves the pipeline and delegates to + :meth:`VtkNodePipeline.set_color_variable`. *array_type* must + already be a :class:`VisorVtkVariableType`; it is parsed at the + trigger boundary, never here, and the pipeline compares it by + identity, so any other value is a logged no-op there. + *spectrum_id* is not forwarded -- it is stored opaquely by the + registry and is not needed to configure the mapper. An unknown + *node_id* is a logged no-op, never a raise. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "apply_color_variable: no pipeline for node %s; skipping.", node_id + ) + return + pipe.set_color_variable(array_type, array_name, component, min_val, max_val) def clear_color_variable(self, node_id: int) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """See :meth:`IRenderer.clear_color_variable`. + + Resolves the pipeline and delegates to + :meth:`VtkNodePipeline.clear_color_variable`. An unknown + *node_id* is a logged no-op, never a raise. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "clear_color_variable: no pipeline for node %s; skipping.", node_id + ) + return + pipe.clear_color_variable() def refresh_color_variable_range( self, diff --git a/src/ansys/visor/viewer/vtk/node_pipeline.py b/src/ansys/visor/viewer/vtk/node_pipeline.py index 7adeb5ad..fa611e0b 100644 --- a/src/ansys/visor/viewer/vtk/node_pipeline.py +++ b/src/ansys/visor/viewer/vtk/node_pipeline.py @@ -27,6 +27,7 @@ from vtkmodules.vtkRenderingCore import vtkActor, vtkPolyDataMapper from ansys.visor.viewer.core.visor_colors import VisorColors +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger logger = VisorDefaultLogger(__name__) @@ -105,6 +106,145 @@ def update_input( algorithm = algorithm_filter(algorithm) self.mapper.SetInputConnection(algorithm.GetOutputPort()) + # ------------------------------------------------------------------ + # Per-part visual mutations + # + # These bodies live here rather than on the renderer because each is + # more than one VTK call, and the colour-variable body additionally + # reads a pipeline-internal object (``base_algorithm``). Identity + # resolution (node id -> pipeline) and the miss branch stay on the + # renderer, which is the only holder of the id -> pipeline map. + # ------------------------------------------------------------------ + + def set_selected(self, selected: bool, diffuse_rgb: list[float]) -> None: + """Apply or remove the selection highlight on this part. + + Parameters + ---------- + selected: + Target state. Absolute, never a toggle. + diffuse_rgb: + The part's diffuse colour, re-applied unconditionally on both + branches -- selection changes the ambient/diffuse lighting + terms, it does not replace the part's colour. + + Note: :meth:`set_diffuse_color` also writes ``SetDiffuseColor``; + this method is not a substitute for it and vice versa. + """ + prop = self.actor.GetProperty() + if selected: + prop.SetAmbientColor(0 / 255, 62 / 255, 111 / 255) + prop.SetDiffuse(0.5) + prop.SetAmbient(0.5) + else: + prop.SetDiffuse(1.0) + prop.SetAmbient(0.0) + prop.SetDiffuseColor(*diffuse_rgb) + + def set_visibility(self, visible: bool) -> None: + """Show or hide this part. + + Mutates the actor itself, not its property. + + Parameters + ---------- + visible: + Target state. Absolute, never a toggle. + """ + self.actor.SetVisibility(1 if visible else 0) + + def set_opacity(self, opacity: float) -> None: + """Set this part's opacity. + + Mutates the actor's property. + + Parameters + ---------- + opacity: + The opacity value to apply, passed through unchanged. + """ + self.actor.GetProperty().SetOpacity(opacity) + + def set_diffuse_color(self, r: float, g: float, b: float) -> None: + """Set this part's diffuse colour. + + Mutates the actor property's diffuse colour only. + + Parameters + ---------- + r, g, b: + The diffuse colour components, passed through unchanged. + + Note: :meth:`set_selected` also writes ``SetDiffuseColor`` on both + of its branches; this method is not a substitute for it and vice + versa. + """ + self.actor.GetProperty().SetDiffuseColor(r, g, b) + + def set_color_variable( + self, + association: VisorVtkVariableType, + array_name: str, + component: int, + min_val: float, + max_val: float, + ) -> None: + """Colour this part by a scalar array, over an explicit range. + + Configures the mapper only. No lookup table is authored here: the + table belongs to a later story, and until then a reference resolves + against the client-held default table. + + ``association`` is compared by identity against + :class:`VisorVtkVariableType`; it is never parsed, upper-cased or + string-compared. A value that is neither member, and an array name + that does not exist on the input, are both logged no-ops that + mutate nothing. + """ + in_data = self.base_algorithm.GetInput() + if association is VisorVtkVariableType.POINT: + field = in_data.GetPointData() + elif association is VisorVtkVariableType.CELL: + field = in_data.GetCellData() + else: + logger.warning( + "set_color_variable: association %r is not a VisorVtkVariableType; " + "skipping.", + association, + ) + return + + if field.GetArray(array_name) is None: + logger.warning( + "set_color_variable: array %r not found for association %s; skipping.", + array_name, + association, + ) + return + + mapper = self.mapper + if association is VisorVtkVariableType.POINT: + mapper.SetScalarModeToUsePointFieldData() + else: + mapper.SetScalarModeToUseCellFieldData() + mapper.SelectColorArray(array_name) + mapper.SetArrayComponent(component) + mapper.SetScalarRange(min_val, max_val) + mapper.SetColorModeToMapScalars() + mapper.SetScalarVisibility(True) + # Makes the mapper honour the range set above rather than the + # lookup table's own range. + mapper.SetUseLookupTableScalarRange(0) + + def clear_color_variable(self) -> None: + """Stop colouring this part by a scalar array. + + One call. Does not touch a lookup table and does not restore a + diffuse colour -- the part's colour is whatever was last applied + to it. + """ + self.mapper.SetScalarVisibility(False) + # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index c0bd33ae..ac4c9266 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -6,8 +6,11 @@ 1. Node lifecycle -- register_node / deregister_node (the non-trivial seam). 2. Interface conformance -- NullRenderer and VisorLocalRenderer both satisfy IRenderer's abstract contract. -3. Per-part visual mutations -- one focused test per property; - tests assert they accept their arguments and return None. +3. Per-part visual mutations -- visibility, opacity, diffuse colour, + selection and colour variable all resolve the pipeline and delegate to + VtkNodePipeline (their VTK effects are asserted in + tests/unit/vtk/test_node_pipeline.py); edge visibility and the + colour-variable range refresh stay no-ops. 4. Camera round-trip -- reset_camera, sync_camera / get_camera_state. 5. Render / flush delegation. 6. pick_geometry -- vertex, edge, face modes. @@ -19,6 +22,7 @@ import pytest +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.renderer.base import IRenderer from ansys.visor.viewer.renderer.local_renderer import VisorLocalRenderer from ansys.visor.viewer.renderer.null_renderer import NullRenderer @@ -92,6 +96,7 @@ def GetNextActor(self): # noqa: N802 return a + # --------------------------------------------------------------------------- # Fixture: VisorLocalRenderer with all VTK infrastructure mocked # --------------------------------------------------------------------------- @@ -336,38 +341,196 @@ def test_deregister_all_empties_registry_and_detaches_all_actors(self, renderer) # =========================================================================== class TestPerPartMutations: - """Each method accepts its contract arguments and returns None without raising.""" - - def test_apply_visibility(self, renderer): - assert renderer.apply_visibility(1, True) is None - - def test_apply_opacity(self, renderer): - assert renderer.apply_opacity(1, 0.5) is None + """The two permanently un-implemented methods accept their arguments. - def test_apply_diffuse_color(self, renderer): - assert renderer.apply_diffuse_color(1, 1.0, 0.0, 0.0) is None + Both stay no-ops beyond this story: edge visibility is a global display + toggle, and the colour-variable range is not held per part. + """ def test_apply_edge_visibility(self, renderer): assert renderer.apply_edge_visibility(1, False) is None - def test_apply_selected(self, renderer): - assert renderer.apply_selected(1, True, [1.0, 0.0, 0.0]) is None - def test_apply_color_variable(self, renderer): + def test_refresh_color_variable_range(self, renderer): assert ( - renderer.apply_color_variable(1, "sp-1", "POINT", "pressure", -1, 0.0, 1.0) + renderer.refresh_color_variable_range(1, "sp-1", "CELL", "temp", 0) is None ) - def test_clear_color_variable(self, renderer): - assert renderer.clear_color_variable(1) is None - def test_refresh_color_variable_range(self, renderer): - assert ( - renderer.refresh_color_variable_range(1, "sp-1", "CELL", "temp", 0) - is None +# =========================================================================== +# 3b. Delegated apply bodies: visibility, opacity, diffuse colour, +# selection, colour variable +# +# The VTK effects of these live on VtkNodePipeline and are asserted in +# tests/unit/vtk/test_node_pipeline.py. What is asserted here is that +# the renderer resolved the pipeline and delegated with the arguments +# it was given, and that an unknown node id is a logged no-op. +# =========================================================================== + +class TestDelegatedApplyBodies: + + # ------------------------------------------------------------------ + # apply_visibility + # ------------------------------------------------------------------ + + def test_apply_visibility_delegates_to_pipeline(self, renderer): + """The visibility flag is passed straight through.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.apply_visibility(4, True) + + pipe.set_visibility.assert_called_once_with(True) + + def test_apply_visibility_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.apply_visibility(9999, False) # must not raise + + mock_logger.debug.assert_called_once() + pipe.set_visibility.assert_not_called() + + # ------------------------------------------------------------------ + # apply_opacity + # ------------------------------------------------------------------ + + def test_apply_opacity_delegates_to_pipeline(self, renderer): + """The opacity value is passed straight through.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.apply_opacity(4, 0.25) + + pipe.set_opacity.assert_called_once_with(0.25) + + def test_apply_opacity_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.apply_opacity(9999, 0.25) # must not raise + + mock_logger.debug.assert_called_once() + pipe.set_opacity.assert_not_called() + + # ------------------------------------------------------------------ + # apply_diffuse_color + # ------------------------------------------------------------------ + + def test_apply_diffuse_color_delegates_to_pipeline(self, renderer): + """r, g, b are passed straight through.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.apply_diffuse_color(4, 1.0, 0.0, 0.0) + + pipe.set_diffuse_color.assert_called_once_with(1.0, 0.0, 0.0) + + def test_apply_diffuse_color_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.apply_diffuse_color(9999, 1.0, 0.0, 0.0) # must not raise + + mock_logger.debug.assert_called_once() + pipe.set_diffuse_color.assert_not_called() + + # ------------------------------------------------------------------ + # apply_selected + # ------------------------------------------------------------------ + + def test_apply_selected_delegates_to_pipeline(self, renderer): + """Selection state and the given colour are passed straight through.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.apply_selected(4, True, [1.0, 0.0, 0.0]) + + pipe.set_selected.assert_called_once_with(True, [1.0, 0.0, 0.0]) + + def test_apply_selected_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.apply_selected(9999, True, [1.0, 0.0, 0.0]) # must not raise + + mock_logger.debug.assert_called_once() + pipe.set_selected.assert_not_called() + + # ------------------------------------------------------------------ + # apply_color_variable + # ------------------------------------------------------------------ + + def test_apply_color_variable_delegates_with_given_arguments(self, renderer): + """The association is forwarded unchanged; spectrum_id is not forwarded.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.apply_color_variable( + 4, "sp-1", VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5 ) + pipe.set_color_variable.assert_called_once_with( + VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5 + ) + + def test_apply_color_variable_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.apply_color_variable( + 9999, "sp-1", VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5 + ) # must not raise + + mock_logger.debug.assert_called_once() + pipe.set_color_variable.assert_not_called() + + # ------------------------------------------------------------------ + # clear_color_variable + # ------------------------------------------------------------------ + + def test_clear_color_variable_delegates_to_pipeline(self, renderer): + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + renderer.clear_color_variable(4) + + pipe.clear_color_variable.assert_called_once_with() + + def test_clear_color_variable_unknown_node_id_is_logged_no_op(self, renderer): + """An unregistered node id logs at debug, does not raise, delegates nothing.""" + pipe = MagicMock(name="pipeline") + renderer._pipelines[4] = pipe + + with patch( + "ansys.visor.viewer.renderer.local_renderer.logger" + ) as mock_logger: + renderer.clear_color_variable(9999) # must not raise + + mock_logger.debug.assert_called_once() + pipe.clear_color_variable.assert_not_called() + # =========================================================================== # 4. Camera diff --git a/tests/unit/vtk/test_node_pipeline.py b/tests/unit/vtk/test_node_pipeline.py index 8443ca7a..5a03955a 100644 --- a/tests/unit/vtk/test_node_pipeline.py +++ b/tests/unit/vtk/test_node_pipeline.py @@ -1,8 +1,9 @@ """Unit tests for VtkNodePipeline.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest +from vtkmodules.vtkCommonCore import vtkFloatArray from vtkmodules.vtkCommonDataModel import vtkPlane, vtkPolyData, vtkUnstructuredGrid from vtkmodules.vtkFiltersCore import vtkAppendPolyData from vtkmodules.vtkFiltersGeometry import vtkGeometryFilter @@ -10,6 +11,7 @@ from vtkmodules.vtkRenderingCore import vtkActor, vtkPolyDataMapper from ansys.visor.viewer.core.visor_colors import VisorColors +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.vtk.node_pipeline import VtkNodePipeline @@ -26,6 +28,38 @@ def unstructured_dataset() -> vtkUnstructuredGrid: return vtkUnstructuredGrid() +@pytest.fixture +def array_dataset() -> vtkPolyData: + """Sphere output carrying one named point array and one named cell array. + + Each array is sized from the dataset's own counts -- the point array to + ``GetNumberOfPoints()`` and the cell array to ``GetNumberOfCells()`` -- so + the arrays are valid for the association they are attached to. For the + default vtkSphereSource that is 50 points and 96 cells. + """ + src = vtkSphereSource() + src.Update() + dataset = src.GetOutput() + + pressure = vtkFloatArray() + pressure.SetName("pressure") + pressure.SetNumberOfComponents(1) + pressure.SetNumberOfTuples(dataset.GetNumberOfPoints()) + for i in range(dataset.GetNumberOfPoints()): + pressure.SetTuple1(i, float(i)) + dataset.GetPointData().AddArray(pressure) + + temperature = vtkFloatArray() + temperature.SetName("temperature") + temperature.SetNumberOfComponents(1) + temperature.SetNumberOfTuples(dataset.GetNumberOfCells()) + for i in range(dataset.GetNumberOfCells()): + temperature.SetTuple1(i, float(i)) + dataset.GetCellData().AddArray(temperature) + + return dataset + + # --------------------------------------------------------------------------- # from_dataset # --------------------------------------------------------------------------- @@ -132,3 +166,273 @@ def algorithm_filter(base): assert pipe.mapper.GetInputConnection(0, 0).GetProducer() is wrapper +# --------------------------------------------------------------------------- +# set_selected +# +# The selection highlight colour is ambient RGB 0, 62, 111 out of 255. The +# expected values below are written as decimal literals so that the test does +# not restate the production expression. +# --------------------------------------------------------------------------- + +SELECTION_AMBIENT_COLOR = (0.0, 0.24313725490196078, 0.43529411764705883) + + +def test_set_selected_true_sets_selection_ambient_color_and_lighting(poly_dataset): + pipe = VtkNodePipeline.from_dataset(poly_dataset) + + pipe.set_selected(True, [0.1, 0.2, 0.3]) + + prop = pipe.actor.GetProperty() + assert prop.GetAmbientColor() == pytest.approx(SELECTION_AMBIENT_COLOR) + assert prop.GetDiffuse() == pytest.approx(0.5) + assert prop.GetAmbient() == pytest.approx(0.5) + + +def test_set_selected_true_still_applies_the_given_diffuse_color(poly_dataset): + """Selecting changes the lighting terms; it does not replace the colour.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + + pipe.set_selected(True, [1.0, 0.0, 0.0]) + + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx((1.0, 0.0, 0.0)) + + +def test_set_selected_false_restores_lighting_defaults(poly_dataset): + pipe = VtkNodePipeline.from_dataset(poly_dataset) + pipe.set_selected(True, [1.0, 0.0, 0.0]) + + pipe.set_selected(False, [0.0, 1.0, 0.0]) + + prop = pipe.actor.GetProperty() + assert prop.GetDiffuse() == pytest.approx(1.0) + assert prop.GetAmbient() == pytest.approx(0.0) + assert prop.GetDiffuseColor() == pytest.approx((0.0, 1.0, 0.0)) + + +def test_set_selected_false_leaves_ambient_color_untouched(poly_dataset): + """Deselecting resets the ambient *term*, not the ambient colour.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + # Seeded ambient colour: a distinctive value neither branch writes. + pipe.actor.GetProperty().SetAmbientColor(0.11, 0.22, 0.33) + + pipe.set_selected(False, [0.0, 1.0, 0.0]) + + assert pipe.actor.GetProperty().GetAmbientColor() == pytest.approx( + (0.11, 0.22, 0.33) + ) + + +# --------------------------------------------------------------------------- +# set_visibility +# --------------------------------------------------------------------------- + +def test_set_visibility_true_shows_actor(poly_dataset): + """set_visibility(True) leaves the actor's visibility flag set.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + pipe.actor.SetVisibility(0) + + pipe.set_visibility(True) + + assert pipe.actor.GetVisibility() == 1 + + +def test_set_visibility_false_hides_actor(poly_dataset): + """set_visibility(False) leaves the actor's visibility flag clear.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + pipe.actor.SetVisibility(1) + + pipe.set_visibility(False) + + assert pipe.actor.GetVisibility() == 0 + + +# --------------------------------------------------------------------------- +# set_opacity +# --------------------------------------------------------------------------- + +def test_set_opacity_sets_property_opacity(poly_dataset): + """set_opacity writes the requested value onto the actor property.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + + pipe.set_opacity(0.25) + + assert pipe.actor.GetProperty().GetOpacity() == pytest.approx(0.25) + + +def test_set_opacity_does_not_touch_visibility_or_diffuse_color(poly_dataset): + """Opacity lands on the property's opacity field and nothing else.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + pipe.actor.SetVisibility(0) + pipe.actor.GetProperty().SetDiffuseColor(0.25, 0.5, 0.75) + + pipe.set_opacity(0.25) + + assert pipe.actor.GetVisibility() == 0 + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( + (0.25, 0.5, 0.75) + ) + + +# --------------------------------------------------------------------------- +# set_diffuse_color +# --------------------------------------------------------------------------- + +def test_set_diffuse_color_sets_property_diffuse_color(poly_dataset): + """set_diffuse_color writes r, g, b onto the property's diffuse colour.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + + pipe.set_diffuse_color(1.0, 0.0, 0.0) + + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( + (1.0, 0.0, 0.0) + ) + + +def test_set_diffuse_color_does_not_touch_ambient_color_or_opacity(poly_dataset): + """SetDiffuseColor, not SetColor or SetAmbientColor, and opacity is left alone.""" + pipe = VtkNodePipeline.from_dataset(poly_dataset) + pipe.actor.GetProperty().SetAmbientColor(0.25, 0.5, 0.75) + pipe.actor.GetProperty().SetOpacity(0.75) + + pipe.set_diffuse_color(1.0, 0.0, 0.0) + + assert pipe.actor.GetProperty().GetAmbientColor() == pytest.approx( + (0.25, 0.5, 0.75) + ) + assert pipe.actor.GetProperty().GetOpacity() == pytest.approx(0.75) + + +# --------------------------------------------------------------------------- +# set_color_variable +# --------------------------------------------------------------------------- + +def test_set_color_variable_point_uses_point_field_data_and_selects_array( + array_dataset, +): + pipe = VtkNodePipeline.from_dataset(array_dataset) + + pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 0, 0.0, 7.5) + + assert pipe.mapper.GetScalarModeAsString() == "UsePointFieldData" + assert pipe.mapper.GetArrayName() == "pressure" + + +def test_set_color_variable_cell_uses_cell_field_data(array_dataset): + pipe = VtkNodePipeline.from_dataset(array_dataset) + + pipe.set_color_variable(VisorVtkVariableType.CELL, "temperature", 0, 0.0, 7.5) + + assert pipe.mapper.GetScalarModeAsString() == "UseCellFieldData" + assert pipe.mapper.GetArrayName() == "temperature" + + +def test_set_color_variable_sets_the_array_component(array_dataset): + """Component selection lives on the mapper, not on a lookup table.""" + pipe = VtkNodePipeline.from_dataset(array_dataset) + + pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5) + + assert pipe.mapper.GetArrayComponent() == 1 + + +def test_set_color_variable_sets_exact_scalar_range(array_dataset): + """The mapper honours the passed range, not a table's own range. + + If SetUseLookupTableScalarRange(0) were omitted the mapper would defer to + the lookup table and the range read back would not be what was passed. + """ + pipe = VtkNodePipeline.from_dataset(array_dataset) + + pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 0, 0.0, 7.5) + + assert pipe.mapper.GetScalarRange() == pytest.approx((0.0, 7.5)) + assert pipe.mapper.GetUseLookupTableScalarRange() == 0 + + +def test_set_color_variable_enables_scalar_visibility_and_map_scalars(array_dataset): + pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded scalar visibility: OFF, so an enabling call is visible. + pipe.mapper.SetScalarVisibility(False) + + pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 0, 0.0, 7.5) + + assert pipe.mapper.GetScalarVisibility() == 1 + assert pipe.mapper.GetColorModeAsString() == "MapScalars" + + +def test_set_color_variable_unknown_point_array_warns_and_mutates_nothing( + array_dataset, +): + pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded state: scalar visibility OFF and a sentinel array name. If the + # body wrongly proceeded, both would change. + pipe.mapper.SetScalarVisibility(False) + pipe.mapper.SelectColorArray("seeded-sentinel-array") + + with patch("ansys.visor.viewer.vtk.node_pipeline.logger") as mock_logger: + pipe.set_color_variable(VisorVtkVariableType.POINT, "no-such-array", 0, 0.0, 7.5) + + mock_logger.warning.assert_called_once() + assert pipe.mapper.GetScalarVisibility() == 0 + assert pipe.mapper.GetArrayName() == "seeded-sentinel-array" + + +def test_set_color_variable_unknown_cell_array_warns_and_mutates_nothing(array_dataset): + pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded state, as above. "pressure" exists but only as a *point* array, + # so the cell-side lookup must miss. + pipe.mapper.SetScalarVisibility(False) + pipe.mapper.SelectColorArray("seeded-sentinel-array") + + with patch("ansys.visor.viewer.vtk.node_pipeline.logger") as mock_logger: + pipe.set_color_variable(VisorVtkVariableType.CELL, "pressure", 0, 0.0, 7.5) + + mock_logger.warning.assert_called_once() + assert pipe.mapper.GetScalarVisibility() == 0 + assert pipe.mapper.GetArrayName() == "seeded-sentinel-array" + + +def test_set_color_variable_non_enum_association_warns_and_mutates_nothing( + array_dataset, +): + """A bare string is not a VisorVtkVariableType and must not pick a branch.""" + pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded state: scalar visibility OFF and a sentinel array name. + pipe.mapper.SetScalarVisibility(False) + pipe.mapper.SelectColorArray("seeded-sentinel-array") + + with patch("ansys.visor.viewer.vtk.node_pipeline.logger") as mock_logger: + pipe.set_color_variable("POINT", "pressure", 0, 0.0, 7.5) + + mock_logger.warning.assert_called_once() + assert pipe.mapper.GetScalarVisibility() == 0 + assert pipe.mapper.GetArrayName() == "seeded-sentinel-array" + + +# --------------------------------------------------------------------------- +# clear_color_variable +# --------------------------------------------------------------------------- + +def test_clear_color_variable_disables_scalar_visibility(array_dataset): + pipe = VtkNodePipeline.from_dataset(array_dataset) + pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 0, 0.0, 7.5) + assert pipe.mapper.GetScalarVisibility() == 1 + + pipe.clear_color_variable() + + assert pipe.mapper.GetScalarVisibility() == 0 + + +def test_clear_color_variable_leaves_diffuse_color_untouched(array_dataset): + """Clearing does not restore or replace the part's diffuse colour.""" + pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded diffuse colour: distinctive, and not the default mesh colour. + pipe.actor.GetProperty().SetDiffuseColor(0.11, 0.22, 0.33) + + pipe.clear_color_variable() + + assert pipe.actor.GetProperty().GetDiffuseColor() == pytest.approx( + (0.11, 0.22, 0.33) + ) + +