diff --git a/doc/changelog.d/161.added.md b/doc/changelog.d/161.added.md new file mode 100644 index 00000000..d8fee916 --- /dev/null +++ b/doc/changelog.d/161.added.md @@ -0,0 +1 @@ +[Remote rendering 3.5c] Server creates color lookup table and client stops creating its own diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index 7f1f1885..78466dfb 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -140,8 +140,6 @@ class SetPartColorVariablePayload(BaseModel): association: VisorVtkVariableType array_name: str = Field(alias="arrayName") component: int - min_val: float = Field(alias="min") - max_val: float = Field(alias="max") class ClearPartColorVariablePayload(BaseModel): @@ -449,9 +447,8 @@ def set_part_color_variable(self, payload) -> None: name. ``variableId`` is forwarded verbatim and is never parsed by the server. - ``min`` and ``max`` are still required on the wire and appear on the - arrival line, but are not forwarded: the server applies its own - record's effective range for the referenced slot. + The range is not carried: the server applies its own record's + effective range for the referenced slot. """ api = self._mutation_api("set_part_color_variable", payload) if api is None: diff --git a/src/ansys/visor/viewer/models/runtime/requests/visor_save_state_response.py b/src/ansys/visor/viewer/models/runtime/requests/visor_save_state_response.py index ceb289b1..a3929015 100644 --- a/src/ansys/visor/viewer/models/runtime/requests/visor_save_state_response.py +++ b/src/ansys/visor/viewer/models/runtime/requests/visor_save_state_response.py @@ -1,22 +1,16 @@ import json from typing import Any -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator -from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState -logger = VisorDefaultLogger(__name__) - class VisorSaveStateResponse(BaseModel): """ Model for the response from the client to receive the runtime viewer state, which is in turn used by the server to save the persisted state of the Visor viewer. - - The server owns the variable records, so any ``appState.scene.variableStates`` the - browser sends is discarded before validation (see :meth:`_discard_browser_variable_states`). """ model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) @@ -24,41 +18,6 @@ class VisorSaveStateResponse(BaseModel): request_id: int = Field(alias="requestId") app_state: RuntimeAppState = Field(alias="appState") - # Guards a client that still sends variableStates in the pre-record shape. - @model_validator(mode="before") - @classmethod - def _discard_browser_variable_states(cls, data: Any) -> Any: - """Drop ``appState.scene.variableStates`` from the browser's reply. - - The browser's entries lack the record's default fields and are not the - authority; ``get_state`` takes the variables from the server's record. - """ - if not isinstance(data, dict): - return data - key = "appState" if "appState" in data else ("app_state" if "app_state" in data else None) - if key is None: - return data - app_state = data[key] - if isinstance(app_state, str): - try: - app_state = json.loads(app_state) - except (TypeError, ValueError): - return data - if not isinstance(app_state, dict): - return data - scene = app_state.get("scene") - if not isinstance(scene, dict): - return data - discarded = False - for scene_key in ("variableStates", "variable_states"): - if scene_key in scene: - discarded = True - if not discarded: - return data - scene = {k: v for k, v in scene.items() if k not in ("variableStates", "variable_states")} - logger.debug("browser variableStates discarded") - return {**data, key: {**app_state, "scene": scene}} - @field_validator("app_state", mode="before") @classmethod def _coerce_app_state(cls, v: Any) -> Any: diff --git a/src/ansys/visor/viewer/renderer/base.py b/src/ansys/visor/viewer/renderer/base.py index 231bb00d..0b3b19ec 100644 --- a/src/ansys/visor/viewer/renderer/base.py +++ b/src/ansys/visor/viewer/renderer/base.py @@ -162,23 +162,6 @@ def clear_color_variable(self, node_id: int) -> None: write, and the client is not notified. """ - @abstractmethod - def refresh_color_variable_range( - self, - node_id: int, - variable_id: str, - array_type: str, - array_name: str, - component: int, - ) -> None: - """ - Update only the mapper's scalar range for *node_id* from the current - input data, without calling ``pipeline.base_algorithm.Update()``. - - Safe to call after in-place data modifications where VTK's pipeline - MTime is stale. - """ - # ------------------------------------------------------------------------ # Camera # ------------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index 1cf30c9e..ba97e8f5 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -309,16 +309,6 @@ def _serialize_part_state(self, node_id: int) -> None: return self._object_manager.UpdateStateFromObject(mapper_id) - def refresh_color_variable_range( - self, - node_id: int, - variable_id: str, - array_type: str, - array_name: str, - component: int, - ) -> None: - """No-op in Story 1.2. Phase 3 populates.""" - # ------------------------------------------------------------------ # IRenderer: camera # ------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/vtk/node_pipeline.py b/src/ansys/visor/viewer/vtk/node_pipeline.py index d40de33b..4369a067 100644 --- a/src/ansys/visor/viewer/vtk/node_pipeline.py +++ b/src/ansys/visor/viewer/vtk/node_pipeline.py @@ -4,13 +4,14 @@ A self-contained VTK rendering pipeline for a single scene-graph leaf node. -Owns the three VTK objects that turn a raw dataset into something the +Owns the four VTK objects that turn a raw dataset into something the renderer can display: * ``base_algorithm`` -- geometry extraction (``vtkGeometryFilter`` for unstructured grids; ``vtkAppendPolyData`` for poly data). * ``mapper`` -- ``vtkPolyDataMapper`` connected to the algorithm output. * ``actor`` -- ``vtkActor`` connected to the mapper. +* ``lookup_table`` -- ``vtkLookupTable`` set on the mapper. Created when a leaf node is registered with the renderer and destroyed when that node is deregistered. The scene-graph node itself owns only the raw @@ -20,6 +21,7 @@ from dataclasses import dataclass from typing import Callable, Optional +from vtkmodules.vtkCommonCore import vtkLookupTable from vtkmodules.vtkCommonDataModel import vtkDataObject, vtkPlane, vtkPolyData, vtkUnstructuredGrid from vtkmodules.vtkCommonExecutionModel import vtkPolyDataAlgorithm from vtkmodules.vtkFiltersCore import vtkAppendPolyData @@ -32,6 +34,9 @@ logger = VisorDefaultLogger(__name__) +LOOKUP_TABLE_HUE_RANGE: tuple[float, float] = (0.667, 0.0) +"""Blue (low) to red (high).""" + @dataclass class VtkNodePipeline: @@ -45,11 +50,15 @@ class VtkNodePipeline: The ``vtkPolyDataMapper`` driving the actor. base_algorithm: The geometry-extraction algorithm whose output feeds the mapper. + lookup_table: + The part's only table: MAGNITUDE over ``LOOKUP_TABLE_HUE_RANGE``, + built, set on ``mapper``, and never reconfigured. """ actor: vtkActor mapper: vtkPolyDataMapper base_algorithm: vtkPolyDataAlgorithm + lookup_table: vtkLookupTable # ------------------------------------------------------------------ # Factories @@ -57,7 +66,7 @@ class VtkNodePipeline: @classmethod def from_dataset(cls, dataset: vtkDataObject) -> "VtkNodePipeline": - """Build a complete pipeline from ``dataset``. + """Build a complete pipeline from ``dataset``, its mapper holding a built lookup table. Parameters ---------- @@ -69,13 +78,20 @@ def from_dataset(cls, dataset: vtkDataObject) -> "VtkNodePipeline": mapper = vtkPolyDataMapper() mapper.SetInputConnection(base_algorithm.GetOutputPort()) + lookup_table = cls._create_lookup_table() + mapper.SetLookupTable(lookup_table) actor = vtkActor() actor.SetMapper(mapper) actor.GetProperty().SetDiffuseColor(VisorColors.DefaultMeshColor) actor.GetProperty().SetInterpolation(0) # flat shading - return cls(actor=actor, mapper=mapper, base_algorithm=base_algorithm) + return cls( + actor=actor, + mapper=mapper, + base_algorithm=base_algorithm, + lookup_table=lookup_table, + ) # ------------------------------------------------------------------ @@ -204,9 +220,8 @@ def set_color_variable( ) -> 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. + Configures the mapper only. Colours through ``lookup_table``; the + component stays on the mapper (-1 is magnitude). ``association`` is compared by identity against :class:`VisorVtkVariableType`; it is never parsed, upper-cased or @@ -262,6 +277,23 @@ def clear_color_variable(self) -> None: # Private helpers # ------------------------------------------------------------------ + @staticmethod + def _create_lookup_table() -> vtkLookupTable: + """A MAGNITUDE table over LOOKUP_TABLE_HUE_RANGE, built, its Table array marked modified. + + Created before the pipeline is registered, so the first serialize + ships it built. Any later write to this table must configure it, + call ``Build()`` and ``GetTable().Modified()``, then re-serialize the + mapper id, the table id and the ``Table`` array id inside the scene + lock, without notifying. + """ + table = vtkLookupTable() + table.SetHueRange(*LOOKUP_TABLE_HUE_RANGE) + table.SetVectorModeToMagnitude() + table.Build() + table.GetTable().Modified() + return table + @staticmethod def _create_base_algorithm(dataset: vtkDataObject) -> vtkPolyDataAlgorithm: if isinstance(dataset, vtkUnstructuredGrid): diff --git a/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx index e33b6856..bc7b589e 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/WasmRendererPartTriggers.test.tsx @@ -233,9 +233,15 @@ describe('WasmRenderer per-part sends: trigger name and payload', () => { association: 'POINT', arrayName: 'pressure', component: 0, - min: 2, - max: 8, }); + const payload = sender.mock.calls[0][1] as Record; + expect(Object.keys(payload).sort()).toEqual([ + 'arrayName', + 'association', + 'component', + 'nodeId', + 'variableId', + ]); }); test('sendPartColorVariableAsync sends CELL as the association for a cell array', async () => { @@ -257,9 +263,15 @@ describe('WasmRenderer per-part sends: trigger name and payload', () => { association: 'CELL', arrayName: 'temperature', component: -1, - min: -1.5, - max: 4.5, }); + const payload = sender.mock.calls[0][1] as Record; + expect(Object.keys(payload).sort()).toEqual([ + 'arrayName', + 'association', + 'component', + 'nodeId', + 'variableId', + ]); }); test('sendPartColorVariableAsync forwards variableId verbatim without parsing it', async () => { @@ -332,7 +344,7 @@ describe('WasmRenderer per-part applies still mutate their wasm objects', () => expect(property.SetDiffuseColor).toHaveBeenCalledWith(1, 0, 0); }); - test('setColorVariableAsync still configures the mapper and the default table', async () => { + test('setColorVariableAsync configures the mapper and authors no table', async () => { const { renderer, mapper, lut } = await makeRenderer(makeSender()); await renderer.setColorVariableAsync(NODE_ID, { @@ -349,9 +361,10 @@ describe('WasmRenderer per-part applies still mutate their wasm objects', () => expect(mapper.SetColorModeToMapScalars).toHaveBeenCalled(); expect(mapper.ColorByArrayComponent).toHaveBeenCalledWith('pressure', 0); expect(mapper.SetScalarVisibility).toHaveBeenCalledWith(1); - expect(mapper.CreateDefaultLookupTable).toHaveBeenCalled(); - expect(lut.SetHueRange).toHaveBeenCalledWith(0.667, 0.0); - expect(lut.SetVectorModeToMagnitude).toHaveBeenCalled(); + expect(mapper.CreateDefaultLookupTable).not.toHaveBeenCalled(); + expect(mapper.GetLookupTable).not.toHaveBeenCalled(); + expect(lut.SetHueRange).not.toHaveBeenCalled(); + expect(lut.SetVectorModeToMagnitude).not.toHaveBeenCalled(); }); test('clearColorVariableAsync still turns scalar visibility off', async () => { diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index a8d9074c..fa89b42c 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -383,6 +383,7 @@ export class WasmRenderer implements IRenderer { await wasmProperty.SetOpacity(opacity); } + /** Configure the part's mapper to colour by the descriptor's array; it maps through the table served with the mapper. */ async setColorVariableAsync( nodeId: NodeId, descriptor: ColorVariableDescriptor @@ -401,11 +402,6 @@ export class WasmRenderer implements IRenderer { await wasmMapper.SetColorModeToMapScalars(); await wasmMapper.ColorByArrayComponent(descriptor.variableName, descriptor.component); await wasmMapper.SetScalarVisibility(1); - // Force creation of LUT if not already done. (Alternatively, after 9.5.20250802.dev0, you can call mapper.SetLookupTable(null)) - await wasmMapper.CreateDefaultLookupTable(); - const lut = await wasmMapper.GetLookupTable(); - await lut.SetHueRange(0.667, 0.0); - await lut.SetVectorModeToMagnitude(); } async clearColorVariableAsync(nodeId: NodeId): Promise { @@ -527,15 +523,14 @@ export class WasmRenderer implements IRenderer { // `variableId` is the client-built opaque token, forwarded verbatim; // nothing on either side parses it. The association travels as its own // typed 'POINT'|'CELL' field, and the array name as its own field, - // precisely so that no one has to. + // precisely so that no one has to. The range is not sent: the server + // applies its own record's range for the referenced slot. await this.#sendTriggerAsync('set_part_color_variable', nodeId, { nodeId, variableId: descriptor.variableId, association: descriptor.variableType, arrayName: descriptor.variableName, component: descriptor.component, - min: descriptor.min, - max: descriptor.max, }); } diff --git a/tests/unit/app/test_local_app.py b/tests/unit/app/test_local_app.py index a36ddbf0..e67bf0aa 100644 --- a/tests/unit/app/test_local_app.py +++ b/tests/unit/app/test_local_app.py @@ -175,8 +175,7 @@ def test_set_part_selected_carries_no_colour(app, api): def test_set_part_color_variable_delegates_payload_values(app, api): """Every colour-variable field but the range reaches the coordinator in contract order. - ``min`` and ``max`` are still required on the wire but are not forwarded: - the server applies its own record's range. + A payload still carrying ``min`` and ``max`` parses; neither is forwarded. """ app.set_part_color_variable( { @@ -195,6 +194,23 @@ def test_set_part_color_variable_delegates_payload_values(app, api): ) +def test_set_part_color_variable_delegates_without_min_and_max(app, api): + """A payload with no range delegates once, with the five identity arguments.""" + app.set_part_color_variable( + { + "nodeId": 7, + "variableId": "POINT::pressure::1", + "association": "POINT", + "arrayName": "pressure", + "component": 0, + } + ) + + api.set_part_color_variable.assert_called_once_with( + 7, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0 + ) + + def test_set_part_color_variable_parses_point_association_to_the_enum_member(app, api): """'POINT' arrives as the POINT member itself, not as a string.""" app.set_part_color_variable( diff --git a/tests/unit/models/test_visor_save_state_response.py b/tests/unit/models/test_visor_save_state_response.py index 6272ff37..b3742119 100644 --- a/tests/unit/models/test_visor_save_state_response.py +++ b/tests/unit/models/test_visor_save_state_response.py @@ -1,5 +1,5 @@ import json -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest from pydantic import ValidationError @@ -114,33 +114,8 @@ def test_model_dump_contains_fields(monkeypatch): assert "appState" in data -def test_save_path_rejects_a_variable_state_missing_the_identity_fields(): - """ The server owns the variable records, so the browser's ``variableStates`` is - discarded before validation. An entry missing the identity fields (an old - client) therefore parses, and nothing of it reaches the model. - """ - payload = { - "requestId": 1, - "appState": { - "scene": { - "variableStates": { - "POINT::pressure::1": { - "id": "POINT::pressure::1", - "magnitudeRange": [0.0, 1.0], - "ranges": [[0.0, 1.0]], - } - } - } - }, - } - - resp = VisorSaveStateResponse.model_validate(payload) - - assert resp.app_state.scene.variable_states == {} - - def test_save_path_accepts_a_variable_state_carrying_the_identity_fields(): - """ The server owns the variable records, so the browser's ``variableStates`` is discarded before validation.""" + """A browser ``variableStates`` entry in the record's shape reaches the model unaltered.""" payload = { "requestId": 1, "appState": { @@ -151,6 +126,9 @@ def test_save_path_accepts_a_variable_state_carrying_the_identity_fields(): "arrayName": "pressure", "type": "POINT", "numComponents": 1, + "partIds": [1], + "defaultMagnitudeRange": [0.0, 1.0], + "defaultRanges": [[0.0, 1.0]], "magnitudeRange": [0.0, 1.0], "ranges": [[0.0, 1.0]], } @@ -161,35 +139,5 @@ def test_save_path_accepts_a_variable_state_carrying_the_identity_fields(): resp = VisorSaveStateResponse.model_validate(payload) - assert resp.app_state.scene.variable_states == {} - - -def test_save_response_discards_browser_variable_states_from_a_json_string_app_state(): - """#21: the discard also runs when appState arrives as a JSON string, and logs one DEBUG line. - - The entry lacks the record's default fields (partIds, defaultMagnitudeRange, - defaultRanges), so without the discard it would fail validation against the record. - """ - app_state = json.dumps({ - "scene": { - "unit": "mm", - "variableStates": { - "POINT::pressure::1": { - "id": "POINT::pressure::1", - "arrayName": "pressure", - "type": "POINT", - "numComponents": 1, - "magnitudeRange": [0.0, 1.0], - "ranges": [[0.0, 1.0]], - } - }, - } - }) - - with patch("ansys.visor.viewer.models.runtime.requests.visor_save_state_response.logger") as mock_logger: - resp = VisorSaveStateResponse.model_validate({"requestId": 7, "appState": app_state}) - - assert resp.app_state.scene.variable_states == {} - assert resp.app_state.scene.unit == "mm" - mock_logger.debug.assert_called_once_with("browser variableStates discarded") + assert resp.app_state.scene.variable_states["POINT::pressure::1"].array_name == "pressure" diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index 2b1cf77e..e6e0afeb 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -434,24 +434,6 @@ def test_deregister_all_empties_registry_and_detaches_all_actors(self, renderer) assert renderer._vtk_renderer.RemoveActor.call_count == 2 -# =========================================================================== -# 3. Per-part visual mutations -# =========================================================================== - -class TestPerPartMutations: - """The permanently un-implemented method accepts its arguments. - - It stays a no-op beyond this story: the colour-variable range is not - held per part. - """ - - def test_refresh_color_variable_range(self, renderer): - assert ( - renderer.refresh_color_variable_range(1, "sp-1", "CELL", "temp", 0) - is None - ) - - # =========================================================================== # 3a-bis. Scene-wide widget state: set_edges_visible # diff --git a/tests/unit/vtk/test_node_pipeline.py b/tests/unit/vtk/test_node_pipeline.py index 546f3590..83a0f52b 100644 --- a/tests/unit/vtk/test_node_pipeline.py +++ b/tests/unit/vtk/test_node_pipeline.py @@ -1,5 +1,6 @@ """Unit tests for VtkNodePipeline.""" +import json from unittest.mock import MagicMock, patch import pytest @@ -9,6 +10,7 @@ from vtkmodules.vtkFiltersGeometry import vtkGeometryFilter from vtkmodules.vtkFiltersSources import vtkSphereSource from vtkmodules.vtkRenderingCore import vtkActor, vtkPolyDataMapper +from vtkmodules.vtkSerializationManager import vtkObjectManager from ansys.visor.viewer.core.visor_colors import VisorColors from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType @@ -60,6 +62,24 @@ def array_dataset() -> vtkPolyData: return dataset +@pytest.fixture +def vector_dataset() -> vtkPolyData: + """Sphere output carrying a 3-component point array, every tuple (0, 10, 3).""" + src = vtkSphereSource() + src.Update() + dataset = src.GetOutput() + + velocity = vtkFloatArray() + velocity.SetName("velocity") + velocity.SetNumberOfComponents(3) + velocity.SetNumberOfTuples(dataset.GetNumberOfPoints()) + for i in range(dataset.GetNumberOfPoints()): + velocity.SetTuple3(i, 0.0, 10.0, 3.0) + dataset.GetPointData().AddArray(velocity) + + return dataset + + # --------------------------------------------------------------------------- # from_dataset # --------------------------------------------------------------------------- @@ -97,13 +117,37 @@ def test_from_dataset_unsupported_type_raises(): VtkNodePipeline.from_dataset(object()) +def test_from_dataset_serves_a_built_lookup_table(poly_dataset): + """The first serialize ships the mapper's table, built and in magnitude mode. + + A table that was never built serves 0 tuples; a built one serves 256. + VectorMode 0 is MAGNITUDE. + """ + pipe = VtkNodePipeline.from_dataset(poly_dataset) + manager = vtkObjectManager() + manager.Initialize() + manager.RegisterObject(pipe.actor) + + manager.UpdateStatesFromObjects() + + mapper_state = json.loads(manager.GetState(manager.GetId(pipe.mapper))) + table_id = manager.GetId(pipe.lookup_table) + table_state = json.loads(manager.GetState(table_id)) + array_state = json.loads(manager.GetState(table_state["Table"]["Id"])) + assert mapper_state["LookupTable"]["Id"] == table_id + assert array_state["NumberOfTuples"] == 256 + assert table_state["VectorMode"] == 0 + + # --------------------------------------------------------------------------- # set_clipping_plane # --------------------------------------------------------------------------- def test_set_clipping_plane_none_clears_planes(): mapper = MagicMock() - pipe = VtkNodePipeline(actor=MagicMock(), mapper=mapper, base_algorithm=MagicMock()) + pipe = VtkNodePipeline( + actor=MagicMock(), mapper=mapper, base_algorithm=MagicMock(), lookup_table=MagicMock() + ) pipe.set_clipping_plane(None) @@ -113,7 +157,9 @@ def test_set_clipping_plane_none_clears_planes(): def test_set_clipping_plane_with_plane_adds_it(): mapper = MagicMock() - pipe = VtkNodePipeline(actor=MagicMock(), mapper=mapper, base_algorithm=MagicMock()) + pipe = VtkNodePipeline( + actor=MagicMock(), mapper=mapper, base_algorithm=MagicMock(), lookup_table=MagicMock() + ) plane = vtkPlane() pipe.set_clipping_plane(plane) @@ -386,6 +432,8 @@ def test_set_color_variable_sets_exact_scalar_range(array_dataset): the lookup table and the range read back would not be what was passed. """ pipe = VtkNodePipeline.from_dataset(array_dataset) + # Seeded: deferring to the table's range, so the call has to turn it off. + pipe.mapper.SetUseLookupTableScalarRange(1) pipe.set_color_variable(VisorVtkVariableType.POINT, "pressure", 0, 0.0, 7.5) @@ -393,6 +441,27 @@ def test_set_color_variable_sets_exact_scalar_range(array_dataset): assert pipe.mapper.GetUseLookupTableScalarRange() == 0 +def test_mapped_colors_follow_magnitude_for_minus_one_and_the_mapper_component_otherwise( + vector_dataset, +): + """Every tuple is (0, 10, 3) over the range (0, 20). + + For -1 the table maps the magnitude (about 10.44); for 2 the mapper's + component selects 3. The expected RGBA literals are the hue range + (0.667, 0.0) read at those two values. + """ + magnitude = VtkNodePipeline.from_dataset(vector_dataset) + magnitude.set_color_variable(VisorVtkVariableType.POINT, "velocity", -1, 0.0, 20.0) + magnitude.mapper.Update() + + component = VtkNodePipeline.from_dataset(vector_dataset) + component.set_color_variable(VisorVtkVariableType.POINT, "velocity", 2, 0.0, 20.0) + component.mapper.Update() + + assert magnitude.mapper.MapScalars(1.0).GetTuple4(0) == (4, 255, 0, 255) + assert component.mapper.MapScalars(1.0).GetTuple4(0) == (0, 164, 255, 255) + + 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.