From c5c56e00105d0dc03e37cb1cd276c5a7567ba4cf Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Mon, 28 Sep 2026 21:08:14 -0700 Subject: [PATCH 01/12] feat: server-owned variable records --- .../models/common/visor_variable_record.py | 69 +++ .../models/common/visor_variable_state.py | 6 +- .../models/persist/persisted_viewer_state.py | 8 +- .../requests/visor_save_state_response.py | 42 +- .../models/runtime/scene/runtime_app_state.py | 7 +- .../runtime/scene/runtime_scene_state.py | 5 +- .../models/runtime/visor_scene_details.py | 4 + src/ansys/visor/viewer/vtk/scene/base.py | 60 ++- .../viewer/vtk/scene/visor_state_mapper.py | 12 +- .../vtk/variables/visor_variable_aggregate.py | 153 +++++++ tests/integration/test_save_load_state.py | 62 +++ tests/integration/test_visor_file_io.py | 2 +- tests/unit/app/test_visor_vtk_local.py | 4 +- tests/unit/models/test_runtime_scene_state.py | 32 +- .../models/test_visor_save_state_response.py | 58 ++- .../unit/models/test_visor_variable_record.py | 87 ++++ tests/unit/vtk/io/test_visor_file_io.py | 2 +- tests/unit/vtk/scene/test_base.py | 418 ++++++++++++++++-- .../unit/vtk/scene/test_visor_state_mapper.py | 57 ++- tests/unit/vtk/test_wire_format_identity.py | 96 ++++ .../test_visor_variable_aggregate.py | 106 +++++ .../test_visor_variable_empty_block.py | 72 +++ 22 files changed, 1264 insertions(+), 98 deletions(-) create mode 100644 src/ansys/visor/viewer/models/common/visor_variable_record.py create mode 100644 src/ansys/visor/viewer/vtk/variables/visor_variable_aggregate.py create mode 100644 tests/unit/models/test_visor_variable_record.py create mode 100644 tests/unit/vtk/variables/test_visor_variable_aggregate.py create mode 100644 tests/unit/vtk/variables/test_visor_variable_empty_block.py diff --git a/src/ansys/visor/viewer/models/common/visor_variable_record.py b/src/ansys/visor/viewer/models/common/visor_variable_record.py new file mode 100644 index 00000000..ca188fdc --- /dev/null +++ b/src/ansys/visor/viewer/models/common/visor_variable_record.py @@ -0,0 +1,69 @@ +"""Server-owned record for one color variable across every dataset in the scene.""" + +from typing import Dict, List, Tuple + +from pydantic import BaseModel, ConfigDict, Field, field_serializer + +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState +from ansys.visor.viewer.models.persist.scene.persisted_scene_state import _IDENTIFIER_SEPARATOR + + +def compose_variable_identifier(type: VisorVtkVariableType, name: str, num_components: int) -> str: + """Compose the stable variable identifier ``::::``. + + The inverse of :func:`derive_variable_fields_from_identifier`, and the same + composition the client uses, so identifiers in existing files key identically. + """ + return f"{type.value}{_IDENTIFIER_SEPARATOR}{name}{_IDENTIFIER_SEPARATOR}{num_components}" + + +class VisorVariableRecord(BaseModel): + """ + The server's record of one variable, keyed by its composed identifier. + + Every field is required: a record never carries an unset range. ``magnitude_range`` + and ``ranges`` are the custom (effective) ranges; ``default_magnitude_range`` and + ``default_ranges`` are the ranges widened across every participating part. + ``part_ids`` lists the parts that carry the variable. + + The identity and range fields share their attribute names and aliases with + :class:`VisorVariableState`, which is the persisted projection (:meth:`to_variable_state`). + """ + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(...) + array_name: str = Field(..., alias="arrayName") + type: VisorVtkVariableType = Field(...) + num_components: int = Field(..., alias="numComponents") + part_ids: List[int] = Field(..., alias="partIds") + default_magnitude_range: Tuple[float, float] = Field(..., alias="defaultMagnitudeRange") + default_ranges: List[Tuple[float, float]] = Field(..., alias="defaultRanges") + magnitude_range: Tuple[float, float] = Field(..., alias="magnitudeRange") + ranges: List[Tuple[float, float]] = Field(...) + + @field_serializer("type") + def _serialize_type(self, value: VisorVtkVariableType) -> str: + """Emit the wire value (e.g. "POINT"/"CELL") for both dict-mode and JSON-mode dumps.""" + return value.value + + def to_variable_state(self) -> VisorVariableState: + """Project the record onto the persisted entry: identity plus the effective ranges.""" + return VisorVariableState( + id=self.id, + array_name=self.array_name, + type=self.type, + num_components=self.num_components, + magnitude_range=self.magnitude_range, + ranges=list(self.ranges), + ) + + +class VisorVariableRecords(BaseModel): + """Holder for the scene's variable records, keyed by identifier. + + Created once by the scene and never rebound. Writers assign a new dict to + ``variables`` (copy-on-write); readers are handed ``model_copy(deep=True)``. + """ + variables: Dict[str, VisorVariableRecord] = Field(default_factory=dict) + diff --git a/src/ansys/visor/viewer/models/common/visor_variable_state.py b/src/ansys/visor/viewer/models/common/visor_variable_state.py index 335a23b2..489d4775 100644 --- a/src/ansys/visor/viewer/models/common/visor_variable_state.py +++ b/src/ansys/visor/viewer/models/common/visor_variable_state.py @@ -21,8 +21,10 @@ class VisorVariableState(BaseModel): explicitly (rather than only opaquely inside ``id``) so a consumer can act on them without parsing the client-built identifier. - In the future, the backend can own this, but for now we can treat this as passthrough data, - as the id value is stable across sessions. + This is the persisted entry: the identity plus the effective ranges at save time. The backend owns + the variables as :class:`VisorVariableRecord` (``visor_variable_record.py``), and this model is that + record's persisted projection (``VisorVariableRecord.to_variable_state``). The id value is stable + across sessions. """ model_config = ConfigDict(populate_by_name=True) diff --git a/src/ansys/visor/viewer/models/persist/persisted_viewer_state.py b/src/ansys/visor/viewer/models/persist/persisted_viewer_state.py index 16161113..9d77413e 100644 --- a/src/ansys/visor/viewer/models/persist/persisted_viewer_state.py +++ b/src/ansys/visor/viewer/models/persist/persisted_viewer_state.py @@ -41,7 +41,8 @@ def from_components(cls, datasets: Dict[str, PersistedDatasetState], camera: VisorCameraState | None = None, cross_section: VisorCrossSectionState | None = None, - variable_states: Dict[str, "VisorVariableState"] | None = None, + *, + variable_states: Dict[str, "VisorVariableState"], ) -> "PersistedViewerStateV1": """ Create a PersistedViewerStateV1 instance from UI settings and dataset states. @@ -51,7 +52,8 @@ def from_components(cls, unit (str | None): Scene unit (or None). datasets (Dict[str, PersistedDatasetState]): Dataset states keyed by dataset name. camera (VisorCameraState | None): Camera state (or None). - variable_states (Dict[str, VisorVariableState] | None): Variable states keyed by variable identifier (or None). + variable_states (Dict[str, VisorVariableState]): Variable states keyed by variable identifier. + Keyword-only and required; pass ``{}`` for none. Returns: PersistedViewerStateV1: The constructed viewer state. """ @@ -64,6 +66,6 @@ def from_components(cls, edges_enabled=edges_enabled, bounding_box_enabled=bounding_box_enabled, dataset_states=datasets, - variable_states=variable_states or {}, + variable_states=variable_states, ) return cls(ui=ui_state, scene=scene_state) 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 a3929015..79f05614 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,16 +1,22 @@ import json from typing import Any -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_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) @@ -18,6 +24,40 @@ class VisorSaveStateResponse(BaseModel): request_id: int = Field(alias="requestId") app_state: RuntimeAppState = Field(alias="appState") + @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/models/runtime/scene/runtime_app_state.py b/src/ansys/visor/viewer/models/runtime/scene/runtime_app_state.py index d1df1823..af5f88b3 100644 --- a/src/ansys/visor/viewer/models/runtime/scene/runtime_app_state.py +++ b/src/ansys/visor/viewer/models/runtime/scene/runtime_app_state.py @@ -7,7 +7,7 @@ from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState -from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState +from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecord from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimeDatasetState from ansys.visor.viewer.models.runtime.scene.runtime_scene_state import RuntimeSceneState @@ -39,7 +39,8 @@ def from_components( bounding_box_enabled: bool | None = None, cross_section: VisorCrossSectionState | None = None, camera: VisorCameraState | None = None, - variable_states: Dict[str, VisorVariableState] | None = None, + *, + variable_states: Dict[str, VisorVariableRecord], ) -> "RuntimeAppState": """Construct a RuntimeAppState from the given components. @@ -56,7 +57,7 @@ def from_components( edges_enabled=edges_enabled, bounding_box_enabled=bounding_box_enabled, dataset_states=dataset_states, - variable_states=variable_states or {}, + variable_states=variable_states, ) return cls( ui=ui, diff --git a/src/ansys/visor/viewer/models/runtime/scene/runtime_scene_state.py b/src/ansys/visor/viewer/models/runtime/scene/runtime_scene_state.py index cefaabcc..32188726 100644 --- a/src/ansys/visor/viewer/models/runtime/scene/runtime_scene_state.py +++ b/src/ansys/visor/viewer/models/runtime/scene/runtime_scene_state.py @@ -6,7 +6,7 @@ from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState -from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState +from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecord from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimeDatasetState @@ -28,7 +28,8 @@ class RuntimeSceneState(BaseModel): edges_enabled: bool | None = Field(default=None, alias="edgesEnabled") bounding_box_enabled: bool | None = Field(default=None, alias="boundingBoxEnabled") dataset_states: Dict[int, "RuntimeDatasetState"] = Field(default_factory=dict, alias="datasetStates") - variable_states: Dict[str, "VisorVariableState"] = Field(default_factory=dict, alias="variableStates") + # The server's variable records (see VisorSceneBase._variable_records); never taken from the browser. + variable_states: Dict[str, "VisorVariableRecord"] = Field(default_factory=dict, alias="variableStates") @field_validator("dataset_states", mode="before") @classmethod diff --git a/src/ansys/visor/viewer/models/runtime/visor_scene_details.py b/src/ansys/visor/viewer/models/runtime/visor_scene_details.py index 3a34acc8..9fab9687 100644 --- a/src/ansys/visor/viewer/models/runtime/visor_scene_details.py +++ b/src/ansys/visor/viewer/models/runtime/visor_scene_details.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState +from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecord from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimeDatasetState from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import RendererAnnotation @@ -36,6 +37,8 @@ def from_components( cross_section_enabled: bool | None = None, edges_enabled: bool | None = None, bounding_box_enabled: bool | None = None, + *, + variable_states: Dict[str, VisorVariableRecord], ) -> "VisorSceneDetails": """Construct an instance from components. @@ -55,6 +58,7 @@ def from_components( cross_section_enabled=cross_section_enabled, edges_enabled=edges_enabled, bounding_box_enabled=bounding_box_enabled, + variable_states=variable_states, ) return cls( app_state=app_state, diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 2651240a..0853d347 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -3,7 +3,7 @@ import json import threading from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Dict, List from trame_server import Server @@ -15,6 +15,8 @@ from ansys.visor.viewer.core.visor_types import VisorDatasetType from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState +from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecords +from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 from ansys.visor.viewer.models.runtime.visor_scene_details import VisorSceneDetails from ansys.visor.viewer.renderer.base import IRenderer @@ -24,6 +26,10 @@ from ansys.visor.viewer.vtk.scene.visor_state_mapper import VisorStateMapper from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraph from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables +from ansys.visor.viewer.vtk.variables.visor_variable_aggregate import ( + build_variable_records, + overlay_persisted_ranges, +) from ansys.visor.viewer.vtk.variables.visor_variable_update import VisorVariableUpdate if TYPE_CHECKING: @@ -69,6 +75,7 @@ class VisorSceneBase(ABC): _edges_enabled: bool _bounding_box_enabled: bool _ui_state: VisorUIState + _variable_records: VisorVariableRecords def __init__( self, @@ -102,6 +109,13 @@ def __init__( panel_top_right_tab_index=0, ) + # The server's variable records, keyed by composed identifier. Created once + # here and never rebound. Written only by _rebuild_variable_records, under + # _vtk_lock, by assigning a new dict (copy-on-write) so the unlocked reader in + # get_scene_details never sees a dict change size. Handed out only as + # model_copy(deep=True): a shallow copy would share the dict and its entries. + self._variable_records = VisorVariableRecords() + # Server-tracked widget toggles. Absolute values, never toggles. # Initialised to the client widgets' own constructor defaults so a # get_state before the client has ever spoken reports what the client @@ -261,6 +275,9 @@ async def get_state(self, timeout: float) -> PersistedViewerStateV1: runtime_state.scene.edges_enabled = self._edges_enabled runtime_state.scene.bounding_box_enabled = self._bounding_box_enabled runtime_state.ui = self._ui_state.model_copy() + # Variables and unit are the server's, never the browser's reply. + runtime_state.scene.variable_states = self._variable_records.model_copy(deep=True).variables + runtime_state.scene.unit = self._dataset_registry.unit runtime_state.scene.dataset_states = registry_dataset_states persisted = self._state_mapper.runtime_to_persisted(runtime_state) @@ -283,12 +300,17 @@ def apply_state(self, state: PersistedViewerStateV1): # Transform the frontend PersistedViewerStateV1 -> RuntimeAppState runtime_app_state = self._state_mapper.persisted_to_runtime(state) + # Variable records: rebuilt from the registry and overlaid with the file's + # ranges, then placed on the runtime state (CC-1), all before + # _restore_part_states, which reads each colored part's range from that dict. + self._rebuild_variable_records("load", persisted=state.scene.variable_states) + runtime_app_state.scene.variable_states = self._variable_records.model_copy(deep=True).variables + # One call per state class: updates the server's stored state and its VTK objects. self._restore_part_states(runtime_app_state) self._restore_widget_state(runtime_app_state) self._restore_ui_state(runtime_app_state) self._restore_camera_state(runtime_app_state) - # TODO: restore variable states when they are synced back to the server. # Finalize here, not on the load path. On a cold load -- viewer started with no dataset, # then a state loaded -- load_state adds the datasets and only then calls apply_state, so a @@ -337,6 +359,10 @@ def get_scene_details(self) -> VisorSceneDetails: The UI record is passed as a copy and never as the instance, for the reason :meth:`get_state` gives: a panel trigger landing after this call would otherwise mutate a payload already served. + + The variable records are passed as a deep copy of the holder, also + without the lock: writers rebind ``variables`` to a new dict, so this + read sees either the old dict or the new one, never one in flux. """ if self._scene_graph is None: self._initialize_scene_graph() @@ -355,6 +381,7 @@ def get_scene_details(self) -> VisorSceneDetails: cross_section_enabled=self._cross_section_enabled, edges_enabled=self._edges_enabled, bounding_box_enabled=self._bounding_box_enabled, + variable_states=self._variable_records.model_copy(deep=True).variables, ) def get_scene_details_json(self) -> str: @@ -375,6 +402,7 @@ def clear(self): self._renderer.deregister_all() self._dataset_registry.clear() self._scene_graph = None + self._rebuild_variable_records("clear") def populate_scene(self): """Update widgets to reflect the current scene contents. @@ -460,6 +488,7 @@ def add_dataset(self, input: VisorDatasetType, metadata: ExtendedMetadata) -> in node_ids = [leaf.id for leaf in part_nodes] self._dataset_registry.add(dataset_id, dataset_name, input, node_ids, metadata) + self._rebuild_variable_records("add") return dataset_id @@ -491,6 +520,7 @@ def remove_dataset(self, dataset_id: int): # Unregister the dataset self._dataset_registry.remove(dataset_id) + self._rebuild_variable_records("remove") def list_variables_for_dataset(self, dataset_id: int) -> List[VisorPartVariables]: return self._dataset_registry.list_variables(dataset_id) @@ -515,11 +545,37 @@ def update_variables_for_dataset(self, dataset_id: int, variables: List[VisorVar with timer.phase("update_descendant_parts"): dataset_node.refresh_descendant_variable_metadata(include_self=True) + # After the reload and the metadata refresh: a width change changes the id. + self._rebuild_variable_records("update_variables") + with timer.phase("render"): self.render() timer.log() + def _rebuild_variable_records( + self, + point: str, + persisted: Dict[str, VisorVariableState] | None = None, + ) -> None: + """Rebuild the variable records from the registry. + + ``persisted`` is ``None`` at add, remove, clear and update: the previous + records are carried by the custom-range rule. On load it is the file's + ``variable_states``: the records are built fresh and the file's ranges + overlaid. + + Under ``_vtk_lock``. Assigns a new dict to the holder (copy-on-write); + never mutates the live dict or its entries. + """ + with self._vtk_lock: + if persisted is None: + records = build_variable_records(self._dataset_registry, self._variable_records.variables) + else: + records = overlay_persisted_ranges(build_variable_records(self._dataset_registry, {}), persisted) + self._variable_records.variables = records + logger.debug("variable records rebuilt at %s: %d records", point, len(records)) + def render(self): """Delegate to the renderer backend. diff --git a/src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py b/src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py index cc28ecc6..9a322beb 100644 --- a/src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py +++ b/src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py @@ -45,8 +45,11 @@ def runtime_to_persisted(self, runtime_app_state: RuntimeAppState) -> PersistedV unit = scene_state.unit # Scene - camera camera = scene_state.camera - # Scene - variables: Pass through as-is since they are already keyed by stable variable identifier - variable_states = scene_state.variable_states + # Scene - variables: the server's records, projected onto the persisted entry + variable_states = { + variable_id: record.to_variable_state() + for variable_id, record in scene_state.variable_states.items() + } # Scene - datasets runtime_dataset_states = scene_state.dataset_states persisted_dataset_states = {} @@ -93,8 +96,9 @@ def persisted_to_runtime(self, state: PersistedViewerStateV1) -> RuntimeAppState # scene - camera camera = scene_state.camera - # scene - variables: Pass through as-is since they are already keyed by stable variable identifier - variable_states = scene_state.variable_states or {} + # scene - variables: the records are built from the datasets, not from the file. + # VisorSceneBase.apply_state overlays the file's ranges onto the rebuilt records. + variable_states = {} # scene - datasets: Convert persisted dataset states to runtime runtime_dataset_states = {} diff --git a/src/ansys/visor/viewer/vtk/variables/visor_variable_aggregate.py b/src/ansys/visor/viewer/vtk/variables/visor_variable_aggregate.py new file mode 100644 index 00000000..397e49e6 --- /dev/null +++ b/src/ansys/visor/viewer/vtk/variables/visor_variable_aggregate.py @@ -0,0 +1,153 @@ +"""Pure aggregation of the scene's variable records from the dataset registry.""" + +from typing import TYPE_CHECKING, Dict, List, Tuple + +from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger +from ansys.visor.viewer.models.common.visor_variable_record import ( + VisorVariableRecord, + compose_variable_identifier, +) +from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState + +if TYPE_CHECKING: + from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry + +logger = VisorDefaultLogger(__name__) + +Range = Tuple[float, float] + + +def _widen(current: Range | None, other: Range | None) -> Range | None: + """Return the union of two ranges; ``None`` contributes nothing.""" + if other is None: + return current + other = (float(other[0]), float(other[1])) + if current is None: + return other + return (min(current[0], other[0]), max(current[1], other[1])) + + +def _follow_or_keep(previous_custom: Range, previous_default: Range, new_default: Range) -> Range: + """A custom slot equal to its previous default follows the new default; otherwise it is kept.""" + if tuple(previous_custom) == tuple(previous_default): + return new_default + return tuple(previous_custom) + + +def build_variable_records( + registry: "VisorDatasetRegistry", + previous: Dict[str, VisorVariableRecord], +) -> Dict[str, VisorVariableRecord]: + """Build the records from every dataset's variables, carrying custom ranges from ``previous``. + + 1. Union: each part carrying a variable joins that identifier's ``part_ids``. + 2. Widen: default ranges are the min of the mins and the max of the maxes. + 3. Custom: an existing id keeps an edited custom slot; an unedited slot (equal to the + previous default) follows the new default. A new id starts with custom equal to default. + + Returns a new dict of new records; ``previous`` is never mutated. + """ + parts: Dict[str, List[int]] = {} + identity: Dict[str, tuple] = {} + magnitude: Dict[str, Range | None] = {} + components: Dict[str, List[Range | None]] = {} + + for dataset in list(registry.datasets.values()): + for part_variables in dataset.list_variables(): + for variable in part_variables.variables: + n = int(variable.num_components) + variable_id = compose_variable_identifier(variable.type, variable.name, n) + if variable_id not in identity: + identity[variable_id] = (variable.name, variable.type, n) + parts[variable_id] = [] + magnitude[variable_id] = None + components[variable_id] = [None] * n + if part_variables.part_id not in parts[variable_id]: + parts[variable_id].append(part_variables.part_id) + + variable_magnitude = variable.magnitude_range + if variable_magnitude is None and n == 1 and variable.ranges: + variable_magnitude = variable.ranges[0] + magnitude[variable_id] = _widen(magnitude[variable_id], variable_magnitude) + + slots = components[variable_id] + for k in range(min(n, len(variable.ranges))): + slots[k] = _widen(slots[k], variable.ranges[k]) + + records: Dict[str, VisorVariableRecord] = {} + for variable_id, (name, variable_type, n) in identity.items(): + default_magnitude = magnitude[variable_id] or (0.0, 0.0) + default_ranges = [slot if slot is not None else (0.0, 0.0) for slot in components[variable_id]] + + custom_magnitude = default_magnitude + custom_ranges = list(default_ranges) + old = previous.get(variable_id) + if old is not None: + custom_magnitude = _follow_or_keep(old.magnitude_range, old.default_magnitude_range, default_magnitude) + custom_ranges = [ + _follow_or_keep(old.ranges[k], old.default_ranges[k], default_ranges[k]) + if k < len(old.ranges) and k < len(old.default_ranges) else default_ranges[k] + for k in range(n) + ] + + records[variable_id] = VisorVariableRecord( + id=variable_id, + array_name=name, + type=variable_type, + num_components=n, + part_ids=sorted(parts[variable_id]), + default_magnitude_range=default_magnitude, + default_ranges=default_ranges, + magnitude_range=custom_magnitude, + ranges=custom_ranges, + ) + return records + + +def overlay_persisted_ranges( + records: Dict[str, VisorVariableRecord], + persisted: Dict[str, VisorVariableState], +) -> Dict[str, VisorVariableRecord]: + """Overlay the file's custom ranges onto freshly built records. + + - A null or absent ``magnitudeRange`` falls back to the default (DEBUG). + - ``ranges`` whose length differs from ``num_components`` fall back to the default (DEBUG). + - A file id with no record is dropped (WARNING). + + Returns a new dict; ``records`` is never mutated. + """ + result = dict(records) + for variable_id, entry in (persisted or {}).items(): + record = result.get(variable_id) + if record is None: + logger.warning("persisted variable %s has no record in the scene; dropped", variable_id) + continue + + magnitude = entry.magnitude_range + if magnitude is None: + logger.debug("persisted variable %s has no magnitudeRange; default used", variable_id) + magnitude = record.default_magnitude_range + + ranges = entry.ranges + if ranges is None or len(ranges) != record.num_components: + logger.debug( + "persisted variable %s has %s ranges for %d components; default used", + variable_id, None if ranges is None else len(ranges), record.num_components, + ) + ranges = record.default_ranges + + result[variable_id] = record.model_copy( + update={"magnitude_range": tuple(magnitude), "ranges": [tuple(r) for r in ranges]}, + deep=True, + ) + return result + + +def resolve_record_range(record: VisorVariableRecord, component: int) -> Range | None: + """Return the effective range for a component slot: ``-1`` is magnitude, ``0..n-1`` a component.""" + if component == -1: + return record.magnitude_range + if 0 <= component < record.num_components and component < len(record.ranges): + return record.ranges[component] + return None + diff --git a/tests/integration/test_save_load_state.py b/tests/integration/test_save_load_state.py index 157d2214..cde56c51 100644 --- a/tests/integration/test_save_load_state.py +++ b/tests/integration/test_save_load_state.py @@ -43,9 +43,11 @@ from ansys.visor.viewer.app.visor_vtk import VisorVTK from ansys.visor.viewer.core.metadata import ExtendedMetadata +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.models.common.part_properties import PartProperties from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState +from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState from ansys.visor.viewer.models.persist.dataset.persisted_dataset_state import PersistedDatasetState from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( @@ -276,6 +278,7 @@ def _make_state(self, dataset_states: dict, unit: str = "m") -> PersistedViewerS edges_enabled=None, bounding_box_enabled=None, datasets=dataset_states, + variable_states={}, ) def test_single_dataset_is_restored_into_empty_scene(self, file_io, iface, tmp_path): @@ -458,6 +461,7 @@ def test_saved_visor_json_carries_registry_part_state_keyed_by_name(self, iface, }, ) }, + variable_states={}, ) async def _frontend_round_trip(timeout: float = 5.0): @@ -507,6 +511,7 @@ def test_reloading_the_saved_state_restores_the_registry(self, file_io, iface, t }, ) }, + variable_states={}, ) file_io.write_state(str(tmp_path), state) @@ -529,6 +534,62 @@ def test_reloading_the_saved_state_restores_the_registry(self, file_io, iface, t assert record.variable_id == "POINT::pressure::1" assert record.variable_component == 0 + def test_reloading_a_colored_part_restores_its_file_range(self, file_io, iface, tmp_path): + """I-1: a colored part loads at the range the file stores, through the server's record. + + plate.vtp carries a 2-component point array "UV" whose component-0 data + range is (-110.0, 110.0). The file stores a custom range that differs + from it in both ends, so a pass proves the file's range reached the + mapper through the rebuilt, overlaid record (CC-1) and not the default. + """ + original = file_to_dataset(_vtp_path()) + snap = file_io.get_persisted_dataset_path(str(tmp_path), "plate") + file_io.write_dataset(snap, original) + + state = PersistedViewerStateV1.from_components( + ui_state=VisorUIState(), + unit="m", + orthographic_enabled=None, + cross_section_enabled=None, + edges_enabled=None, + bounding_box_enabled=None, + datasets={ + "plate": PersistedDatasetState( + serialized_dataset_path=str(snap), + parts={ + "plate": PartProperties(color_by="POINT::UV::2", color_by_component=0) + }, + ) + }, + variable_states={ + "POINT::UV::2": VisorVariableState( + id="POINT::UV::2", + array_name="UV", + type=VisorVtkVariableType.POINT, + num_components=2, + magnitude_range=(1.0, 2.0), + ranges=[(-50.0, 60.0), (-7.0, 8.0)], + ) + }, + ) + file_io.write_state(str(tmp_path), state) + + iface._scene._push_runtime_state = MagicMock() + iface._scene._renderer.flush_wasm_state = MagicMock() + + iface.load_state(str(tmp_path)) + + dataset = next(iter(iface._scene.datasets.values())) + part_id = dataset.part_index.part_ids[0] + mapper = iface._scene._renderer._pipelines[part_id].mapper + assert mapper.GetArrayName() == "UV" + assert mapper.GetScalarRange() == pytest.approx((-50.0, 60.0)) + + held = iface._scene._variable_records.variables["POINT::UV::2"] + assert held.ranges == [(-50.0, 60.0), (-7.0, 8.0)] + assert held.magnitude_range == (1.0, 2.0) + assert held.default_ranges[0] == (-110.0, 110.0) + def test_saved_visor_json_carries_the_camera_record_not_the_browsers(self, iface, tmp_path): """save_state writes the server's camera record, not the browser's reply. @@ -551,6 +612,7 @@ def test_saved_visor_json_carries_the_camera_record_not_the_browsers(self, iface unit="m", dataset_states={}, camera=_reply_camera(), + variable_states={}, ) async def _frontend_round_trip(timeout: float = 5.0): diff --git a/tests/integration/test_visor_file_io.py b/tests/integration/test_visor_file_io.py index f315627e..62268ca9 100644 --- a/tests/integration/test_visor_file_io.py +++ b/tests/integration/test_visor_file_io.py @@ -347,7 +347,7 @@ def test_vtkhdf_multiblock_snapshot_can_be_deleted_after_load(self, file_io, tmp state = PersistedViewerStateV1.from_components( ui_state=VisorUIState(), unit="m", orthographic_enabled=None, cross_section_enabled=None, edges_enabled=None, bounding_box_enabled=None, - datasets={} + datasets={}, variable_states={} ) deleted = file_io.delete_stale_snapshots(str(tmp_path), state) diff --git a/tests/unit/app/test_visor_vtk_local.py b/tests/unit/app/test_visor_vtk_local.py index 4ac73634..5e39298c 100644 --- a/tests/unit/app/test_visor_vtk_local.py +++ b/tests/unit/app/test_visor_vtk_local.py @@ -407,7 +407,7 @@ def _make_state_with_datasets(snapshot_path: str | None, name="model", unit="m") return PersistedViewerStateV1.from_components( ui_state=VisorUIState(), unit=unit, orthographic_enabled=None, cross_section_enabled=None, edges_enabled=None, bounding_box_enabled=None, - datasets={name: ds_state} + datasets={name: ds_state}, variable_states={} ) @@ -519,7 +519,7 @@ def test_load_state_passes_correct_metadata_to_add_dataset(tmp_path, iface): state = PersistedViewerStateV1.from_components( ui_state=VisorUIState(), unit="mm", orthographic_enabled=None, cross_section_enabled=None, edges_enabled=None, bounding_box_enabled=None, - datasets={"mesh": ds_state} + datasets={"mesh": ds_state}, variable_states={} ) built_meta = MagicMock(spec=ExtendedMetadata) diff --git a/tests/unit/models/test_runtime_scene_state.py b/tests/unit/models/test_runtime_scene_state.py index 6b4210b5..c88dc3f9 100644 --- a/tests/unit/models/test_runtime_scene_state.py +++ b/tests/unit/models/test_runtime_scene_state.py @@ -3,9 +3,8 @@ import pytest from pydantic import ValidationError -from ansys.visor.viewer.models.common.visor_variable_state import ( - VisorVariableState, -) +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecord from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( RuntimeDatasetState, ) @@ -102,13 +101,24 @@ def test_dataset_states_accept_valid_mapping(): # ------------------------------------------------------------------ def test_variable_states_default_and_assignment(): - """variable_states should accept valid mapping.""" - - data = { - "a": MagicMock(spec=VisorVariableState), - } + """variable_states holds the server's records: a real record built from literals.""" + + record = VisorVariableRecord( + id="POINT::pressure::1", + array_name="pressure", + type=VisorVtkVariableType.POINT, + num_components=1, + part_ids=[1], + default_magnitude_range=(0.0, 10.0), + default_ranges=[(0.0, 10.0)], + magnitude_range=(2.0, 3.0), + ranges=[(2.0, 3.0)], + ) - state = RuntimeSceneState(variable_states=data) + state = RuntimeSceneState(variable_states={"POINT::pressure::1": record}) - assert "a" in state.variable_states - assert isinstance(state.variable_states["a"], VisorVariableState) + stored = state.variable_states["POINT::pressure::1"] + assert isinstance(stored, VisorVariableRecord) + assert stored.magnitude_range == (2.0, 3.0) + assert stored.default_ranges == [(0.0, 10.0)] + assert stored.part_ids == [1] diff --git a/tests/unit/models/test_visor_save_state_response.py b/tests/unit/models/test_visor_save_state_response.py index b5d97b41..c241e9dc 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 +from unittest.mock import MagicMock, patch import pytest from pydantic import ValidationError @@ -115,14 +115,11 @@ def test_model_dump_contains_fields(monkeypatch): def test_save_path_rejects_a_variable_state_missing_the_identity_fields(): - """The save path must keep raising on a client that stops emitting the fields. - - ``VisorVariableState`` is shared between ``PersistedSceneState.variable_states`` - and ``RuntimeSceneState.variable_states``, so making the three identity - fields optional on the model would have relaxed this coercion too. The - tolerance for old save files lives on the persisted container instead, and - this pins the fact that it did not leak here: reads tolerate absence, - writes do not. + """Rewritten in place (3.5.1 increment 1): the save path no longer rejects this entry. + + 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, @@ -139,16 +136,13 @@ def test_save_path_rejects_a_variable_state_missing_the_identity_fields(): }, } - with pytest.raises(ValidationError) as excinfo: - VisorSaveStateResponse.model_validate(payload) + resp = VisorSaveStateResponse.model_validate(payload) - # Reported under the wire alias, which is the spelling validation ran by. - reported = {error["loc"][-1] for error in excinfo.value.errors()} - assert {"arrayName", "type", "numComponents"} <= reported + assert resp.app_state.scene.variable_states == {} def test_save_path_accepts_a_variable_state_carrying_the_identity_fields(): - """The same payload with the three fields present validates, so the guard is specific.""" + """Rewritten in place (3.5.1 increment 1): a complete browser entry is discarded too.""" payload = { "requestId": 1, "appState": { @@ -169,7 +163,35 @@ def test_save_path_accepts_a_variable_state_carrying_the_identity_fields(): resp = VisorSaveStateResponse.model_validate(payload) - stored = resp.app_state.scene.variable_states["POINT::pressure::1"] - assert stored.array_name == "pressure" - assert stored.num_components == 1 + 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") diff --git a/tests/unit/models/test_visor_variable_record.py b/tests/unit/models/test_visor_variable_record.py new file mode 100644 index 00000000..7992b610 --- /dev/null +++ b/tests/unit/models/test_visor_variable_record.py @@ -0,0 +1,87 @@ +"""Tests for the server-owned variable record (3.5.1 increment 1, tests 1-4). + +Every expected value is a hand-written literal. +""" +import pytest +from pydantic import ValidationError + +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.models.common.visor_variable_record import ( + VisorVariableRecord, + compose_variable_identifier, +) +from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState +from ansys.visor.viewer.models.persist.scene.persisted_scene_state import ( + derive_variable_fields_from_identifier, +) + + +def _record_kwargs(): + return dict( + id="CELL::temperature::1", + array_name="temperature", + type=VisorVtkVariableType.CELL, + num_components=1, + part_ids=[3, 5], + default_magnitude_range=(-1.0, 7.0), + default_ranges=[(-1.0, 7.0)], + magnitude_range=(0.5, 6.5), + ranges=[(0.5, 6.5)], + ) + + +def test_record_rejects_a_missing_default_magnitude_range(): + """#1: every field is required; a record without its default range does not validate.""" + kwargs = _record_kwargs() + del kwargs["default_magnitude_range"] + + with pytest.raises(ValidationError) as excinfo: + VisorVariableRecord(**kwargs) + + assert {error["loc"][-1] for error in excinfo.value.errors()} == {"defaultMagnitudeRange"} + + +def test_record_type_dumps_as_its_wire_value(): + """#2: the enum serializes as "CELL", in both dict and JSON dumps, under every alias.""" + record = VisorVariableRecord(**_record_kwargs()) + + assert record.model_dump(by_alias=True) == { + "id": "CELL::temperature::1", + "arrayName": "temperature", + "type": "CELL", + "numComponents": 1, + "partIds": [3, 5], + "defaultMagnitudeRange": (-1.0, 7.0), + "defaultRanges": [(-1.0, 7.0)], + "magnitudeRange": (0.5, 6.5), + "ranges": [(0.5, 6.5)], + } + assert '"type":"CELL"' in record.model_dump_json(by_alias=True) + + +def test_to_variable_state_projects_identity_and_the_effective_ranges(): + """#3: the persisted projection keeps identity and custom ranges, and drops the rest.""" + projected = VisorVariableRecord(**_record_kwargs()).to_variable_state() + + assert isinstance(projected, VisorVariableState) + assert projected.model_dump(by_alias=True) == { + "id": "CELL::temperature::1", + "arrayName": "temperature", + "type": "CELL", + "numComponents": 1, + "magnitudeRange": (0.5, 6.5), + "ranges": [(0.5, 6.5)], + } + + +def test_compose_variable_identifier_is_the_inverse_of_derive(): + """#4: composes the hand-written literal id, which derive splits back into its fields.""" + identifier = compose_variable_identifier(VisorVtkVariableType.POINT, "pressure", 1) + + assert identifier == "POINT::pressure::1" + assert derive_variable_fields_from_identifier("POINT::pressure::1") == { + "type": "POINT", + "array_name": "pressure", + "num_components": 1, + } + diff --git a/tests/unit/vtk/io/test_visor_file_io.py b/tests/unit/vtk/io/test_visor_file_io.py index fe1873ac..c4f3135b 100644 --- a/tests/unit/vtk/io/test_visor_file_io.py +++ b/tests/unit/vtk/io/test_visor_file_io.py @@ -465,7 +465,7 @@ def _make_state(self, dataset_states: dict) -> PersistedViewerStateV1: return PersistedViewerStateV1.from_components( ui_state=VisorUIState(), unit="m", orthographic_enabled=None, cross_section_enabled=None, edges_enabled=None, bounding_box_enabled=None, - datasets=dataset_states + datasets=dataset_states, variable_states={} ) def test_deletes_vtkhdf_not_in_state(self, file_io, tmp_path): diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 3c9ad6a4..fe66afe8 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -599,7 +599,7 @@ def mocked_scene(renderer): # must return a real RuntimeAppState rather than a MagicMock (whose # dataset_states would be a MagicMock and raise on iteration). s._state_mapper.persisted_to_runtime.return_value = RuntimeAppState.from_components( - ui=VisorUIState(dark_theme=False), unit="m", dataset_states={} + ui=VisorUIState(dark_theme=False), unit="m", dataset_states={}, variable_states={} ) s._vtk_lock = _LockSpy() return s @@ -800,15 +800,20 @@ def _seed_part_variables(registry, variables, part_id=NODE_ID, dataset_id=1): ] -def _runtime_state(part_states, variable_states=None, dataset_id=1): - """A real RuntimeAppState carrying the given per-part records.""" +def _runtime_state(part_states, dataset_id=1): + """A real RuntimeAppState carrying the given per-part records. + + ``variable_states`` is empty: since 3.5.1 the mapper returns ``{}`` and + apply_state fills it from the server's rebuilt record (CC-1). A file's + variable entries are handed to :func:`_apply` instead. + """ return RuntimeAppState.from_components( ui=VisorUIState(dark_theme=False), unit="m", dataset_states={ dataset_id: RuntimeDatasetState(id=dataset_id, part_states=part_states) }, - variable_states=variable_states or {}, + variable_states={}, ) @@ -828,6 +833,7 @@ def _frontend_state(): part_states={NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.99)}, ) }, + variable_states={}, ) @@ -848,11 +854,17 @@ def _runtime_to_persisted(runtime_state): return captured -def _apply(scene, runtime_state): - """Run apply_state with the mapper stubbed to return *runtime_state*.""" +def _apply(scene, runtime_state, file_variable_states=None): + """Run apply_state with the mapper stubbed to return *runtime_state*. + + *file_variable_states* is what the loaded file carries in + ``scene.variable_states``; apply_state overlays it onto the rebuilt record. + """ scene._state_mapper = MagicMock(name="state_mapper") scene._state_mapper.persisted_to_runtime.return_value = runtime_state - return scene.apply_state(MagicMock(name="persisted_state")) + persisted = MagicMock(name="persisted_state") + persisted.scene.variable_states = dict(file_variable_states or {}) + return scene.apply_state(persisted) class _DepthRecordingRegistry(VisorDatasetRegistry): @@ -1079,6 +1091,7 @@ async def _get_runtime_state_async(timeout): unit="m", dataset_states={}, camera=reply_camera, + variable_states={}, ) scene._get_runtime_state_async = _get_runtime_state_async @@ -1185,13 +1198,15 @@ def test_apply_state_pushes_a_json_encodable_runtime_state(scene, registry): """ pushed = {} scene._push_runtime_state = lambda state: pushed.update(state=state) + # The pushed variable entry is now the server's record, rebuilt from the registry (CC-1). + _seed_part_variables(registry, [_pressure_variable()]) _apply( scene, _runtime_state( {NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.25, variable_id=VARIABLE_ID)}, - variable_states={VARIABLE_ID: _variable_state()}, ), + file_variable_states={VARIABLE_ID: _variable_state()}, ) encoded = json.dumps(pushed["state"].model_dump(by_alias=True)) @@ -1319,6 +1334,7 @@ def _persisted_state(camera): bounding_box_enabled=None, datasets={}, camera=camera, + variable_states={}, ) @@ -1769,18 +1785,30 @@ def test_apply_state_short_diffuse_color_is_a_logged_no_op(scene, registry, pipe # Load path — the colour-variable branch # --------------------------------------------------------------------------- -def _color_variable_state(component, **variable_kwargs): - """A part coloured by the fixture's point array, at *component*.""" +def _color_variable_state(component, variable_id=VARIABLE_ID): + """A part coloured by *variable_id*, at *component*.""" return _runtime_state( { NODE_ID: RuntimePartProperties( - id=NODE_ID, variable_id=VARIABLE_ID, variable_component=component + id=NODE_ID, variable_id=variable_id, variable_component=component ) }, - variable_states={VARIABLE_ID: _variable_state(**variable_kwargs)}, ) +def _file_entries(**variable_kwargs): + """The loaded file's ``scene.variable_states``: one entry for VARIABLE_ID.""" + return {VARIABLE_ID: _variable_state(**variable_kwargs)} + + +def _seed_parts(registry, variables_by_part, dataset_id=1): + """Give the registry's dataset per-part variable metadata for several parts.""" + registry.datasets[dataset_id].list_variables.return_value = [ + VisorPartVariables(part_id=part_id, part_name=f"part{part_id}", variables=list(variables)) + for part_id, variables in variables_by_part.items() + ] + + def _seed_unconfigured_mapper(pipeline): """Distinctive, non-default mapper state so a no-op is visible as one.""" pipeline.mapper.SetScalarVisibility(0) @@ -1793,7 +1821,7 @@ def test_apply_state_restores_the_magnitude_range_when_component_is_minus_one( """A stored component of -1 reads magnitude_range, not ranges[0].""" _seed_part_variables(registry, [_pressure_variable()]) - _apply(scene, _color_variable_state(-1)) + _apply(scene, _color_variable_state(-1), _file_entries()) assert pipeline.mapper.GetArrayName() == "pressure" assert pipeline.mapper.GetScalarRange() == pytest.approx((0.0, 49.0)) @@ -1803,7 +1831,7 @@ def test_apply_state_restores_the_per_component_range(scene, registry, pipeline) """A stored component of 0 reads ranges[0], not magnitude_range.""" _seed_part_variables(registry, [_pressure_variable()]) - _apply(scene, _color_variable_state(0)) + _apply(scene, _color_variable_state(0), _file_entries()) assert pipeline.mapper.GetArrayName() == "pressure" assert pipeline.mapper.GetScalarRange() == pytest.approx((10.0, 20.0)) @@ -1817,7 +1845,7 @@ def test_apply_state_negative_component_other_than_minus_one_is_a_logged_no_op( _seed_unconfigured_mapper(pipeline) with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply(scene, _color_variable_state(-2)) + _apply(scene, _color_variable_state(-2), _file_entries()) assert mock_logger.warning.call_count == 1 assert pipeline.mapper.GetScalarVisibility() == 0 @@ -1832,7 +1860,7 @@ def test_apply_state_component_beyond_the_stored_ranges_is_a_logged_no_op( _seed_unconfigured_mapper(pipeline) with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply(scene, _color_variable_state(3)) + _apply(scene, _color_variable_state(3), _file_entries()) assert mock_logger.warning.call_count == 1 assert pipeline.mapper.GetScalarVisibility() == 0 @@ -1840,64 +1868,78 @@ def test_apply_state_component_beyond_the_stored_ranges_is_a_logged_no_op( def test_apply_state_absent_range_is_a_logged_no_op(scene, registry, pipeline): - """A variable entry with no magnitude range applies nothing.""" + """Rewritten in place (3.5.1 increment 1): an absent file range now loads at the default. + + The overlay fills a null ``magnitudeRange`` from the record's default + (D3), so the entry the restore reads always carries a range: the part is + colored at the widened default (0.0, 49.0) and nothing is refused. + """ _seed_part_variables(registry, [_pressure_variable()]) _seed_unconfigured_mapper(pipeline) with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply(scene, _color_variable_state(-1, magnitude_range=None)) + _apply(scene, _color_variable_state(-1), _file_entries(magnitude_range=None)) - assert mock_logger.warning.call_count == 1 - assert pipeline.mapper.GetScalarVisibility() == 0 - assert pipeline.mapper.GetScalarRange() == pytest.approx((11.0, 22.0)) + assert mock_logger.warning.call_count == 0 + assert pipeline.mapper.GetScalarVisibility() == 1 + assert pipeline.mapper.GetScalarRange() == pytest.approx((0.0, 49.0)) def test_apply_state_unknown_variable_identifier_is_a_logged_no_op( scene, registry, pipeline ): - """A stored identifier with no variable entry applies nothing.""" + """A stored identifier with no record applies nothing. + + Rewritten in place (3.5.1 increment 1): the entry now comes from the + record, which the registry's "pressure" always produces, so the part names + an identifier no dataset carries. + """ _seed_part_variables(registry, [_pressure_variable()]) _seed_unconfigured_mapper(pipeline) - runtime = _runtime_state( - { - NODE_ID: RuntimePartProperties( - id=NODE_ID, variable_id=VARIABLE_ID, variable_component=0 - ) - }, - variable_states={}, - ) with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply(scene, runtime) + _apply(scene, _color_variable_state(0, variable_id="POINT::absent::1")) assert mock_logger.warning.call_count == 1 assert pipeline.mapper.GetScalarVisibility() == 0 def test_apply_state_unknown_array_name_is_a_logged_no_op(scene, registry, pipeline): - """An array the part does not carry applies nothing.""" - _seed_part_variables(registry, [_pressure_variable()]) + """An array the part does not carry applies nothing. + + Rewritten in place (3.5.1 increment 1): the record's array name comes from + the registry, so "pressure" lives on another part and this part carries + only a cell array. + """ + temperature = VisorVariable( + index=0, type=VisorVtkVariableType.CELL, name="temperature", num_components=1, + num_points=96, ranges=[(0.0, 95.0)], magnitude_range=(0.0, 95.0), + ) + _seed_parts(registry, {NODE_ID: [temperature], SECOND_NODE_ID: [_pressure_variable()]}) _seed_unconfigured_mapper(pipeline) with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply(scene, _color_variable_state(0, array_name="no_such_array")) + _apply(scene, _color_variable_state(0), _file_entries()) assert mock_logger.warning.call_count == 1 assert pipeline.mapper.GetScalarVisibility() == 0 def test_apply_state_array_width_mismatch_is_a_logged_no_op(scene, registry, pipeline): - """Same name and association, different width: a different quantity.""" - _seed_part_variables(registry, [_pressure_variable()]) + """Same name and association, different width: a different quantity. + + Rewritten in place (3.5.1 increment 1): the width-3 record exists because + another part carries a width-3 "pressure"; this part's is width 1. + """ + wide_pressure = VisorVariable( + index=0, type=VisorVtkVariableType.POINT, name="pressure", num_components=3, + num_points=50, ranges=[(10.0, 20.0), (0.0, 1.0), (0.0, 2.0)], magnitude_range=(0.0, 30.0), + ) + _seed_parts(registry, {NODE_ID: [_pressure_variable()], SECOND_NODE_ID: [wide_pressure]}) _seed_unconfigured_mapper(pipeline) with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply( - scene, - _color_variable_state( - 0, num_components=3, ranges=((10.0, 20.0), (0.0, 1.0), (0.0, 2.0)) - ), - ) + _apply(scene, _color_variable_state(0, variable_id="POINT::pressure::3")) assert mock_logger.warning.call_count == 1 assert pipeline.mapper.GetScalarVisibility() == 0 @@ -1922,11 +1964,10 @@ def test_apply_state_variable_id_without_component_is_a_logged_no_op( _seed_unconfigured_mapper(pipeline) runtime = _runtime_state( {NODE_ID: RuntimePartProperties(id=NODE_ID, variable_id=VARIABLE_ID)}, - variable_states={VARIABLE_ID: _variable_state()}, ) with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply(scene, runtime) + _apply(scene, runtime, _file_entries()) assert mock_logger.warning.call_count == 1 assert pipeline.mapper.GetScalarVisibility() == 0 @@ -1939,11 +1980,10 @@ def test_apply_state_component_without_variable_id_does_not_clear( pipeline.mapper.SetScalarVisibility(1) runtime = _runtime_state( {NODE_ID: RuntimePartProperties(id=NODE_ID, variable_component=0)}, - variable_states={VARIABLE_ID: _variable_state()}, ) with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply(scene, runtime) + _apply(scene, runtime, _file_entries()) assert mock_logger.warning.call_count == 1 assert pipeline.mapper.GetScalarVisibility() == 1 @@ -2075,6 +2115,7 @@ async def _get_runtime_state_async(timeout): cross_section_enabled=reply_toggle, edges_enabled=reply_toggle, bounding_box_enabled=reply_toggle, + variable_states={}, ) scene._get_runtime_state_async = _get_runtime_state_async @@ -2239,6 +2280,7 @@ def _toggle_runtime_state(**toggles): unit="m", dataset_states={}, **toggles, + variable_states={}, ) @@ -2456,6 +2498,7 @@ async def _get_runtime_state_async(timeout): unit="m", dataset_states={}, orthographic_enabled=reply_orthographic, + variable_states={}, ) scene._get_runtime_state_async = _get_runtime_state_async @@ -2702,6 +2745,7 @@ async def _get_runtime_state_async(timeout): unit="m", dataset_states={}, cross_section=reply_plane, + variable_states={}, ) scene._get_runtime_state_async = _get_runtime_state_async @@ -2876,6 +2920,7 @@ def _sync(origin, normal): origin=LOADED_CROSS_SECTION_ORIGIN, normal=LOADED_CROSS_SECTION_NORMAL, ), + variable_states={}, ) ) @@ -2972,6 +3017,7 @@ async def _get_runtime_state_async(timeout): ui=reply_ui, unit="m", dataset_states={}, + variable_states={}, ) scene._get_runtime_state_async = _get_runtime_state_async @@ -2983,6 +3029,7 @@ def _panel_runtime_state(ui): ui=ui, unit="m", dataset_states={}, + variable_states={}, ) @@ -3228,3 +3275,282 @@ def test_get_state_hands_out_a_copy_of_the_ui_record(scene): assert persisted.ui is not scene._ui_state +# =========================================================================== +# 3.5.1 increment 1 -- the server's variable records +# +# Rebuild points (#10-#16), the set_state push (#18) and get_state (#19, #20). +# Expected values are hand-written literals: the fixture "pressure" variable is +# magnitude (0.0, 49.0) and component (10.0, 20.0) on NODE_ID. +# =========================================================================== + +AGGREGATE_LOGGER = "ansys.visor.viewer.vtk.variables.visor_variable_aggregate.logger" + + +def _pressure_record(**overrides): + """The record the fixture "pressure" on NODE_ID rebuilds to, with custom == default.""" + from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecord + + fields = dict( + id=VARIABLE_ID, + array_name="pressure", + type=VisorVtkVariableType.POINT, + num_components=1, + part_ids=[NODE_ID], + default_magnitude_range=(0.0, 49.0), + default_ranges=[(10.0, 20.0)], + magnitude_range=(0.0, 49.0), + ranges=[(10.0, 20.0)], + ) + fields.update(overrides) + return VisorVariableRecord(**fields) + + +def _temperature_variable() -> VisorVariable: + return VisorVariable( + index=0, type=VisorVtkVariableType.CELL, name="temperature", num_components=1, + num_points=96, ranges=[(1.0, 2.0)], magnitude_range=(1.0, 2.0), + ) + + +@pytest.fixture +def records_scene(): + """Scene with a real registry and a MagicMock scene graph and renderer.""" + s = _ConcreteScene(MagicMock(name="server"), dark_mode=False, renderer=MagicMock(name="renderer")) + s._scene_graph = MagicMock(name="scene_graph") + s._scene_graph.get_descendant_node.return_value.get_descendant_part_nodes.return_value = [] + s._dataset_registry = VisorDatasetRegistry() + return s + + +def test_rebuild_at_add_builds_the_record_after_the_registry_add(records_scene): + """#10: add_dataset rebuilds after registry.add, so the new dataset's variable is held.""" + registry = records_scene._dataset_registry + records_scene._scene_graph.load_dataset.return_value = 1 + + def _registry_add(dataset_id, name, data, node_ids, metadata): + registry.datasets[dataset_id] = _make_part_dataset( + dataset_id, [NODE_ID], + part_variables=[VisorPartVariables(NODE_ID, "part", [_pressure_variable()])], + ) + + with ( + patch.object(registry, "get_sanitized_metadata_name", return_value="ds"), + patch.object(registry, "add", side_effect=_registry_add), + ): + records_scene.add_dataset(MagicMock(name="input"), MagicMock(name="metadata")) + + assert records_scene._variable_records.variables == {VARIABLE_ID: _pressure_record()} + + +def test_rebuild_at_remove_drops_ids_with_no_remaining_part(records_scene): + """#11: removing the only dataset carrying "temperature" drops its record.""" + registry = records_scene._dataset_registry + registry.datasets = { + 1: _make_part_dataset(1, [NODE_ID], part_variables=[ + VisorPartVariables(NODE_ID, "part", [_pressure_variable()])]), + 2: _make_part_dataset(2, [SECOND_NODE_ID], part_variables=[ + VisorPartVariables(SECOND_NODE_ID, "part", [_temperature_variable()])]), + } + records_scene._rebuild_variable_records("add") + assert sorted(records_scene._variable_records.variables) == ["CELL::temperature::1", VARIABLE_ID] + + records_scene.remove_dataset(2) + + assert records_scene._variable_records.variables == {VARIABLE_ID: _pressure_record()} + + +def test_rebuild_at_clear_empties_the_records(records_scene): + """#12: clear leaves no record.""" + records_scene._dataset_registry.datasets = { + 1: _make_part_dataset(1, [NODE_ID], part_variables=[ + VisorPartVariables(NODE_ID, "part", [_pressure_variable()])]), + } + records_scene._rebuild_variable_records("add") + holder = records_scene._variable_records + + records_scene.clear() + + assert records_scene._variable_records.variables == {} + assert records_scene._variable_records is holder + + +def test_rebuild_at_update_follows_the_reload_and_the_metadata_refresh(records_scene): + """#13: a width change drops the old id and starts the new one at default. + + Call-order pin: registry.update_variables (which reloads the part variables), + then refresh_descendant_variable_metadata, then the rebuild. + """ + from ansys.visor.viewer.vtk.scene import base as base_module + + registry = records_scene._dataset_registry + dataset = _make_part_dataset(1, [NODE_ID], part_variables=[ + VisorPartVariables(NODE_ID, "part", [_pressure_variable()])]) + registry.datasets = {1: dataset} + records_scene._rebuild_variable_records("add") + + wide_pressure = VisorVariable( + index=0, type=VisorVtkVariableType.POINT, name="pressure", num_components=3, + num_points=50, ranges=[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)], magnitude_range=(0.0, 7.0), + ) + order = [] + + def _update_variables(dataset_id, variables): + order.append("update_variables") + dataset.list_variables.return_value = [VisorPartVariables(NODE_ID, "part", [wide_pressure])] + + node = records_scene._scene_graph.get_descendant_node.return_value + node.refresh_descendant_variable_metadata.side_effect = lambda **_: order.append("refresh") + real_build = base_module.build_variable_records + + def _build(*args): + order.append("rebuild") + return real_build(*args) + + with ( + patch.object(registry, "update_variables", side_effect=_update_variables), + patch.object(base_module, "build_variable_records", side_effect=_build), + ): + records_scene.update_variables_for_dataset(1, []) + + assert order == ["update_variables", "refresh", "rebuild"] + assert records_scene._variable_records.variables == { + "POINT::pressure::3": _pressure_record( + id="POINT::pressure::3", + num_components=3, + default_magnitude_range=(0.0, 7.0), + default_ranges=[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)], + magnitude_range=(0.0, 7.0), + ranges=[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)], + ) + } + + +def test_load_overlays_the_file_range_before_the_part_restore(scene, registry): + """#14: the file's range is held after load. + + Call-order pin: rebuild, overlay and the CC-1 placement all precede + _restore_part_states -- asserted by recorded order and by the entry the + restore sees at the moment it is called. + """ + from ansys.visor.viewer.vtk.scene import base as base_module + + _seed_part_variables(registry, [_pressure_variable()]) + order = [] + seen_by_restore = {} + real_build = base_module.build_variable_records + real_overlay = base_module.overlay_persisted_ranges + + def _build(*args): + order.append("rebuild") + return real_build(*args) + + def _overlay(*args): + order.append("overlay") + return real_overlay(*args) + + def _restore(runtime_app_state): + order.append("restore") + seen_by_restore["entry"] = runtime_app_state.scene.variable_states.get(VARIABLE_ID) + + with ( + patch.object(base_module, "build_variable_records", side_effect=_build), + patch.object(base_module, "overlay_persisted_ranges", side_effect=_overlay), + patch.object(scene, "_restore_part_states", side_effect=_restore), + ): + _apply( + scene, + _runtime_state({}), + _file_entries(magnitude_range=(1.0, 2.0), ranges=((3.0, 4.0),)), + ) + + expected = _pressure_record(magnitude_range=(1.0, 2.0), ranges=[(3.0, 4.0)]) + assert order == ["rebuild", "overlay", "restore"] + assert seen_by_restore["entry"] == expected + assert scene._variable_records.variables == {VARIABLE_ID: expected} + + +def test_load_fills_a_null_magnitude_range_with_the_default(scene, registry): + """#15: a null magnitudeRange in the file loads at the default, logged at DEBUG.""" + _seed_part_variables(registry, [_pressure_variable()]) + + with patch(AGGREGATE_LOGGER) as mock_logger: + _apply(scene, _runtime_state({}), _file_entries(magnitude_range=None, ranges=((3.0, 4.0),))) + + assert scene._variable_records.variables == { + VARIABLE_ID: _pressure_record(magnitude_range=(0.0, 49.0), ranges=[(3.0, 4.0)]) + } + assert mock_logger.debug.call_count == 1 + assert mock_logger.warning.call_count == 0 + + +def test_load_drops_a_file_id_with_no_record(scene, registry): + """#16: a file id no dataset carries is dropped with a WARNING.""" + _seed_part_variables(registry, [_pressure_variable()]) + ghost = VisorVariableState( + id="POINT::ghost::1", array_name="ghost", type=VisorVtkVariableType.POINT, + num_components=1, magnitude_range=(5.0, 6.0), ranges=[(5.0, 6.0)], + ) + + with patch(AGGREGATE_LOGGER) as mock_logger: + _apply(scene, _runtime_state({}), {"POINT::ghost::1": ghost}) + + assert scene._variable_records.variables == {VARIABLE_ID: _pressure_record()} + assert mock_logger.warning.call_count == 1 + + +def test_set_state_push_carries_the_record(scene, registry): + """#18: the state handed to the push carries the record, not the file entry, as a copy.""" + _seed_part_variables(registry, [_pressure_variable()]) + pushed = {} + scene._push_runtime_state = lambda state: pushed.update(state=state) + + _apply(scene, _runtime_state({}), _file_entries(magnitude_range=(1.0, 2.0), ranges=((3.0, 4.0),))) + + entry = pushed["state"].scene.variable_states[VARIABLE_ID] + assert entry == _pressure_record(magnitude_range=(1.0, 2.0), ranges=[(3.0, 4.0)]) + assert entry is not scene._variable_records.variables[VARIABLE_ID] + assert pushed["state"].model_dump(by_alias=True)["scene"]["variableStates"][VARIABLE_ID] == { + "id": VARIABLE_ID, + "arrayName": "pressure", + "type": "POINT", + "numComponents": 1, + "partIds": [NODE_ID], + "defaultMagnitudeRange": (0.0, 49.0), + "defaultRanges": [(10.0, 20.0)], + "magnitudeRange": (1.0, 2.0), + "ranges": [(3.0, 4.0)], + } + + +def _contradictory_reply(): + """The browser's reply: a different range for the same id, a stray id, and another unit.""" + return RuntimeAppState.from_components( + ui=VisorUIState(dark_theme=False), + unit="ft", + dataset_states={}, + variable_states={ + VARIABLE_ID: _pressure_record(magnitude_range=(900.0, 901.0), ranges=[(902.0, 903.0)]), + "POINT::stray::1": _pressure_record(id="POINT::stray::1", array_name="stray"), + }, + ) + + +def test_get_state_takes_the_variable_states_from_the_server(scene, registry): + """#19: the save receives the server's record, whatever the browser replied.""" + _seed_part_variables(registry, [_pressure_variable()]) + scene._rebuild_variable_records("add") + captured = _capture_persist_input(scene, _contradictory_reply()) + + asyncio.run(scene.get_state(timeout=1.0)) + + assert captured["runtime_state"].scene.variable_states == {VARIABLE_ID: _pressure_record()} + + +def test_get_state_takes_the_unit_from_the_registry(scene, registry): + """#20: the save receives the registry's unit, whatever the browser replied.""" + registry.unit = "mm" + captured = _capture_persist_input(scene, _contradictory_reply()) + + asyncio.run(scene.get_state(timeout=1.0)) + + assert captured["runtime_state"].scene.unit == "mm" diff --git a/tests/unit/vtk/scene/test_visor_state_mapper.py b/tests/unit/vtk/scene/test_visor_state_mapper.py index 80f43608..eeeb1d45 100644 --- a/tests/unit/vtk/scene/test_visor_state_mapper.py +++ b/tests/unit/vtk/scene/test_visor_state_mapper.py @@ -1,7 +1,27 @@ import pytest +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState +from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecord +from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState +from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState from ansys.visor.viewer.vtk.scene.visor_state_mapper import VisorStateMapper + +def _literal_record() -> VisorVariableRecord: + """A record whose custom range (2.0, 3.0) differs from its default (0.0, 10.0).""" + return VisorVariableRecord( + id="POINT::pressure::1", + array_name="pressure", + type=VisorVtkVariableType.POINT, + num_components=1, + part_ids=[1], + default_magnitude_range=(0.0, 10.0), + default_ranges=[(0.0, 10.0)], + magnitude_range=(2.0, 3.0), + ranges=[(2.0, 3.0)], + ) + # ------------------------------------------------------------------ # Helpers / fakes # ------------------------------------------------------------------ @@ -66,7 +86,7 @@ def fake_from_components(**kwargs): "scene": type("Scene", (), { "unit": "m", "camera": "cam", - "variable_states": {"var": 1}, + "variable_states": {"POINT::pressure::1": _literal_record()}, "dataset_states": {1: {"state": 123}}, "cross_section": "cs", "orthographic_enabled": True, @@ -83,7 +103,40 @@ def fake_from_components(**kwargs): assert called["datasets"] == {"dsA": {"converted": {"state": 123}}} assert called["unit"] == "m" assert called["camera"] == "cam" - assert called["variable_states"] == {"var": 1} + # Rewritten in place (3.5.1 increment 1): the record is projected, not passed through. + assert called["variable_states"] == { + "POINT::pressure::1": VisorVariableState( + id="POINT::pressure::1", + array_name="pressure", + type=VisorVtkVariableType.POINT, + num_components=1, + magnitude_range=(2.0, 3.0), + ranges=[(2.0, 3.0)], + ) + } + + +def test_runtime_to_persisted_projects_records_onto_the_persisted_entry(): + """#22: through the real persisted model, the saved entry carries the effective range and no default keys.""" + registry = FakeRegistry(datasets={}) + runtime_app_state = RuntimeAppState.from_components( + ui=VisorUIState(), + unit="m", + dataset_states={}, + variable_states={"POINT::pressure::1": _literal_record()}, + ) + + persisted = VisorStateMapper(registry).runtime_to_persisted(runtime_app_state) + + entry = persisted.model_dump(by_alias=True)["scene"]["variable_states"]["POINT::pressure::1"] + assert entry == { + "id": "POINT::pressure::1", + "arrayName": "pressure", + "type": "POINT", + "numComponents": 1, + "magnitudeRange": (2.0, 3.0), + "ranges": [(2.0, 3.0)], + } def test_runtime_to_persisted_skips_missing_dataset(monkeypatch): diff --git a/tests/unit/vtk/test_wire_format_identity.py b/tests/unit/vtk/test_wire_format_identity.py index 81ea3757..886ccc42 100644 --- a/tests/unit/vtk/test_wire_format_identity.py +++ b/tests/unit/vtk/test_wire_format_identity.py @@ -350,3 +350,99 @@ def test_data_array_type_is_str_and_point_or_cell_in_emitted_json(): assert d["type"] in {"POINT", "CELL"} +# --------------------------------------------------------------------------- +# 3.5.1 increment 1: the server's variable records on the scene-details wire. +# +# The registry holds two hand-written datasets sharing "pressure"; the scene +# graph is the fixture sphere and plays no part in the variables. Expected +# values are hand-written literals. +# --------------------------------------------------------------------------- + +class _VariablesOnlyDataset: + """Dataset stand-in answering the two reads get_scene_details and the rebuild make.""" + + def __init__(self, dataset_id, parts): + from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( + RuntimeDatasetState, + ) + self.state = RuntimeDatasetState(id=dataset_id, part_states={}) + self._parts = parts + + def list_variables(self): + from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables + return [ + VisorPartVariables(part_id=part_id, part_name=f"p{part_id}", variables=variables) + for part_id, variables in self._parts.items() + ] + + +def _variable(name, ranges, magnitude): + from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType + from ansys.visor.viewer.vtk.variables.visor_variables import VisorVariable + return VisorVariable( + index=0, type=VisorVtkVariableType.POINT, name=name, num_components=len(ranges), + num_points=4, ranges=list(ranges), magnitude_range=magnitude, + ) + + +def _scene_with_two_datasets_sharing_pressure(): + scene = _build_scene(_sphere_polydata()) + scene._dataset_registry.datasets = { + 1: _VariablesOnlyDataset(1, { + 11: [ + _variable("pressure", [(0.0, 10.0)], (0.0, 10.0)), + _variable("velocity", [(0.0, 1.0), (0.0, 2.0), (0.0, 3.0)], (0.0, 4.0)), + ], + }), + 2: _VariablesOnlyDataset(2, {21: [_variable("pressure", [(-5.0, 4.0)], (-5.0, 4.0))]}), + } + scene._rebuild_variable_records("add") + return scene + + +def test_scene_details_json_carries_every_record_key_and_no_null(): + """#17: every record key for every variable, no null, on two datasets sharing one variable.""" + scene = _scene_with_two_datasets_sharing_pressure() + + payload = json.loads(scene.get_scene_details_json()) + + assert payload["appState"]["scene"]["variableStates"] == { + "POINT::pressure::1": { + "id": "POINT::pressure::1", + "arrayName": "pressure", + "type": "POINT", + "numComponents": 1, + "partIds": [11, 21], + "defaultMagnitudeRange": [-5.0, 10.0], + "defaultRanges": [[-5.0, 10.0]], + "magnitudeRange": [-5.0, 10.0], + "ranges": [[-5.0, 10.0]], + }, + "POINT::velocity::3": { + "id": "POINT::velocity::3", + "arrayName": "velocity", + "type": "POINT", + "numComponents": 3, + "partIds": [11], + "defaultMagnitudeRange": [0.0, 4.0], + "defaultRanges": [[0.0, 1.0], [0.0, 2.0], [0.0, 3.0]], + "magnitudeRange": [0.0, 4.0], + "ranges": [[0.0, 1.0], [0.0, 2.0], [0.0, 3.0]], + }, + } + + +def test_mutating_the_handed_out_records_leaves_the_holder_unchanged(): + """#24: get_scene_details hands out a deep copy; mutating its entries does not reach the holder.""" + scene = _scene_with_two_datasets_sharing_pressure() + + details = scene.get_scene_details() + handed_out = details.app_state.scene.variable_states["POINT::pressure::1"] + handed_out.magnitude_range = (900.0, 901.0) + handed_out.ranges[0] = (902.0, 903.0) + handed_out.part_ids.append(999) + + held = scene._variable_records.variables["POINT::pressure::1"] + assert held.magnitude_range == (-5.0, 10.0) + assert held.ranges == [(-5.0, 10.0)] + assert held.part_ids == [11, 21] diff --git a/tests/unit/vtk/variables/test_visor_variable_aggregate.py b/tests/unit/vtk/variables/test_visor_variable_aggregate.py new file mode 100644 index 00000000..9d2b214f --- /dev/null +++ b/tests/unit/vtk/variables/test_visor_variable_aggregate.py @@ -0,0 +1,106 @@ +"""Tests for the pure variable aggregate (3.5.1 increment 1, tests 5-9). + +The registry is a hand-written fake; every expected value is a hand-written +literal, never recomputed the way the code computes it. +""" +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables +from ansys.visor.viewer.vtk.variables.visor_variable_aggregate import build_variable_records +from ansys.visor.viewer.vtk.variables.visor_variables import VisorVariable + +POINT = VisorVtkVariableType.POINT + + +def _var(name, ranges, magnitude, point_type=POINT): + return VisorVariable( + index=0, type=point_type, name=name, num_components=len(ranges), + num_points=4, ranges=list(ranges), magnitude_range=magnitude, + ) + + +class _FakeDataset: + def __init__(self, parts): + self._parts = parts + + def list_variables(self): + return [ + VisorPartVariables(part_id=part_id, part_name=f"p{part_id}", variables=variables) + for part_id, variables in self._parts.items() + ] + + +class _FakeRegistry: + def __init__(self, *datasets): + self.datasets = {i + 1: d for i, d in enumerate(datasets)} + + +def _two_datasets_sharing_pressure(): + return _FakeRegistry( + _FakeDataset({1: [_var("pressure", [(0.0, 10.0)], (0.0, 10.0))]}), + _FakeDataset({3: [_var("pressure", [(-5.0, 4.0)], (-5.0, 4.0))]}), + ) + + +def test_participation_is_the_union_of_parts_across_datasets(): + """#5: one record for the shared variable, naming both parts.""" + records = build_variable_records(_two_datasets_sharing_pressure(), {}) + + assert list(records) == ["POINT::pressure::1"] + assert records["POINT::pressure::1"].part_ids == [1, 3] + + +def test_default_ranges_widen_to_the_min_of_mins_and_max_of_maxes(): + """#6: (0.0, 10.0) and (-5.0, 4.0) widen to (-5.0, 10.0); a new id's custom equals its default.""" + record = build_variable_records(_two_datasets_sharing_pressure(), {})["POINT::pressure::1"] + + assert record.default_magnitude_range == (-5.0, 10.0) + assert record.default_ranges == [(-5.0, 10.0)] + assert record.magnitude_range == (-5.0, 10.0) + assert record.ranges == [(-5.0, 10.0)] + + +def test_a_width_split_produces_distinct_ids(): + """#7: same name and association at widths 3 and 1 are two records, one part each.""" + registry = _FakeRegistry( + _FakeDataset({ + 1: [_var("velocity", [(0.0, 1.0), (0.0, 2.0), (0.0, 3.0)], (0.0, 4.0))], + 2: [_var("velocity", [(5.0, 6.0)], (5.0, 6.0))], + }) + ) + + records = build_variable_records(registry, {}) + + assert sorted(records) == ["POINT::velocity::1", "POINT::velocity::3"] + assert records["POINT::velocity::3"].part_ids == [1] + assert records["POINT::velocity::3"].default_ranges == [(0.0, 1.0), (0.0, 2.0), (0.0, 3.0)] + assert records["POINT::velocity::1"].part_ids == [2] + + +def _one_dataset_pressure(): + return _FakeRegistry(_FakeDataset({1: [_var("pressure", [(0.0, 10.0)], (0.0, 10.0))]})) + + +def test_an_edited_custom_range_is_kept_across_widening(): + """#8: a custom slot that differs from its previous default survives the rebuild.""" + previous = build_variable_records(_one_dataset_pressure(), {}) + previous["POINT::pressure::1"] = previous["POINT::pressure::1"].model_copy( + update={"magnitude_range": (2.0, 3.0), "ranges": [(2.0, 3.0)]} + ) + + record = build_variable_records(_two_datasets_sharing_pressure(), previous)["POINT::pressure::1"] + + assert record.default_magnitude_range == (-5.0, 10.0) + assert record.magnitude_range == (2.0, 3.0) + assert record.ranges == [(2.0, 3.0)] + + +def test_an_unedited_custom_range_follows_the_new_default(): + """#9: a custom slot equal to its previous default (0.0, 10.0) follows the widening to (-5.0, 10.0).""" + previous = build_variable_records(_one_dataset_pressure(), {}) + assert previous["POINT::pressure::1"].magnitude_range == (0.0, 10.0) + + record = build_variable_records(_two_datasets_sharing_pressure(), previous)["POINT::pressure::1"] + + assert record.magnitude_range == (-5.0, 10.0) + assert record.ranges == [(-5.0, 10.0)] + diff --git a/tests/unit/vtk/variables/test_visor_variable_empty_block.py b/tests/unit/vtk/variables/test_visor_variable_empty_block.py new file mode 100644 index 00000000..4db23d6c --- /dev/null +++ b/tests/unit/vtk/variables/test_visor_variable_empty_block.py @@ -0,0 +1,72 @@ +"""G4: variable metadata on a multiblock with an empty (None) block (3.5.1 increment 1, test 23). + +The scene graph and the dataset's PartIndex must name the same parts with the +same variables, or the record's ``part_ids`` name ids the client's nodes lack. + +Expected to fail today on F-E: ``VisorGroupNode._post_init`` walks every +``GetBlock(i)`` including ``None``, and node creation raises RuntimeError. +PartIndex skips empty blocks. Recorded as found-not-fixed; neither +part_index.py nor group_node.py is edited in this increment. +""" +import pytest +from vtkmodules.vtkCommonCore import vtkFloatArray +from vtkmodules.vtkCommonDataModel import vtkMultiBlockDataSet +from vtkmodules.vtkFiltersSources import vtkSphereSource + +from ansys.visor.viewer.core.metadata import ExtendedMetadata +from ansys.visor.viewer.vtk.datasets.visor_dataset import VisorDataset +from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraph + + +def _sphere_with_point_array(name: str): + src = vtkSphereSource() + src.Update() + poly = src.GetOutput() + array = vtkFloatArray() + array.SetName(name) + array.SetNumberOfComponents(1) + array.SetNumberOfTuples(poly.GetNumberOfPoints()) + for i in range(poly.GetNumberOfPoints()): + array.SetTuple1(i, float(i)) + poly.GetPointData().AddArray(array) + return poly + + +def _multiblock_with_an_empty_block() -> vtkMultiBlockDataSet: + mb = vtkMultiBlockDataSet() + mb.SetNumberOfBlocks(3) + mb.SetBlock(0, _sphere_with_point_array("pressure")) + # Block 1 is left None: the empty block. + mb.SetBlock(2, _sphere_with_point_array("temperature")) + return mb + + +@pytest.mark.xfail( + strict=True, + raises=RuntimeError, + reason="F-E: VisorGroupNode._post_init walks the None block and node creation raises " + "RuntimeError; PartIndex skips it. Found, not fixed, in 3.5.1 increment 1.", +) +def test_part_node_data_arrays_and_list_variables_agree_on_an_empty_block(): + """#23: per part id, part_node.data_arrays and dataset.list_variables() name the same arrays.""" + mb = _multiblock_with_an_empty_block() + + graph = VisorSceneGraph() + graph.load_dataset(mb, "ds") + part_nodes = graph.get_descendant_part_nodes(include_self=False) + node_ids = [node.id for node in part_nodes] + dataset = VisorDataset(1, "ds", mb, node_ids, ExtendedMetadata(name="ds", unit="m")) + + # Hand-written, in leaf order: the two non-empty blocks. + expected_names = [["pressure"], ["temperature"]] + assert len(node_ids) == 2 + expected = dict(zip(node_ids, expected_names)) + + from_graph = {node.id: [info.name for info in node.data_arrays] for node in part_nodes} + from_dataset = { + part.part_id: [variable.name for variable in part.variables] + for part in dataset.list_variables() + } + assert from_graph == expected + assert from_dataset == expected + From b61c109c4284acd4879a968a7563a0496bfdf768 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 29 Sep 2026 09:53:59 -0700 Subject: [PATCH 02/12] feat: clean up server side variable aggregation --- .../models/common/visor_variable_record.py | 166 +++++++++++++++++- .../requests/visor_save_state_response.py | 2 + src/ansys/visor/viewer/vtk/scene/base.py | 56 +++--- .../vtk/variables/visor_variable_aggregate.py | 153 ---------------- .../test_visor_variable_record_build.py} | 19 +- tests/unit/vtk/scene/test_base.py | 30 ++-- tests/unit/vtk/test_wire_format_identity.py | 2 +- 7 files changed, 222 insertions(+), 206 deletions(-) delete mode 100644 src/ansys/visor/viewer/vtk/variables/visor_variable_aggregate.py rename tests/unit/{vtk/variables/test_visor_variable_aggregate.py => models/test_visor_variable_record_build.py} (78%) diff --git a/src/ansys/visor/viewer/models/common/visor_variable_record.py b/src/ansys/visor/viewer/models/common/visor_variable_record.py index ca188fdc..877a1dc4 100644 --- a/src/ansys/visor/viewer/models/common/visor_variable_record.py +++ b/src/ansys/visor/viewer/models/common/visor_variable_record.py @@ -1,13 +1,23 @@ """Server-owned record for one color variable across every dataset in the scene.""" -from typing import Dict, List, Tuple +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, List, Tuple from pydantic import BaseModel, ConfigDict, Field, field_serializer from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState from ansys.visor.viewer.models.persist.scene.persisted_scene_state import _IDENTIFIER_SEPARATOR +if TYPE_CHECKING: + from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry + from ansys.visor.viewer.vtk.variables.visor_variables import VisorVariable + +logger = VisorDefaultLogger(__name__) + +Range = Tuple[float, float] + def compose_variable_identifier(type: VisorVtkVariableType, name: str, num_components: int) -> str: """Compose the stable variable identifier ``::::``. @@ -58,6 +68,94 @@ def to_variable_state(self) -> VisorVariableState: ranges=list(self.ranges), ) + def range_for(self, component: int) -> Range | None: + """Return the effective range for a slot: ``-1`` is magnitude, ``0..n-1`` a component, else None.""" + if component == -1: + return self.magnitude_range + if 0 <= component < self.num_components and component < len(self.ranges): + return self.ranges[component] + return None + + +@dataclass +class _VariableAccumulator: + """Participation and widened default ranges for one (association, name, width) key.""" + + variable_type: VisorVtkVariableType + name: str + num_components: int + part_ids: List[int] = field(default_factory=list, init=False) + magnitude: Range | None = field(default=None, init=False) + components: List[Range | None] = field(default_factory=list, init=False) + + def __post_init__(self) -> None: + """Start with one empty default slot per component.""" + self.components = [None] * self.num_components + + @staticmethod + def _widen(current: Range | None, other: Range | None) -> Range | None: + """Return the union of two ranges; ``None`` contributes nothing.""" + if other is None: + return current + other = (float(other[0]), float(other[1])) + if current is None: + return other + return (min(current[0], other[0]), max(current[1], other[1])) + + @staticmethod + def _follow_or_keep(previous_custom: Range, previous_default: Range, new_default: Range) -> Range: + """A custom slot equal to its previous default follows the new default; otherwise it is kept.""" + if tuple(previous_custom) == tuple(previous_default): + return new_default + return tuple(previous_custom) + + @property + def variable_id(self) -> str: + """The composed identifier for this key: the one compose_variable_identifier call site.""" + return compose_variable_identifier(self.variable_type, self.name, self.num_components) + + def add_part(self, part_id: int, variable: "VisorVariable") -> None: + """Record the part's participation and widen the default ranges with its variable's ranges.""" + if part_id not in self.part_ids: + self.part_ids.append(part_id) + + variable_magnitude = variable.magnitude_range + if variable_magnitude is None and self.num_components == 1 and variable.ranges: + variable_magnitude = variable.ranges[0] + self.magnitude = self._widen(self.magnitude, variable_magnitude) + + for k in range(min(self.num_components, len(variable.ranges))): + self.components[k] = self._widen(self.components[k], variable.ranges[k]) + + def to_record(self, previous_record: VisorVariableRecord | None) -> VisorVariableRecord: + """Build the record, carrying each custom slot from ``previous_record`` by the D3 rule.""" + n = self.num_components + default_magnitude = self.magnitude or (0.0, 0.0) + default_ranges = [slot if slot is not None else (0.0, 0.0) for slot in self.components] + + custom_magnitude = default_magnitude + custom_ranges = list(default_ranges) + old = previous_record + if old is not None: + custom_magnitude = self._follow_or_keep(old.magnitude_range, old.default_magnitude_range, default_magnitude) + custom_ranges = [ + self._follow_or_keep(old.ranges[k], old.default_ranges[k], default_ranges[k]) + if k < len(old.ranges) and k < len(old.default_ranges) else default_ranges[k] + for k in range(n) + ] + + return VisorVariableRecord( + id=self.variable_id, + array_name=self.name, + type=self.variable_type, + num_components=n, + part_ids=sorted(self.part_ids), + default_magnitude_range=default_magnitude, + default_ranges=default_ranges, + magnitude_range=custom_magnitude, + ranges=custom_ranges, + ) + class VisorVariableRecords(BaseModel): """Holder for the scene's variable records, keyed by identifier. @@ -67,3 +165,69 @@ class VisorVariableRecords(BaseModel): """ variables: Dict[str, VisorVariableRecord] = Field(default_factory=dict) + @classmethod + def from_registry( + cls, + registry: "VisorDatasetRegistry", + previous: "VisorVariableRecords", + ) -> "VisorVariableRecords": + """Build a new holder from the registry's per-part variables, carrying custom ranges from ``previous``. + + 1. Union: each part carrying a variable joins that identifier's ``part_ids``. + 2. Widen: default ranges are the min of the mins and the max of the maxes. + 3. Custom: an existing id keeps an edited custom slot; an unedited slot (equal to the + previous default) follows the new default. A new id starts with custom equal to default. + + ``previous`` is never mutated. + """ + accumulators: Dict[tuple, _VariableAccumulator] = {} + for dataset in list(registry.datasets.values()): + for part_variables in dataset.list_variables(): + for variable in part_variables.variables: + key = (variable.type, variable.name, int(variable.num_components)) + if key not in accumulators: + accumulators[key] = _VariableAccumulator(*key) + accumulators[key].add_part(part_variables.part_id, variable) + + variables: Dict[str, VisorVariableRecord] = {} + for accumulator in accumulators.values(): + variable_id = accumulator.variable_id + variables[variable_id] = accumulator.to_record(previous.variables.get(variable_id)) + return cls(variables=variables) + + def overlay(self, file_states: Dict[str, VisorVariableState]) -> "VisorVariableRecords": + """Apply a file's ranges to this fresh build by the D3 load rule and return this holder. + + - A null or absent ``magnitudeRange`` falls back to the default (DEBUG). + - ``ranges`` whose length differs from ``num_components`` fall back to the default (DEBUG). + - A file id with no record is dropped (WARNING). + + Assigns a new dict to ``variables``; the previous dict and its entries are never mutated. + """ + result = dict(self.variables) + for variable_id, entry in (file_states or {}).items(): + record = result.get(variable_id) + if record is None: + logger.warning("persisted variable %s has no record in the scene; dropped", variable_id) + continue + + magnitude = entry.magnitude_range + if magnitude is None: + logger.debug("persisted variable %s has no magnitudeRange; default used", variable_id) + magnitude = record.default_magnitude_range + + ranges = entry.ranges + if ranges is None or len(ranges) != record.num_components: + logger.debug( + "persisted variable %s has %s ranges for %d components; default used", + variable_id, None if ranges is None else len(ranges), record.num_components, + ) + ranges = record.default_ranges + + result[variable_id] = record.model_copy( + update={"magnitude_range": tuple(magnitude), "ranges": [tuple(r) for r in ranges]}, + deep=True, + ) + self.variables = result + return self + 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 79f05614..9b45ef5b 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 @@ -24,6 +24,8 @@ 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. + # A later increment decides whether this validator stays. @model_validator(mode="before") @classmethod def _discard_browser_variable_states(cls, data: Any) -> Any: diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 0853d347..a8b9abb2 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -26,10 +26,6 @@ from ansys.visor.viewer.vtk.scene.visor_state_mapper import VisorStateMapper from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraph from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables -from ansys.visor.viewer.vtk.variables.visor_variable_aggregate import ( - build_variable_records, - overlay_persisted_ranges, -) from ansys.visor.viewer.vtk.variables.visor_variable_update import VisorVariableUpdate if TYPE_CHECKING: @@ -110,8 +106,9 @@ def __init__( ) # The server's variable records, keyed by composed identifier. Created once - # here and never rebound. Written only by _rebuild_variable_records, under - # _vtk_lock, by assigning a new dict (copy-on-write) so the unlocked reader in + # here and never rebound. Written only by _rebuild_variable_records_from_registry + # and _load_variable_records, under _vtk_lock, by assigning a new dict + # (copy-on-write) so the unlocked reader in # get_scene_details never sees a dict change size. Handed out only as # model_copy(deep=True): a shallow copy would share the dict and its entries. self._variable_records = VisorVariableRecords() @@ -303,7 +300,7 @@ def apply_state(self, state: PersistedViewerStateV1): # Variable records: rebuilt from the registry and overlaid with the file's # ranges, then placed on the runtime state (CC-1), all before # _restore_part_states, which reads each colored part's range from that dict. - self._rebuild_variable_records("load", persisted=state.scene.variable_states) + self._load_variable_records(state.scene.variable_states) runtime_app_state.scene.variable_states = self._variable_records.model_copy(deep=True).variables # One call per state class: updates the server's stored state and its VTK objects. @@ -402,7 +399,7 @@ def clear(self): self._renderer.deregister_all() self._dataset_registry.clear() self._scene_graph = None - self._rebuild_variable_records("clear") + self._rebuild_variable_records_from_registry() def populate_scene(self): """Update widgets to reflect the current scene contents. @@ -488,7 +485,7 @@ def add_dataset(self, input: VisorDatasetType, metadata: ExtendedMetadata) -> in node_ids = [leaf.id for leaf in part_nodes] self._dataset_registry.add(dataset_id, dataset_name, input, node_ids, metadata) - self._rebuild_variable_records("add") + self._rebuild_variable_records_from_registry() return dataset_id @@ -520,7 +517,7 @@ def remove_dataset(self, dataset_id: int): # Unregister the dataset self._dataset_registry.remove(dataset_id) - self._rebuild_variable_records("remove") + self._rebuild_variable_records_from_registry() def list_variables_for_dataset(self, dataset_id: int) -> List[VisorPartVariables]: return self._dataset_registry.list_variables(dataset_id) @@ -546,35 +543,38 @@ def update_variables_for_dataset(self, dataset_id: int, variables: List[VisorVar dataset_node.refresh_descendant_variable_metadata(include_self=True) # After the reload and the metadata refresh: a width change changes the id. - self._rebuild_variable_records("update_variables") + self._rebuild_variable_records_from_registry() with timer.phase("render"): self.render() timer.log() - def _rebuild_variable_records( - self, - point: str, - persisted: Dict[str, VisorVariableState] | None = None, - ) -> None: - """Rebuild the variable records from the registry. + def _rebuild_variable_records_from_registry(self) -> None: + """Rebuild the held records from the registry, carrying custom ranges; under ``_vtk_lock``. + + Used at add, remove, clear and update. Assigns a new dict to the holder + (copy-on-write); never mutates the live dict or its entries. + """ + with self._vtk_lock: + records = VisorVariableRecords.from_registry(self._dataset_registry, self._variable_records).variables + self._variable_records.variables = records + logger.debug("variable records rebuilt from registry: %d records", len(records)) - ``persisted`` is ``None`` at add, remove, clear and update: the previous - records are carried by the custom-range rule. On load it is the file's - ``variable_states``: the records are built fresh and the file's ranges - overlaid. + def _load_variable_records(self, file_states: Dict[str, VisorVariableState]) -> None: + """Build the held records fresh from the registry and overlay the file's ranges; under ``_vtk_lock``. - Under ``_vtk_lock``. Assigns a new dict to the holder (copy-on-write); - never mutates the live dict or its entries. + Used at load. Assigns a new dict to the holder (copy-on-write); never + mutates the live dict or its entries. """ with self._vtk_lock: - if persisted is None: - records = build_variable_records(self._dataset_registry, self._variable_records.variables) - else: - records = overlay_persisted_ranges(build_variable_records(self._dataset_registry, {}), persisted) + records = ( + VisorVariableRecords.from_registry(self._dataset_registry, VisorVariableRecords()) + .overlay(file_states) + .variables + ) self._variable_records.variables = records - logger.debug("variable records rebuilt at %s: %d records", point, len(records)) + logger.debug("variable records loaded from file: %d records", len(records)) def render(self): """Delegate to the renderer backend. diff --git a/src/ansys/visor/viewer/vtk/variables/visor_variable_aggregate.py b/src/ansys/visor/viewer/vtk/variables/visor_variable_aggregate.py deleted file mode 100644 index 397e49e6..00000000 --- a/src/ansys/visor/viewer/vtk/variables/visor_variable_aggregate.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Pure aggregation of the scene's variable records from the dataset registry.""" - -from typing import TYPE_CHECKING, Dict, List, Tuple - -from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger -from ansys.visor.viewer.models.common.visor_variable_record import ( - VisorVariableRecord, - compose_variable_identifier, -) -from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState - -if TYPE_CHECKING: - from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry - -logger = VisorDefaultLogger(__name__) - -Range = Tuple[float, float] - - -def _widen(current: Range | None, other: Range | None) -> Range | None: - """Return the union of two ranges; ``None`` contributes nothing.""" - if other is None: - return current - other = (float(other[0]), float(other[1])) - if current is None: - return other - return (min(current[0], other[0]), max(current[1], other[1])) - - -def _follow_or_keep(previous_custom: Range, previous_default: Range, new_default: Range) -> Range: - """A custom slot equal to its previous default follows the new default; otherwise it is kept.""" - if tuple(previous_custom) == tuple(previous_default): - return new_default - return tuple(previous_custom) - - -def build_variable_records( - registry: "VisorDatasetRegistry", - previous: Dict[str, VisorVariableRecord], -) -> Dict[str, VisorVariableRecord]: - """Build the records from every dataset's variables, carrying custom ranges from ``previous``. - - 1. Union: each part carrying a variable joins that identifier's ``part_ids``. - 2. Widen: default ranges are the min of the mins and the max of the maxes. - 3. Custom: an existing id keeps an edited custom slot; an unedited slot (equal to the - previous default) follows the new default. A new id starts with custom equal to default. - - Returns a new dict of new records; ``previous`` is never mutated. - """ - parts: Dict[str, List[int]] = {} - identity: Dict[str, tuple] = {} - magnitude: Dict[str, Range | None] = {} - components: Dict[str, List[Range | None]] = {} - - for dataset in list(registry.datasets.values()): - for part_variables in dataset.list_variables(): - for variable in part_variables.variables: - n = int(variable.num_components) - variable_id = compose_variable_identifier(variable.type, variable.name, n) - if variable_id not in identity: - identity[variable_id] = (variable.name, variable.type, n) - parts[variable_id] = [] - magnitude[variable_id] = None - components[variable_id] = [None] * n - if part_variables.part_id not in parts[variable_id]: - parts[variable_id].append(part_variables.part_id) - - variable_magnitude = variable.magnitude_range - if variable_magnitude is None and n == 1 and variable.ranges: - variable_magnitude = variable.ranges[0] - magnitude[variable_id] = _widen(magnitude[variable_id], variable_magnitude) - - slots = components[variable_id] - for k in range(min(n, len(variable.ranges))): - slots[k] = _widen(slots[k], variable.ranges[k]) - - records: Dict[str, VisorVariableRecord] = {} - for variable_id, (name, variable_type, n) in identity.items(): - default_magnitude = magnitude[variable_id] or (0.0, 0.0) - default_ranges = [slot if slot is not None else (0.0, 0.0) for slot in components[variable_id]] - - custom_magnitude = default_magnitude - custom_ranges = list(default_ranges) - old = previous.get(variable_id) - if old is not None: - custom_magnitude = _follow_or_keep(old.magnitude_range, old.default_magnitude_range, default_magnitude) - custom_ranges = [ - _follow_or_keep(old.ranges[k], old.default_ranges[k], default_ranges[k]) - if k < len(old.ranges) and k < len(old.default_ranges) else default_ranges[k] - for k in range(n) - ] - - records[variable_id] = VisorVariableRecord( - id=variable_id, - array_name=name, - type=variable_type, - num_components=n, - part_ids=sorted(parts[variable_id]), - default_magnitude_range=default_magnitude, - default_ranges=default_ranges, - magnitude_range=custom_magnitude, - ranges=custom_ranges, - ) - return records - - -def overlay_persisted_ranges( - records: Dict[str, VisorVariableRecord], - persisted: Dict[str, VisorVariableState], -) -> Dict[str, VisorVariableRecord]: - """Overlay the file's custom ranges onto freshly built records. - - - A null or absent ``magnitudeRange`` falls back to the default (DEBUG). - - ``ranges`` whose length differs from ``num_components`` fall back to the default (DEBUG). - - A file id with no record is dropped (WARNING). - - Returns a new dict; ``records`` is never mutated. - """ - result = dict(records) - for variable_id, entry in (persisted or {}).items(): - record = result.get(variable_id) - if record is None: - logger.warning("persisted variable %s has no record in the scene; dropped", variable_id) - continue - - magnitude = entry.magnitude_range - if magnitude is None: - logger.debug("persisted variable %s has no magnitudeRange; default used", variable_id) - magnitude = record.default_magnitude_range - - ranges = entry.ranges - if ranges is None or len(ranges) != record.num_components: - logger.debug( - "persisted variable %s has %s ranges for %d components; default used", - variable_id, None if ranges is None else len(ranges), record.num_components, - ) - ranges = record.default_ranges - - result[variable_id] = record.model_copy( - update={"magnitude_range": tuple(magnitude), "ranges": [tuple(r) for r in ranges]}, - deep=True, - ) - return result - - -def resolve_record_range(record: VisorVariableRecord, component: int) -> Range | None: - """Return the effective range for a component slot: ``-1`` is magnitude, ``0..n-1`` a component.""" - if component == -1: - return record.magnitude_range - if 0 <= component < record.num_components and component < len(record.ranges): - return record.ranges[component] - return None - diff --git a/tests/unit/vtk/variables/test_visor_variable_aggregate.py b/tests/unit/models/test_visor_variable_record_build.py similarity index 78% rename from tests/unit/vtk/variables/test_visor_variable_aggregate.py rename to tests/unit/models/test_visor_variable_record_build.py index 9d2b214f..81142253 100644 --- a/tests/unit/vtk/variables/test_visor_variable_aggregate.py +++ b/tests/unit/models/test_visor_variable_record_build.py @@ -5,7 +5,7 @@ """ from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables -from ansys.visor.viewer.vtk.variables.visor_variable_aggregate import build_variable_records +from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecords from ansys.visor.viewer.vtk.variables.visor_variables import VisorVariable POINT = VisorVtkVariableType.POINT @@ -43,7 +43,7 @@ def _two_datasets_sharing_pressure(): def test_participation_is_the_union_of_parts_across_datasets(): """#5: one record for the shared variable, naming both parts.""" - records = build_variable_records(_two_datasets_sharing_pressure(), {}) + records = VisorVariableRecords.from_registry(_two_datasets_sharing_pressure(), VisorVariableRecords()).variables assert list(records) == ["POINT::pressure::1"] assert records["POINT::pressure::1"].part_ids == [1, 3] @@ -51,7 +51,8 @@ def test_participation_is_the_union_of_parts_across_datasets(): def test_default_ranges_widen_to_the_min_of_mins_and_max_of_maxes(): """#6: (0.0, 10.0) and (-5.0, 4.0) widen to (-5.0, 10.0); a new id's custom equals its default.""" - record = build_variable_records(_two_datasets_sharing_pressure(), {})["POINT::pressure::1"] + record = VisorVariableRecords.from_registry( + _two_datasets_sharing_pressure(), VisorVariableRecords()).variables["POINT::pressure::1"] assert record.default_magnitude_range == (-5.0, 10.0) assert record.default_ranges == [(-5.0, 10.0)] @@ -68,7 +69,7 @@ def test_a_width_split_produces_distinct_ids(): }) ) - records = build_variable_records(registry, {}) + records = VisorVariableRecords.from_registry(registry, VisorVariableRecords()).variables assert sorted(records) == ["POINT::velocity::1", "POINT::velocity::3"] assert records["POINT::velocity::3"].part_ids == [1] @@ -82,12 +83,13 @@ def _one_dataset_pressure(): def test_an_edited_custom_range_is_kept_across_widening(): """#8: a custom slot that differs from its previous default survives the rebuild.""" - previous = build_variable_records(_one_dataset_pressure(), {}) + previous = VisorVariableRecords.from_registry(_one_dataset_pressure(), VisorVariableRecords()).variables previous["POINT::pressure::1"] = previous["POINT::pressure::1"].model_copy( update={"magnitude_range": (2.0, 3.0), "ranges": [(2.0, 3.0)]} ) - record = build_variable_records(_two_datasets_sharing_pressure(), previous)["POINT::pressure::1"] + record = VisorVariableRecords.from_registry( + _two_datasets_sharing_pressure(), VisorVariableRecords(variables=previous)).variables["POINT::pressure::1"] assert record.default_magnitude_range == (-5.0, 10.0) assert record.magnitude_range == (2.0, 3.0) @@ -96,10 +98,11 @@ def test_an_edited_custom_range_is_kept_across_widening(): def test_an_unedited_custom_range_follows_the_new_default(): """#9: a custom slot equal to its previous default (0.0, 10.0) follows the widening to (-5.0, 10.0).""" - previous = build_variable_records(_one_dataset_pressure(), {}) + previous = VisorVariableRecords.from_registry(_one_dataset_pressure(), VisorVariableRecords()).variables assert previous["POINT::pressure::1"].magnitude_range == (0.0, 10.0) - record = build_variable_records(_two_datasets_sharing_pressure(), previous)["POINT::pressure::1"] + record = VisorVariableRecords.from_registry( + _two_datasets_sharing_pressure(), VisorVariableRecords(variables=previous)).variables["POINT::pressure::1"] assert record.magnitude_range == (-5.0, 10.0) assert record.ranges == [(-5.0, 10.0)] diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index fe66afe8..8e862f5f 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -3283,7 +3283,7 @@ def test_get_state_hands_out_a_copy_of_the_ui_record(scene): # magnitude (0.0, 49.0) and component (10.0, 20.0) on NODE_ID. # =========================================================================== -AGGREGATE_LOGGER = "ansys.visor.viewer.vtk.variables.visor_variable_aggregate.logger" +RECORD_LOGGER = "ansys.visor.viewer.models.common.visor_variable_record.logger" def _pressure_record(**overrides): @@ -3351,7 +3351,7 @@ def test_rebuild_at_remove_drops_ids_with_no_remaining_part(records_scene): 2: _make_part_dataset(2, [SECOND_NODE_ID], part_variables=[ VisorPartVariables(SECOND_NODE_ID, "part", [_temperature_variable()])]), } - records_scene._rebuild_variable_records("add") + records_scene._rebuild_variable_records_from_registry() assert sorted(records_scene._variable_records.variables) == ["CELL::temperature::1", VARIABLE_ID] records_scene.remove_dataset(2) @@ -3365,7 +3365,7 @@ def test_rebuild_at_clear_empties_the_records(records_scene): 1: _make_part_dataset(1, [NODE_ID], part_variables=[ VisorPartVariables(NODE_ID, "part", [_pressure_variable()])]), } - records_scene._rebuild_variable_records("add") + records_scene._rebuild_variable_records_from_registry() holder = records_scene._variable_records records_scene.clear() @@ -3380,13 +3380,13 @@ def test_rebuild_at_update_follows_the_reload_and_the_metadata_refresh(records_s Call-order pin: registry.update_variables (which reloads the part variables), then refresh_descendant_variable_metadata, then the rebuild. """ - from ansys.visor.viewer.vtk.scene import base as base_module + from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecords registry = records_scene._dataset_registry dataset = _make_part_dataset(1, [NODE_ID], part_variables=[ VisorPartVariables(NODE_ID, "part", [_pressure_variable()])]) registry.datasets = {1: dataset} - records_scene._rebuild_variable_records("add") + records_scene._rebuild_variable_records_from_registry() wide_pressure = VisorVariable( index=0, type=VisorVtkVariableType.POINT, name="pressure", num_components=3, @@ -3400,7 +3400,7 @@ def _update_variables(dataset_id, variables): node = records_scene._scene_graph.get_descendant_node.return_value node.refresh_descendant_variable_metadata.side_effect = lambda **_: order.append("refresh") - real_build = base_module.build_variable_records + real_build = VisorVariableRecords.from_registry def _build(*args): order.append("rebuild") @@ -3408,7 +3408,7 @@ def _build(*args): with ( patch.object(registry, "update_variables", side_effect=_update_variables), - patch.object(base_module, "build_variable_records", side_effect=_build), + patch.object(VisorVariableRecords, "from_registry", side_effect=_build), ): records_scene.update_variables_for_dataset(1, []) @@ -3432,13 +3432,13 @@ def test_load_overlays_the_file_range_before_the_part_restore(scene, registry): _restore_part_states -- asserted by recorded order and by the entry the restore sees at the moment it is called. """ - from ansys.visor.viewer.vtk.scene import base as base_module + from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecords _seed_part_variables(registry, [_pressure_variable()]) order = [] seen_by_restore = {} - real_build = base_module.build_variable_records - real_overlay = base_module.overlay_persisted_ranges + real_build = VisorVariableRecords.from_registry + real_overlay = VisorVariableRecords.overlay def _build(*args): order.append("rebuild") @@ -3453,8 +3453,8 @@ def _restore(runtime_app_state): seen_by_restore["entry"] = runtime_app_state.scene.variable_states.get(VARIABLE_ID) with ( - patch.object(base_module, "build_variable_records", side_effect=_build), - patch.object(base_module, "overlay_persisted_ranges", side_effect=_overlay), + patch.object(VisorVariableRecords, "from_registry", side_effect=_build), + patch.object(VisorVariableRecords, "overlay", autospec=True, side_effect=_overlay), patch.object(scene, "_restore_part_states", side_effect=_restore), ): _apply( @@ -3473,7 +3473,7 @@ def test_load_fills_a_null_magnitude_range_with_the_default(scene, registry): """#15: a null magnitudeRange in the file loads at the default, logged at DEBUG.""" _seed_part_variables(registry, [_pressure_variable()]) - with patch(AGGREGATE_LOGGER) as mock_logger: + with patch(RECORD_LOGGER) as mock_logger: _apply(scene, _runtime_state({}), _file_entries(magnitude_range=None, ranges=((3.0, 4.0),))) assert scene._variable_records.variables == { @@ -3491,7 +3491,7 @@ def test_load_drops_a_file_id_with_no_record(scene, registry): num_components=1, magnitude_range=(5.0, 6.0), ranges=[(5.0, 6.0)], ) - with patch(AGGREGATE_LOGGER) as mock_logger: + with patch(RECORD_LOGGER) as mock_logger: _apply(scene, _runtime_state({}), {"POINT::ghost::1": ghost}) assert scene._variable_records.variables == {VARIABLE_ID: _pressure_record()} @@ -3538,7 +3538,7 @@ def _contradictory_reply(): def test_get_state_takes_the_variable_states_from_the_server(scene, registry): """#19: the save receives the server's record, whatever the browser replied.""" _seed_part_variables(registry, [_pressure_variable()]) - scene._rebuild_variable_records("add") + scene._rebuild_variable_records_from_registry() captured = _capture_persist_input(scene, _contradictory_reply()) asyncio.run(scene.get_state(timeout=1.0)) diff --git a/tests/unit/vtk/test_wire_format_identity.py b/tests/unit/vtk/test_wire_format_identity.py index 886ccc42..699eec39 100644 --- a/tests/unit/vtk/test_wire_format_identity.py +++ b/tests/unit/vtk/test_wire_format_identity.py @@ -396,7 +396,7 @@ def _scene_with_two_datasets_sharing_pressure(): }), 2: _VariablesOnlyDataset(2, {21: [_variable("pressure", [(-5.0, 4.0)], (-5.0, 4.0))]}), } - scene._rebuild_variable_records("add") + scene._rebuild_variable_records_from_registry() return scene From 59d564be15410a8084d383bb78d43fd559a20289 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 29 Sep 2026 10:32:50 -0700 Subject: [PATCH 03/12] feat: clean up server side variable aggregation --- .../viewer/models/common/visor_variable_record.py | 14 ++++++++++++-- src/ansys/visor/viewer/vtk/scene/base.py | 6 +----- tests/unit/vtk/scene/test_base.py | 4 ++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/ansys/visor/viewer/models/common/visor_variable_record.py b/src/ansys/visor/viewer/models/common/visor_variable_record.py index 877a1dc4..ec5f12a4 100644 --- a/src/ansys/visor/viewer/models/common/visor_variable_record.py +++ b/src/ansys/visor/viewer/models/common/visor_variable_record.py @@ -195,8 +195,18 @@ def from_registry( variables[variable_id] = accumulator.to_record(previous.variables.get(variable_id)) return cls(variables=variables) - def overlay(self, file_states: Dict[str, VisorVariableState]) -> "VisorVariableRecords": - """Apply a file's ranges to this fresh build by the D3 load rule and return this holder. + @classmethod + def from_file( + cls, + registry: "VisorDatasetRegistry", + file_states: Dict[str, VisorVariableState], + ) -> "VisorVariableRecords": + """Build a fresh holder from the registry and apply the file's ranges by the D3 load rule.""" + return cls.from_registry(registry, VisorVariableRecords())._overlay(file_states) + + def _overlay(self, file_states: Dict[str, VisorVariableState]) -> "VisorVariableRecords": + """Apply a file's ranges to this fresh build by the D3 load rule and return this holder; called only by + from_file. - A null or absent ``magnitudeRange`` falls back to the default (DEBUG). - ``ranges`` whose length differs from ``num_components`` fall back to the default (DEBUG). diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index a8b9abb2..77acc9b5 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -568,11 +568,7 @@ def _load_variable_records(self, file_states: Dict[str, VisorVariableState]) -> mutates the live dict or its entries. """ with self._vtk_lock: - records = ( - VisorVariableRecords.from_registry(self._dataset_registry, VisorVariableRecords()) - .overlay(file_states) - .variables - ) + records = VisorVariableRecords.from_file(self._dataset_registry, file_states).variables self._variable_records.variables = records logger.debug("variable records loaded from file: %d records", len(records)) diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 8e862f5f..ecba9293 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -3438,7 +3438,7 @@ def test_load_overlays_the_file_range_before_the_part_restore(scene, registry): order = [] seen_by_restore = {} real_build = VisorVariableRecords.from_registry - real_overlay = VisorVariableRecords.overlay + real_overlay = VisorVariableRecords._overlay def _build(*args): order.append("rebuild") @@ -3454,7 +3454,7 @@ def _restore(runtime_app_state): with ( patch.object(VisorVariableRecords, "from_registry", side_effect=_build), - patch.object(VisorVariableRecords, "overlay", autospec=True, side_effect=_overlay), + patch.object(VisorVariableRecords, "_overlay", autospec=True, side_effect=_overlay), patch.object(scene, "_restore_part_states", side_effect=_restore), ): _apply( From 380aec5fa10b3ede4c517a09f83012a91aceebcd Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 29 Sep 2026 12:52:19 -0700 Subject: [PATCH 04/12] feat: add set_variable_range trigger and width check --- src/ansys/visor/viewer/app/trame/local_app.py | 31 +- .../models/common/visor_variable_record.py | 15 + .../requests/variable_range_payload.py | 22 ++ src/ansys/visor/viewer/renderer/base.py | 11 + .../visor/viewer/renderer/local_renderer.py | 24 ++ .../visor/viewer/renderer/null_renderer.py | 6 + src/ansys/visor/viewer/vtk/scene/base.py | 288 ++++++++++-------- tests/unit/app/test_local_app.py | 47 ++- tests/unit/vtk/scene/test_base.py | 246 ++++++++++++++- 9 files changed, 544 insertions(+), 146 deletions(-) create mode 100644 src/ansys/visor/viewer/models/runtime/requests/variable_range_payload.py diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index ce7e7f00..7f1f1885 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -13,6 +13,7 @@ from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.models.runtime.requests.sync_camera_payload import SyncCameraPayload +from ansys.visor.viewer.models.runtime.requests.variable_range_payload import SetVariableRangePayload from ansys.visor.viewer.models.runtime.requests.widget_state_payloads import ( SetBoundingBoxVisibilityPayload, SetCrossSectionVisibilityPayload, @@ -60,9 +61,11 @@ def set_part_color_variable( association: VisorVtkVariableType, array_name: str, component: int, - min_val: float, - max_val: float, - ) -> None: ... + ) -> bool: ... + + def set_variable_range( + self, variable_id: str, component: int, min_val: float, max_val: float + ) -> bool: ... def clear_part_color_variable(self, node_id: int) -> None: ... @@ -215,6 +218,7 @@ class LocalApp: set_part_selected: selects or deselects one part set_part_color_variable: colours one part by a scalar variable clear_part_color_variable: stops colouring one part by a scalar variable + set_variable_range: stores one variable slot's effective range sync_camera: records a settled camera reported by the frontend set_cross_section_visibility: shows or hides the cross-section plane set_edges_visible: shows or hides edges on every part @@ -444,6 +448,10 @@ def set_part_color_variable(self, payload) -> None: no-op, matching the posture the pipeline takes on an unknown array 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. """ api = self._mutation_api("set_part_color_variable", payload) if api is None: @@ -454,8 +462,6 @@ def set_part_color_variable(self, payload) -> None: payload.association, payload.array_name, payload.component, - payload.min_val, - payload.max_val, ) @trigger("clear_part_color_variable") @@ -467,6 +473,21 @@ def clear_part_color_variable(self, payload) -> None: return api.clear_part_color_variable(payload.node_id) + @trigger("set_variable_range") + @parse_payload(SetVariableRangePayload) + def set_variable_range(self, payload) -> None: + """Frontend -> Backend: store one variable slot's effective range. + + Scene-wide, not per-part: the coordinator applies the range to every + part whose reference names that slot. + """ + api = self._mutation_api("set_variable_range", payload) + if api is None: + return + api.set_variable_range( + payload.variable_id, payload.component, payload.min_val, payload.max_val + ) + # ------------------------------------------------------------------ # Camera trigger # diff --git a/src/ansys/visor/viewer/models/common/visor_variable_record.py b/src/ansys/visor/viewer/models/common/visor_variable_record.py index ec5f12a4..57f0e69b 100644 --- a/src/ansys/visor/viewer/models/common/visor_variable_record.py +++ b/src/ansys/visor/viewer/models/common/visor_variable_record.py @@ -76,6 +76,21 @@ def range_for(self, component: int) -> Range | None: return self.ranges[component] return None + def with_range(self, component: int, value_range: Range) -> "VisorVariableRecord": + """Return a copy with one effective slot replaced: ``-1`` is magnitude, ``0..n-1`` a component. + + The caller validates *component*; a slot outside ``[-1, num_components)`` raises ``IndexError``. + This record is never mutated. + """ + new_range = (float(value_range[0]), float(value_range[1])) + if component == -1: + return self.model_copy(update={"magnitude_range": new_range}, deep=True) + if not 0 <= component < len(self.ranges): + raise IndexError(f"component {component} is outside [-1, {len(self.ranges)})") + ranges = [tuple(r) for r in self.ranges] + ranges[component] = new_range + return self.model_copy(update={"ranges": ranges}, deep=True) + @dataclass class _VariableAccumulator: diff --git a/src/ansys/visor/viewer/models/runtime/requests/variable_range_payload.py b/src/ansys/visor/viewer/models/runtime/requests/variable_range_payload.py new file mode 100644 index 00000000..3f8dfd91 --- /dev/null +++ b/src/ansys/visor/viewer/models/runtime/requests/variable_range_payload.py @@ -0,0 +1,22 @@ +"""Model for the ``set_variable_range`` trigger payload.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class SetVariableRangePayload(BaseModel): + """ + Payload of the ``set_variable_range`` trigger. + + Scene-wide: it names a variable and one of its slots, not a part. + ``component`` uses the server convention, ``-1`` for magnitude and + ``0..n-1`` for a component. Finiteness and ``min <= max`` are checked by + the coordinator, which refuses with a WARNING, not here. + """ + + model_config = ConfigDict(populate_by_name=True) + + variable_id: str = Field(alias="variableId") + component: int + min_val: float = Field(alias="min") + max_val: float = Field(alias="max") + diff --git a/src/ansys/visor/viewer/renderer/base.py b/src/ansys/visor/viewer/renderer/base.py index 27d7096f..a8aafa48 100644 --- a/src/ansys/visor/viewer/renderer/base.py +++ b/src/ansys/visor/viewer/renderer/base.py @@ -155,6 +155,17 @@ def apply_color_variable( def clear_color_variable(self, node_id: int) -> None: """Disable scalar colouring on *node_id*, reverting to solid diffuse.""" + @abstractmethod + def serialize_part_state(self, node_id: int) -> None: + """Make the state served to the client current for one part's mapper. + + A mapper write without this leaves the served cache holding the old + content under a new version number, so the next client fetch gets the + pre-write range. **Serialize only; do not notify.** An unknown + *node_id* is a logged no-op. No-op on a renderer that serves the + client no VTK object state. + """ + @abstractmethod def refresh_color_variable_range( self, diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index 07986440..3e8fb609 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -270,6 +270,30 @@ def clear_color_variable(self, node_id: int) -> None: return pipe.clear_color_variable() + def serialize_part_state(self, node_id: int) -> None: + """See :meth:`IRenderer.serialize_part_state`. + + Names the mapper's id alone, derived with ``GetId`` on the object as + :meth:`serialize_camera_state` derives the camera's. An id of ``0`` + is one the store has never held (the ROOT sentinel); naming it + degrades to an error-logged no-op in VTK, so it is skipped here with + a debug line and the next full serialization carries the mapper. + """ + pipe = self._pipelines.get(node_id) + if pipe is None: + logger.debug( + "serialize_part_state: no pipeline for node %s; skipping.", node_id + ) + return + mapper_id = self._object_manager.GetId(pipe.mapper) + if mapper_id == 0: + logger.debug( + "serialize_part_state: mapper of node %s is not registered yet; skipping.", + node_id, + ) + return + self._object_manager.UpdateStateFromObject(mapper_id) + def refresh_color_variable_range( self, node_id: int, diff --git a/src/ansys/visor/viewer/renderer/null_renderer.py b/src/ansys/visor/viewer/renderer/null_renderer.py index 20eaeba7..6dd5e51b 100644 --- a/src/ansys/visor/viewer/renderer/null_renderer.py +++ b/src/ansys/visor/viewer/renderer/null_renderer.py @@ -101,6 +101,12 @@ def apply_color_variable( def clear_color_variable(self, node_id: int) -> None: pass + def serialize_part_state(self, node_id: int) -> None: + """See :meth:`IRenderer.serialize_part_state`. + + No-op: this renderer serves the client no VTK object state. + """ + def refresh_color_variable_range( self, node_id: int, diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 77acc9b5..30a80502 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -1,8 +1,10 @@ """VTK scene management for Visor Viewer.""" import json +import math import threading from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import TYPE_CHECKING, Dict, List from trame_server import Server @@ -15,9 +17,13 @@ from ansys.visor.viewer.core.visor_types import VisorDatasetType from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState -from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecords +from ansys.visor.viewer.models.common.visor_variable_record import ( + VisorVariableRecord, + VisorVariableRecords, +) from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 +from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimePartProperties from ansys.visor.viewer.models.runtime.visor_scene_details import VisorSceneDetails from ansys.visor.viewer.renderer.base import IRenderer from ansys.visor.viewer.vtk.datasets.visor_dataset import VisorDataset @@ -33,6 +39,35 @@ logger = VisorDefaultLogger(__name__) + +@dataclass(frozen=True) +class _ColorVariableBinding: + """A part's resolved color-variable reference: the record, the slot, and that slot's effective range.""" + + part_id: int + record: VisorVariableRecord + component: int + min_val: float + max_val: float + + def matches(self, association: VisorVtkVariableType, array_name: str) -> bool: + """Return whether *association* and *array_name* name this binding's array.""" + return association is self.record.type and array_name == self.record.array_name + + def apply_to(self, renderer: IRenderer) -> None: + """Configure the part's mapper at this range and re-serialize it; caller holds ``_vtk_lock``.""" + renderer.apply_color_variable( + self.part_id, + self.record.id, + self.record.type, + self.record.array_name, + self.component, + self.min_val, + self.max_val, + ) + renderer.serialize_part_state(self.part_id) + + class VisorSceneBase(ABC): """ Abstract coordinator for a Visor viewer scene. @@ -298,8 +333,8 @@ def apply_state(self, state: PersistedViewerStateV1): runtime_app_state = self._state_mapper.persisted_to_runtime(state) # Variable records: rebuilt from the registry and overlaid with the file's - # ranges, then placed on the runtime state (CC-1), all before - # _restore_part_states, which reads each colored part's range from that dict. + # ranges, then placed on the runtime state for the push, all before + # _restore_part_states, which resolves each colored part against the held record. self._load_variable_records(state.scene.variable_states) runtime_app_state.scene.variable_states = self._variable_records.model_copy(deep=True).variables @@ -743,26 +778,128 @@ def set_part_color_variable( association: VisorVtkVariableType, array_name: str, component: int, - min_val: float, - max_val: float, - ) -> None: + ) -> bool: """ - Colour the part identified by *node_id* by a scalar variable. - - *variable_id* is stored opaquely and is never parsed here; the - association and array name arrive as explicit arguments. *association* - is already a :class:`VisorVtkVariableType` — it is parsed at the - trigger boundary, never derived from a string here. The range travels - as a parameter only and is not persisted per part. + Colour one part at the record's effective range; False, writing nothing, when refused. + + *variable_id* is stored opaquely and is never parsed here. It is + resolved against the held records: the part must participate in it + and *component* must name one of its slots. *association* and + *array_name* must name the record's array. The range applied is the + record's, never the caller's. A refusal logs one WARNING and leaves + both the registry and the mapper untouched. """ with self._vtk_lock: + binding = self._resolve_color_variable(node_id, variable_id, component) + if binding is None: + return False + if not binding.matches(association, array_name): + logger.warning( + "set_part_color_variable: %s '%s' does not name the array of '%s' " + "(part %s); refused.", association, array_name, variable_id, node_id + ) + return False if not self._dataset_registry.set_part_color_variable(node_id, variable_id, component): logger.debug("set_part_color_variable: no dataset owns node %s; skipping.", node_id) - return - self._renderer.apply_color_variable( - node_id, variable_id, association, array_name, component, min_val, max_val + return False + binding.apply_to(self._renderer) + return True + + def set_variable_range( + self, variable_id: str, component: int, min_val: float, max_val: float + ) -> bool: + """ + Store one slot's effective range and apply it to every part referencing that slot; False, writing + nothing, when refused. + + Refused, with a WARNING, for an unknown id, a component outside + ``[-1, num_components)``, a non-finite value, or ``min > max``. The + entry is replaced by copy-on-write and ``variables`` is rebound, so + the unlocked reader never sees the live dict change. Each applied + mapper is re-serialized inside the lock. Nothing is pushed: the + client applied the range before it sent. + """ + with self._vtk_lock: + record = self._variable_records.variables.get(variable_id) + if record is None: + logger.warning("set_variable_range: no record for '%s'; refused.", variable_id) + return False + if not -1 <= component < record.num_components: + logger.warning( + "set_variable_range: component %s is outside [-1, %s) for '%s'; refused.", + component, record.num_components, variable_id + ) + return False + if not (math.isfinite(min_val) and math.isfinite(max_val)): + logger.warning( + "set_variable_range: non-finite range [%s, %s] for '%s'; refused.", + min_val, max_val, variable_id + ) + return False + if min_val > max_val: + logger.warning( + "set_variable_range: min %s is above max %s for '%s'; refused.", + min_val, max_val, variable_id + ) + return False + + variables = dict(self._variable_records.variables) + variables[variable_id] = record.with_range(component, (min_val, max_val)) + self._variable_records.variables = variables + logger.debug( + "variable range stored: %s component %d [%g, %g]", + variable_id, component, min_val, max_val ) + for part_id in variables[variable_id].part_ids: + part_state = self._dataset_registry.get_part_state(part_id) + if part_state is None: + continue + if part_state.variable_id != variable_id or part_state.variable_component != component: + continue + binding = self._resolve_color_variable(part_id, variable_id, component) + if binding is not None: + binding.apply_to(self._renderer) + return True + + def _resolve_color_variable( + self, part_id: int, variable_id: str, component: int + ) -> _ColorVariableBinding | None: + """ + Resolve a part's reference against the held records; None, with one WARNING, when refused. + + Refused when the id has no record, the part does not participate in + it, or *component* names no slot. The id encodes association, name + and width, so a same-named array of another width is another record + and fails participation. Caller holds ``_vtk_lock``. + """ + record = self._variable_records.variables.get(variable_id) + if record is None: + logger.warning( + "_resolve_color_variable: no record for '%s' (part %s); refused.", variable_id, part_id + ) + return None + if part_id not in record.part_ids: + logger.warning( + "_resolve_color_variable: part %s does not participate in '%s'; refused.", + part_id, variable_id + ) + return None + value_range = record.range_for(component) + if value_range is None: + logger.warning( + "_resolve_color_variable: component %s names no slot of '%s' (%s components, part %s); " + "refused.", component, variable_id, record.num_components, part_id + ) + return None + return _ColorVariableBinding( + part_id=part_id, + record=record, + component=component, + min_val=value_range[0], + max_val=value_range[1], + ) + def clear_part_color_variable(self, node_id: int) -> None: """ Stop colouring the part identified by *node_id* by a scalar variable. @@ -893,7 +1030,6 @@ def _restore_part_states(self, runtime_app_state: "RuntimeAppState") -> None: Callers must hold ``_vtk_lock``. """ dataset_states = runtime_app_state.scene.dataset_states or {} - variable_states = runtime_app_state.scene.variable_states or {} self._dataset_registry.replace_part_states(dataset_states) @@ -906,16 +1042,8 @@ def _restore_part_states(self, runtime_app_state: "RuntimeAppState") -> None: ) continue - # Per-part variable metadata, keyed by part id: one entry per - # non-empty leaf. Built once per dataset rather than per part. - variables_by_part = { - entry.part_id: entry.variables for entry in dataset.list_variables() - } - for part_id, part_state in dataset_state.part_states.items(): - self._restore_one_part_state( - part_id, part_state, variable_states, variables_by_part.get(part_id) - ) + self._restore_one_part_state(part_id, part_state) def _restore_camera_state(self, runtime_app_state: "RuntimeAppState") -> None: """ @@ -999,13 +1127,7 @@ def _restore_ui_state(self, runtime_app_state: "RuntimeAppState") -> None: if ui.panel_top_right_tab_index is not None: self._ui_state.panel_top_right_tab_index = ui.panel_top_right_tab_index - def _restore_one_part_state( - self, - part_id: int, - part_state, - variable_states: dict, - part_variables, - ) -> None: + def _restore_one_part_state(self, part_id: int, part_state: RuntimePartProperties) -> None: """ Apply one restored part record to the pipeline. @@ -1044,28 +1166,19 @@ def _restore_one_part_state( ) self._renderer.apply_selected(part_id, part_state.selected, selection_rgb) - self._restore_part_color_variable(part_id, part_state, variable_states, part_variables) + self._restore_part_color_variable(part_id, part_state) - def _restore_part_color_variable( - self, - part_id: int, - part_state, - variable_states: dict, - part_variables, - ) -> None: + def _restore_part_color_variable(self, part_id: int, part_state: RuntimePartProperties) -> None: """ - Restore one part's color-variable reference, or clear it. + Restore one part's color-variable reference through the resolve helper, or clear it. The reference is a compound value, set and cleared as a unit, so if either the identifier or component is missing, it is a logged no-op. - A stored component of ``-1`` reads ``magnitude_range``; 0 or greater - reads ``ranges[component]``. Any other value is refused. - - The array is resolved against the server's per-part variable metadata, - and its width is checked against the stored component count: two parts - can carry same-named arrays of different widths, which the application - treats as different quantities. + Otherwise the reference resolves against the held records, which the + load path has rebuilt and overlaid with the file's ranges before this + runs. A refused reference is a logged no-op; the mapper is re-serialized + after an applied one. """ variable_id = part_state.variable_id component = part_state.variable_component @@ -1087,81 +1200,10 @@ def _restore_part_color_variable( ) return - variable_state = variable_states.get(variable_id) - if variable_state is None: - logger.warning( - "_restore_part_color_variable: no variable entry for '%s' (part %s); skipping.", - variable_id, part_id - ) - return - - if component == -1: - value_range = variable_state.magnitude_range - elif component >= 0: - if component >= len(variable_state.ranges): - logger.warning( - "_restore_part_color_variable: component %s is outside the %s stored ranges " - "for '%s' (part %s); skipping.", - component, len(variable_state.ranges), variable_id, part_id - ) - return - value_range = variable_state.ranges[component] - else: - logger.warning( - "_restore_part_color_variable: component %s for '%s' (part %s) is neither the " - "magnitude sentinel (-1) nor a component index; skipping.", - component, variable_id, part_id - ) + binding = self._resolve_color_variable(part_id, variable_id, component) + if binding is None: return - - if value_range is None: - logger.warning( - "_restore_part_color_variable: no stored range for component %s of '%s' " - "(part %s); skipping.", component, variable_id, part_id - ) - return - - if part_variables is None: - logger.warning( - "_restore_part_color_variable: no variable metadata for part %s; skipping.", - part_id - ) - return - - resolved = next( - ( - variable for variable in part_variables - if variable.name == variable_state.array_name - and variable.type is variable_state.type - ), - None, - ) - if resolved is None: - logger.warning( - "_restore_part_color_variable: array '%s' (%s) not found on part %s; skipping.", - variable_state.array_name, variable_state.type, part_id - ) - return - - if resolved.num_components != variable_state.num_components: - logger.warning( - "_restore_part_color_variable: array '%s' on part %s has %s components, the " - "stored variable has %s; the part does not participate in this variable.", - variable_state.array_name, part_id, - resolved.num_components, variable_state.num_components - ) - return - - min_val, max_val = value_range - self._renderer.apply_color_variable( - part_id, - variable_id, - variable_state.type, - variable_state.array_name, - component, - min_val, - max_val, - ) + binding.apply_to(self._renderer) # ------------------------------------------------------------------ # Internal helpers diff --git a/tests/unit/app/test_local_app.py b/tests/unit/app/test_local_app.py index abf9fb2b..a36ddbf0 100644 --- a/tests/unit/app/test_local_app.py +++ b/tests/unit/app/test_local_app.py @@ -36,6 +36,7 @@ from ansys.visor.viewer.app.trame.local_app import LocalApp from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.models.runtime.requests.variable_range_payload import SetVariableRangePayload TRIGGER_NAMES = [ "set_part_visibility", @@ -172,7 +173,11 @@ 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 reaches the coordinator in contract order.""" + """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. + """ app.set_part_color_variable( { "nodeId": 7, @@ -186,7 +191,7 @@ def test_set_part_color_variable_delegates_payload_values(app, api): ) api.set_part_color_variable.assert_called_once_with( - 7, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 + 7, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0 ) @@ -454,3 +459,41 @@ def test_missing_coordinator_logs_debug_and_not_warning(app_without_api, name): assert mock_logger.warning.call_count == 0 +# =========================================================================== +# set_variable_range +# =========================================================================== + +def test_set_variable_range_payload_parses_wire_aliases(): + """variableId, min and max arrive under their snake_case fields.""" + parsed = SetVariableRangePayload.model_validate( + {"variableId": "POINT::pressure::1", "component": -1, "min": 2.5, "max": 7.5} + ) + + assert parsed.variable_id == "POINT::pressure::1" + assert parsed.component == -1 + assert parsed.min_val == 2.5 + assert parsed.max_val == 7.5 + + +def test_set_variable_range_registered_trigger_delegates_payload_values(app, api, mock_server): + """The registered callable parses the raw dict and forwards all four values.""" + registered = _registered_triggers(mock_server) + + registered["set_variable_range"]( + {"variableId": "POINT::pressure::1", "component": -1, "min": 2.5, "max": 7.5} + ) + + api.set_variable_range.assert_called_once_with("POINT::pressure::1", -1, 2.5, 7.5) + + +def test_set_variable_range_invalid_payload_is_a_logged_no_op(app, api): + """A payload with no max delegates nothing and warns once.""" + with patch("ansys.visor.viewer.app.trame.local_app.logger") as mock_logger: + assert app.set_variable_range( + {"variableId": "POINT::pressure::1", "component": 0, "min": 2.5} + ) is None + + api.set_variable_range.assert_not_called() + assert mock_logger.warning.call_count == 1 + + diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index ecba9293..7bdc7161 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -437,12 +437,48 @@ def test_set_part_selected_flushes_under_the_lock_after_the_apply(scene, pipelin # =========================================================================== # set_part_color_variable +# +# The range applied is the server record's, so each test seeds a literal +# record whose effective ranges differ from every default and from any range +# a client might send. # =========================================================================== +SEEDED_MAGNITUDE_RANGE = (1.5, 8.5) +SEEDED_COMPONENT_RANGE = (3.25, 6.75) +MAPPER_WASM_ID = 8150003 + + +def _seeded_record(**overrides): + """The "pressure" record on NODE_ID with custom ranges distinct from its defaults.""" + fields = dict(magnitude_range=SEEDED_MAGNITUDE_RANGE, ranges=[SEEDED_COMPONENT_RANGE]) + fields.update(overrides) + return _pressure_record(**fields) + + +def _seed_records(scene, *records): + """Install *records* as the scene's held variable records.""" + scene._variable_records.variables = {record.id: record for record in records} + + +def _temperature_record(): + """A cell-association record on NODE_ID for the fixture's "temperature" array.""" + return _pressure_record( + id="CELL::temperature::1", + array_name="temperature", + type=VisorVtkVariableType.CELL, + default_magnitude_range=(0.0, 95.0), + default_ranges=[(0.0, 95.0)], + magnitude_range=(0.0, 95.0), + ranges=[(0.0, 95.0)], + ) + + def test_set_part_color_variable_writes_the_registry_record(scene, registry): """Store half: variable id and component are recorded together.""" + _seed_records(scene, _seeded_record()) + scene.set_part_color_variable( - NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0 ) state = registry.get_part_state(NODE_ID) @@ -451,22 +487,26 @@ def test_set_part_color_variable_writes_the_registry_record(scene, registry): def test_set_part_color_variable_applies_to_the_vtk_mapper(scene, pipeline): - """Apply half: the mapper selects the array and honours the given range.""" + """Apply half: the mapper selects the array and honours the record's range.""" + _seed_records(scene, _seeded_record()) + scene.set_part_color_variable( - NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0 ) mapper = pipeline.mapper assert mapper.GetScalarVisibility() == 1 assert mapper.GetArrayName() == "pressure" assert mapper.GetArrayComponent() == 0 - assert mapper.GetScalarRange() == pytest.approx((0.0, 49.0)) + assert mapper.GetScalarRange() == pytest.approx((3.25, 6.75)) def test_set_part_color_variable_applies_a_cell_association(scene, pipeline): """Apply half, cell branch: the cell array is selected.""" + _seed_records(scene, _temperature_record()) + scene.set_part_color_variable( - NODE_ID, "CELL::temperature::1", VisorVtkVariableType.CELL, "temperature", 0, 0.0, 95.0 + NODE_ID, "CELL::temperature::1", VisorVtkVariableType.CELL, "temperature", 0 ) assert pipeline.mapper.GetArrayName() == "temperature" @@ -475,11 +515,12 @@ def test_set_part_color_variable_applies_a_cell_association(scene, pipeline): def test_set_part_color_variable_flushes_under_the_lock_after_the_apply(scene, pipeline): """Lock held, and the mapper already configured, when the flush runs.""" + _seed_records(scene, _seeded_record()) scene._vtk_lock = _LockSpy() record = _spy_flush(scene, lambda: pipeline.mapper.GetArrayName()) scene.set_part_color_variable( - NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0 ) # TODO: change "calls" to 1 and uncomment "depth" and "value" when we add round trips @@ -489,6 +530,187 @@ def test_set_part_color_variable_flushes_under_the_lock_after_the_apply(scene, p assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count +def test_set_part_color_variable_applies_the_record_range(scene, pipeline): + """The component slot's effective range comes from the record, not from any caller value.""" + _seed_records(scene, _seeded_record()) + + assert scene.set_part_color_variable( + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0 + ) is True + + assert pipeline.mapper.GetScalarRange() == pytest.approx((3.25, 6.75)) + + +def test_set_part_color_variable_non_participating_part_is_refused_and_not_stored( + scene, registry +): + """A part outside the record's part_ids is refused before the registry is written.""" + _seed_records(scene, _seeded_record(part_ids=[SECOND_NODE_ID])) + + result = scene.set_part_color_variable( + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0 + ) + + assert result is False + assert registry.get_part_state(NODE_ID) is None + + +def test_set_part_color_variable_reserializes_the_mapper_after_the_write_under_the_lock( + scene, pipeline +): + """The mapper is re-serialized by its own id, after the range is written, with the lock held.""" + _seed_records(scene, _seeded_record()) + scene._vtk_lock = _LockSpy() + object_manager = scene._renderer._object_manager + mapper = pipeline.mapper + other_ids = object_manager.GetId.side_effect + object_manager.GetId.side_effect = ( + lambda obj: MAPPER_WASM_ID if obj is mapper else other_ids(obj) + ) + serialized = [] + object_manager.UpdateStateFromObject.side_effect = lambda object_id: serialized.append( + (object_id, scene._vtk_lock.depth, tuple(mapper.GetScalarRange())) + ) + + scene.set_part_color_variable( + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0 + ) + + assert len(serialized) == 1 + object_id, depth, served_range = serialized[0] + assert object_id == 8150003 + assert depth >= 1 + assert served_range == pytest.approx((3.25, 6.75)) + + +# =========================================================================== +# _resolve_color_variable +# =========================================================================== + +def test_resolve_color_variable_magnitude_slot_returns_the_record_magnitude_range(scene): + """Component -1 resolves to the record's magnitude_range, for the part asked about.""" + _seed_records(scene, _seeded_record()) + + binding = scene._resolve_color_variable(NODE_ID, "POINT::pressure::1", -1) + + assert binding.part_id == 7 + assert (binding.min_val, binding.max_val) == (1.5, 8.5) + + +def test_resolve_color_variable_component_slot_returns_the_record_component_range(scene): + """Component 0 resolves to ranges[0], not the magnitude range.""" + _seed_records(scene, _seeded_record()) + + binding = scene._resolve_color_variable(NODE_ID, "POINT::pressure::1", 0) + + assert binding.component == 0 + assert (binding.min_val, binding.max_val) == (3.25, 6.75) + + +def test_resolve_color_variable_component_out_of_range_is_refused(scene): + """A component past the record's width names no slot.""" + _seed_records(scene, _seeded_record()) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + binding = scene._resolve_color_variable(NODE_ID, "POINT::pressure::1", 1) + + assert binding is None + assert mock_logger.warning.call_count == 1 + + +def test_resolve_color_variable_non_participating_part_is_refused(scene): + """A part not in part_ids is refused; a same-named array of another width lands here.""" + _seed_records(scene, _seeded_record(part_ids=[SECOND_NODE_ID])) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + binding = scene._resolve_color_variable(NODE_ID, "POINT::pressure::1", 0) + + assert binding is None + assert mock_logger.warning.call_count == 1 + + +def test_apply_state_resolves_through_the_color_variable_helper(scene, registry, pipeline): + """The load path resolves a colored part through the same helper the live trigger uses.""" + _seed_part_variables(registry, [_pressure_variable()]) + + with patch.object( + scene, "_resolve_color_variable", wraps=scene._resolve_color_variable + ) as spy: + _apply(scene, _color_variable_state(0), _file_entries()) + + spy.assert_called_once_with(7, "POINT::pressure::1", 0) + + +# =========================================================================== +# set_variable_range +# =========================================================================== + +def test_set_variable_range_stores_the_slot_by_copy_on_write_without_a_push(scene): + """The slot is replaced in a new dict; a reader's earlier dict is unchanged; nothing is pushed.""" + _seed_records(scene, _seeded_record()) + scene._push_runtime_state = MagicMock(name="_push_runtime_state") + before = scene._variable_records.variables + + assert scene.set_variable_range("POINT::pressure::1", 0, 3.0, 4.0) is True + + stored = scene._variable_records.variables["POINT::pressure::1"] + assert stored.ranges == [(3.0, 4.0)] + assert stored.magnitude_range == (1.5, 8.5) + assert scene._variable_records.variables is not before + assert before["POINT::pressure::1"].ranges == [(3.25, 6.75)] + scene._renderer.flush_wasm_state.assert_not_called() + scene._push_runtime_state.assert_not_called() + + +def test_set_variable_range_applies_to_parts_referencing_the_slot( + scene, registry, pipeline, renderer, array_dataset +): + """A part referencing the slot takes the range; one on another slot of the same id does not.""" + registry.datasets = {1: _make_part_dataset(1, [NODE_ID, SECOND_NODE_ID])} + second = VtkNodePipeline.from_dataset(array_dataset) + renderer._pipelines[SECOND_NODE_ID] = second + _seed_records(scene, _seeded_record(part_ids=[NODE_ID, SECOND_NODE_ID])) + registry.set_part_color_variable(NODE_ID, "POINT::pressure::1", 0) + registry.set_part_color_variable(SECOND_NODE_ID, "POINT::pressure::1", -1) + _seed_unconfigured_mapper(pipeline) + _seed_unconfigured_mapper(second) + + scene.set_variable_range("POINT::pressure::1", 0, 3.0, 4.0) + + assert pipeline.mapper.GetScalarVisibility() == 1 + assert pipeline.mapper.GetScalarRange() == pytest.approx((3.0, 4.0)) + assert second.mapper.GetScalarVisibility() == 0 + assert second.mapper.GetScalarRange() == pytest.approx((11.0, 22.0)) + + +REFUSED_RANGE_CALLS = { + "unknown_id": ("POINT::absent::1", 0, 3.0, 4.0), + "component_out_of_range": ("POINT::pressure::1", 1, 3.0, 4.0), + "min_above_max": ("POINT::pressure::1", 0, 4.0, 3.0), + "non_finite": ("POINT::pressure::1", 0, float("nan"), 4.0), +} + + +@pytest.mark.parametrize("case", list(REFUSED_RANGE_CALLS)) +def test_set_variable_range_refusal_writes_nothing_and_returns_false( + scene, registry, pipeline, case +): + """Refused: False, one WARNING, the held dict not rebound, the referencing mapper untouched.""" + _seed_records(scene, _seeded_record()) + registry.set_part_color_variable(NODE_ID, "POINT::pressure::1", 0) + _seed_unconfigured_mapper(pipeline) + before = scene._variable_records.variables + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + result = scene.set_variable_range(*REFUSED_RANGE_CALLS[case]) + + assert result is False + assert mock_logger.warning.call_count == 1 + assert scene._variable_records.variables is before + assert pipeline.mapper.GetScalarVisibility() == 0 + assert pipeline.mapper.GetScalarRange() == pytest.approx((11.0, 22.0)) + + # =========================================================================== # clear_part_color_variable # =========================================================================== @@ -506,8 +728,9 @@ def test_clear_part_color_variable_clears_the_registry_record(scene, registry): def test_clear_part_color_variable_disables_scalar_visibility_on_the_mapper(scene, pipeline): """Apply half: scalar colouring is off on the mapper.""" + _seed_records(scene, _seeded_record()) scene.set_part_color_variable( - NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0, 0.0, 49.0 + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0 ) scene.clear_part_color_variable(NODE_ID) @@ -538,15 +761,6 @@ def test_clear_part_color_variable_flushes_under_the_lock_after_the_apply(scene, "set_part_opacity": (UNKNOWN_NODE_ID, 0.25), "set_part_diffuse_color": (UNKNOWN_NODE_ID, [1.0, 0.0, 0.0]), "set_part_selected": (UNKNOWN_NODE_ID, True), - "set_part_color_variable": ( - UNKNOWN_NODE_ID, - "POINT::pressure::1", - VisorVtkVariableType.POINT, - "pressure", - 0, - 0.0, - 49.0, - ), "clear_part_color_variable": (UNKNOWN_NODE_ID,), } From 10d9f6634756be5ec1f7dca37116c82de654fcbd Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:14:34 +0000 Subject: [PATCH 05/12] chore: adding changelog file 152.added.md [dependabot-skip] --- doc/changelog.d/152.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/152.added.md diff --git a/doc/changelog.d/152.added.md b/doc/changelog.d/152.added.md new file mode 100644 index 00000000..8b1db4ea --- /dev/null +++ b/doc/changelog.d/152.added.md @@ -0,0 +1 @@ +[Remote rendering 3.5a] move variable ownership to server From 4275591b05119eb6734482410cefa6b8aad9809f Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 12:37:42 -0700 Subject: [PATCH 06/12] clean up docs --- .../models/common/visor_variable_record.py | 7 +++---- .../requests/visor_save_state_response.py | 1 - .../models/test_visor_save_state_response.py | 6 ++---- tests/unit/vtk/scene/test_base.py | 17 ++++++++--------- tests/unit/vtk/scene/test_visor_state_mapper.py | 2 +- tests/unit/vtk/test_wire_format_identity.py | 2 +- .../test_visor_variable_empty_block.py | 9 ++++----- 7 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/ansys/visor/viewer/models/common/visor_variable_record.py b/src/ansys/visor/viewer/models/common/visor_variable_record.py index 57f0e69b..afd14db7 100644 --- a/src/ansys/visor/viewer/models/common/visor_variable_record.py +++ b/src/ansys/visor/viewer/models/common/visor_variable_record.py @@ -143,7 +143,7 @@ def add_part(self, part_id: int, variable: "VisorVariable") -> None: self.components[k] = self._widen(self.components[k], variable.ranges[k]) def to_record(self, previous_record: VisorVariableRecord | None) -> VisorVariableRecord: - """Build the record, carrying each custom slot from ``previous_record`` by the D3 rule.""" + """Build the record, carrying each custom slot from ``previous_record``.""" n = self.num_components default_magnitude = self.magnitude or (0.0, 0.0) default_ranges = [slot if slot is not None else (0.0, 0.0) for slot in self.components] @@ -216,12 +216,11 @@ def from_file( registry: "VisorDatasetRegistry", file_states: Dict[str, VisorVariableState], ) -> "VisorVariableRecords": - """Build a fresh holder from the registry and apply the file's ranges by the D3 load rule.""" + """Build a fresh holder from the registry and apply the file's ranges.""" return cls.from_registry(registry, VisorVariableRecords())._overlay(file_states) def _overlay(self, file_states: Dict[str, VisorVariableState]) -> "VisorVariableRecords": - """Apply a file's ranges to this fresh build by the D3 load rule and return this holder; called only by - from_file. + """Apply a file's ranges to this fresh build and return this holder; called only by from_file. - A null or absent ``magnitudeRange`` falls back to the default (DEBUG). - ``ranges`` whose length differs from ``num_components`` fall back to the default (DEBUG). 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 9b45ef5b..ceb289b1 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 @@ -25,7 +25,6 @@ class VisorSaveStateResponse(BaseModel): app_state: RuntimeAppState = Field(alias="appState") # Guards a client that still sends variableStates in the pre-record shape. - # A later increment decides whether this validator stays. @model_validator(mode="before") @classmethod def _discard_browser_variable_states(cls, data: Any) -> Any: diff --git a/tests/unit/models/test_visor_save_state_response.py b/tests/unit/models/test_visor_save_state_response.py index c241e9dc..6272ff37 100644 --- a/tests/unit/models/test_visor_save_state_response.py +++ b/tests/unit/models/test_visor_save_state_response.py @@ -115,9 +115,7 @@ def test_model_dump_contains_fields(monkeypatch): def test_save_path_rejects_a_variable_state_missing_the_identity_fields(): - """Rewritten in place (3.5.1 increment 1): the save path no longer rejects this entry. - - The server owns the variable records, so the browser's ``variableStates`` is + """ 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. """ @@ -142,7 +140,7 @@ def test_save_path_rejects_a_variable_state_missing_the_identity_fields(): def test_save_path_accepts_a_variable_state_carrying_the_identity_fields(): - """Rewritten in place (3.5.1 increment 1): a complete browser entry is discarded too.""" + """ The server owns the variable records, so the browser's ``variableStates`` is discarded before validation.""" payload = { "requestId": 1, "appState": { diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 7bdc7161..70324494 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -2082,10 +2082,10 @@ def test_apply_state_component_beyond_the_stored_ranges_is_a_logged_no_op( def test_apply_state_absent_range_is_a_logged_no_op(scene, registry, pipeline): - """Rewritten in place (3.5.1 increment 1): an absent file range now loads at the default. + """An absent file range now loads at the default. - The overlay fills a null ``magnitudeRange`` from the record's default - (D3), so the entry the restore reads always carries a range: the part is + The overlay fills a null ``magnitudeRange`` from the record's default, + so the entry the restore reads always carries a range: the part is colored at the widened default (0.0, 49.0) and nothing is refused. """ _seed_part_variables(registry, [_pressure_variable()]) @@ -2104,7 +2104,7 @@ def test_apply_state_unknown_variable_identifier_is_a_logged_no_op( ): """A stored identifier with no record applies nothing. - Rewritten in place (3.5.1 increment 1): the entry now comes from the + The entry comes from the record, which the registry's "pressure" always produces, so the part names an identifier no dataset carries. """ @@ -2121,7 +2121,7 @@ def test_apply_state_unknown_variable_identifier_is_a_logged_no_op( def test_apply_state_unknown_array_name_is_a_logged_no_op(scene, registry, pipeline): """An array the part does not carry applies nothing. - Rewritten in place (3.5.1 increment 1): the record's array name comes from + The record's array name comes from the registry, so "pressure" lives on another part and this part carries only a cell array. """ @@ -2142,7 +2142,7 @@ def test_apply_state_unknown_array_name_is_a_logged_no_op(scene, registry, pipel def test_apply_state_array_width_mismatch_is_a_logged_no_op(scene, registry, pipeline): """Same name and association, different width: a different quantity. - Rewritten in place (3.5.1 increment 1): the width-3 record exists because + The width-3 record exists because another part carries a width-3 "pressure"; this part's is width 1. """ wide_pressure = VisorVariable( @@ -2851,8 +2851,7 @@ def test_get_state_derives_orthographic_enabled_from_the_camera_record(scene): This is the assertion that closes AC-5. The record carries the hand-written literal ``True`` while the reply carries the hand-written literal ``False``; revert the derivation and the assertion reports - ``False``, which is the reply's answer passed through -- the behaviour - before this increment. + ``False``, which is the reply's answer passed through. ``record_reads == 1`` is asserted here too: the record is bound once and read once, so the camera and the projection are answers to a single @@ -3490,7 +3489,7 @@ def test_get_state_hands_out_a_copy_of_the_ui_record(scene): # =========================================================================== -# 3.5.1 increment 1 -- the server's variable records +# The server's variable records # # Rebuild points (#10-#16), the set_state push (#18) and get_state (#19, #20). # Expected values are hand-written literals: the fixture "pressure" variable is diff --git a/tests/unit/vtk/scene/test_visor_state_mapper.py b/tests/unit/vtk/scene/test_visor_state_mapper.py index eeeb1d45..f84abbaa 100644 --- a/tests/unit/vtk/scene/test_visor_state_mapper.py +++ b/tests/unit/vtk/scene/test_visor_state_mapper.py @@ -103,7 +103,7 @@ def fake_from_components(**kwargs): assert called["datasets"] == {"dsA": {"converted": {"state": 123}}} assert called["unit"] == "m" assert called["camera"] == "cam" - # Rewritten in place (3.5.1 increment 1): the record is projected, not passed through. + # The record is projected, not passed through. assert called["variable_states"] == { "POINT::pressure::1": VisorVariableState( id="POINT::pressure::1", diff --git a/tests/unit/vtk/test_wire_format_identity.py b/tests/unit/vtk/test_wire_format_identity.py index 699eec39..263b5035 100644 --- a/tests/unit/vtk/test_wire_format_identity.py +++ b/tests/unit/vtk/test_wire_format_identity.py @@ -351,7 +351,7 @@ def test_data_array_type_is_str_and_point_or_cell_in_emitted_json(): # --------------------------------------------------------------------------- -# 3.5.1 increment 1: the server's variable records on the scene-details wire. +# The server's variable records on the scene-details wire. # # The registry holds two hand-written datasets sharing "pressure"; the scene # graph is the fixture sphere and plays no part in the variables. Expected diff --git a/tests/unit/vtk/variables/test_visor_variable_empty_block.py b/tests/unit/vtk/variables/test_visor_variable_empty_block.py index 4db23d6c..5b2bd725 100644 --- a/tests/unit/vtk/variables/test_visor_variable_empty_block.py +++ b/tests/unit/vtk/variables/test_visor_variable_empty_block.py @@ -1,12 +1,11 @@ -"""G4: variable metadata on a multiblock with an empty (None) block (3.5.1 increment 1, test 23). +"""G4: variable metadata on a multiblock with an empty (None) block. The scene graph and the dataset's PartIndex must name the same parts with the same variables, or the record's ``part_ids`` name ids the client's nodes lack. Expected to fail today on F-E: ``VisorGroupNode._post_init`` walks every ``GetBlock(i)`` including ``None``, and node creation raises RuntimeError. -PartIndex skips empty blocks. Recorded as found-not-fixed; neither -part_index.py nor group_node.py is edited in this increment. +PartIndex skips empty blocks. Found-not-fixed. """ import pytest from vtkmodules.vtkCommonCore import vtkFloatArray @@ -44,8 +43,8 @@ def _multiblock_with_an_empty_block() -> vtkMultiBlockDataSet: @pytest.mark.xfail( strict=True, raises=RuntimeError, - reason="F-E: VisorGroupNode._post_init walks the None block and node creation raises " - "RuntimeError; PartIndex skips it. Found, not fixed, in 3.5.1 increment 1.", + reason="VisorGroupNode._post_init walks the None block and node creation raises " + "RuntimeError; PartIndex skips it. Found, not fixed.", ) def test_part_node_data_arrays_and_list_variables_agree_on_an_empty_block(): """#23: per part id, part_node.data_arrays and dataset.list_variables() name the same arrays.""" From 5c8cccac6b78459b89a9fcf0d1092db099230298 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 11:21:28 -0700 Subject: [PATCH 07/12] pre-commit fix --- tests/unit/models/test_visor_variable_record_build.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/models/test_visor_variable_record_build.py b/tests/unit/models/test_visor_variable_record_build.py index 81142253..65327da0 100644 --- a/tests/unit/models/test_visor_variable_record_build.py +++ b/tests/unit/models/test_visor_variable_record_build.py @@ -4,8 +4,8 @@ literal, never recomputed the way the code computes it. """ from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType -from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables from ansys.visor.viewer.models.common.visor_variable_record import VisorVariableRecords +from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables from ansys.visor.viewer.vtk.variables.visor_variables import VisorVariable POINT = VisorVtkVariableType.POINT From e9b613247388c86d4db971a2f17133e2b0ad5524 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Mon, 5 Oct 2026 13:15:38 -0700 Subject: [PATCH 08/12] self._renderer.serialize_part_state(node_id) in clear_part_color_variable --- src/ansys/visor/viewer/vtk/scene/base.py | 5 +++- tests/unit/vtk/scene/test_base.py | 30 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 30a80502..5f355c3a 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -905,7 +905,9 @@ def clear_part_color_variable(self, node_id: int) -> None: Stop colouring the part identified by *node_id* by a scalar variable. The variable reference is cleared atomically in the store (id and - component together), matching the atomic set. + component together), matching the atomic set. The mapper is then + re-serialized inside the lock, as :meth:`_ColorVariableBinding.apply_to` + does after an apply, so the state served to the client is current. """ with self._vtk_lock: if not self._dataset_registry.clear_part_color_variable(node_id): @@ -914,6 +916,7 @@ def clear_part_color_variable(self, node_id: int) -> None: ) return self._renderer.clear_color_variable(node_id) + self._renderer.serialize_part_state(node_id) # ========================================================================= # Widget state — coordinator surface diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 70324494..32fa12f2 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -752,6 +752,36 @@ def test_clear_part_color_variable_flushes_under_the_lock_after_the_apply(scene, assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count +def test_clear_part_color_variable_reserializes_the_mapper_after_the_write_under_the_lock( + scene, pipeline +): + """The mapper is re-serialized by its own id, after scalar colouring is off, with the lock held.""" + _seed_records(scene, _seeded_record()) + scene.set_part_color_variable( + NODE_ID, "POINT::pressure::1", VisorVtkVariableType.POINT, "pressure", 0 + ) + assert pipeline.mapper.GetScalarVisibility() == 1 + scene._vtk_lock = _LockSpy() + object_manager = scene._renderer._object_manager + mapper = pipeline.mapper + other_ids = object_manager.GetId.side_effect + object_manager.GetId.side_effect = ( + lambda obj: MAPPER_WASM_ID if obj is mapper else other_ids(obj) + ) + serialized = [] + object_manager.UpdateStateFromObject.side_effect = lambda object_id: serialized.append( + (object_id, scene._vtk_lock.depth, mapper.GetScalarVisibility()) + ) + + scene.clear_part_color_variable(NODE_ID) + + assert len(serialized) == 1 + object_id, depth, served_visibility = serialized[0] + assert object_id == 8150003 + assert depth >= 1 + assert served_visibility == 0 + + # =========================================================================== # Unknown node id: logged no-op at every layer # =========================================================================== From 2e821d998225a44f7e41761fda104b39c46d4425 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Mon, 5 Oct 2026 14:09:16 -0700 Subject: [PATCH 09/12] reconcile_part_color_variables after loading --- src/ansys/visor/viewer/vtk/scene/base.py | 49 ++++++- tests/unit/vtk/scene/test_base.py | 158 +++++++++++++++++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 5f355c3a..7dda5dde 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -586,15 +586,60 @@ def update_variables_for_dataset(self, dataset_id: int, variables: List[VisorVar timer.log() def _rebuild_variable_records_from_registry(self) -> None: - """Rebuild the held records from the registry, carrying custom ranges; under ``_vtk_lock``. + """Rebuild the held records from the registry, carrying custom ranges, then reconcile part references; + under ``_vtk_lock``. Used at add, remove, clear and update. Assigns a new dict to the holder - (copy-on-write); never mutates the live dict or its entries. + (copy-on-write); never mutates the live dict or its entries, so the dict + it replaces is still intact when it is handed to + :meth:`_reconcile_part_color_variables` as the previous records. """ with self._vtk_lock: + previous = self._variable_records.variables records = VisorVariableRecords.from_registry(self._dataset_registry, self._variable_records).variables self._variable_records.variables = records logger.debug("variable records rebuilt from registry: %d records", len(records)) + self._reconcile_part_color_variables(previous) + + def _reconcile_part_color_variables(self, previous: Dict[str, VisorVariableRecord]) -> None: + """Re-apply or clear each part's color variable against the current records, skipping a part whose + effective range is unchanged from *previous*. Caller holds the scene lock. + + Only a part whose variable id and component are both set is reconciled; + a half-set reference is left as it is. A reference that resolves is + re-applied through :meth:`_ColorVariableBinding.apply_to`, unless the + part participated in the same record in *previous* at the same range. + A reference that no longer resolves is cleared through + :meth:`clear_part_color_variable`, which clears the store, resets the + mapper and re-serializes it. + """ + references = [ + (part_id, part_state.variable_id, part_state.variable_component) + for dataset in list(self._dataset_registry.datasets.values()) + for part_id, part_state in list(dataset.state.part_states.items()) + if part_state.variable_id is not None and part_state.variable_component is not None + ] + for part_id, variable_id, component in references: + binding = self._resolve_color_variable(part_id, variable_id, component) + if binding is None: + self.clear_part_color_variable(part_id) + logger.debug( + "reconcile: part %s cleared; '%s' component %s no longer resolves.", + part_id, variable_id, component + ) + continue + old_record = previous.get(variable_id) + if ( + old_record is not None + and part_id in old_record.part_ids + and old_record.range_for(component) == (binding.min_val, binding.max_val) + ): + continue + binding.apply_to(self._renderer) + logger.debug( + "reconcile: part %s re-applied at '%s' component %s [%g, %g].", + part_id, variable_id, component, binding.min_val, binding.max_val + ) def _load_variable_records(self, file_states: Dict[str, VisorVariableState]) -> None: """Build the held records fresh from the registry and overlay the file's ranges; under ``_vtk_lock``. diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 32fa12f2..e49688bc 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -3668,6 +3668,164 @@ def _build(*args): } +# --------------------------------------------------------------------------- +# Reconcile after rebuild: each part's stored color-variable reference is +# re-applied or cleared against the rebuilt records. The fixture "pressure" +# on NODE_ID has component range (10.0, 20.0); the wider "pressure" a second +# dataset brings has (-5.0, 30.0), so the widened default is (-5.0, 30.0). +# --------------------------------------------------------------------------- + +def _wider_pressure_variable() -> VisorVariable: + """A "pressure" of the same width whose component range is wider on both ends.""" + return VisorVariable( + index=0, type=VisorVtkVariableType.POINT, name="pressure", num_components=1, + num_points=50, ranges=[(-5.0, 30.0)], magnitude_range=(0.0, 60.0), + ) + + +def _color_part_by_pressure_component(scene, registry): + """Rebuild the records from the fixture "pressure" and colour NODE_ID by its component 0.""" + _seed_part_variables(registry, [_pressure_variable()]) + scene._rebuild_variable_records_from_registry() + scene.set_part_color_variable(NODE_ID, VARIABLE_ID, VisorVtkVariableType.POINT, "pressure", 0) + + +def _record_mapper_serializations(scene, mapper, read_back): + """Record (object id, lock depth, read_back()) for every re-serialization from here on.""" + object_manager = scene._renderer._object_manager + other_ids = object_manager.GetId.side_effect + object_manager.GetId.side_effect = ( + lambda obj: MAPPER_WASM_ID if obj is mapper else other_ids(obj) + ) + serialized = [] + object_manager.UpdateStateFromObject.side_effect = lambda object_id: serialized.append( + (object_id, scene._vtk_lock.depth, read_back()) + ) + return serialized + + +def _add_dataset_carrying(scene, registry, variable): + """add_dataset a second dataset whose one part, SECOND_NODE_ID, carries *variable*.""" + scene._scene_graph = MagicMock(name="scene_graph") + scene._scene_graph.load_dataset.return_value = 2 + scene._scene_graph.get_descendant_node.return_value.get_descendant_part_nodes.return_value = [] + + def _registry_add(dataset_id, name, data, node_ids, metadata): + registry.datasets[dataset_id] = _make_part_dataset( + dataset_id, [SECOND_NODE_ID], + part_variables=[VisorPartVariables(SECOND_NODE_ID, "part", [variable])], + ) + + with ( + patch.object(registry, "get_sanitized_metadata_name", return_value="second"), + patch.object(registry, "add", side_effect=_registry_add), + ): + scene.add_dataset(MagicMock(name="input"), MagicMock(name="metadata")) + + +def _update_pressure_to_three_components(scene, registry): + """update_variables_for_dataset so NODE_ID's "pressure" becomes 3 components wide.""" + scene._scene_graph = MagicMock(name="scene_graph") + wide_pressure = VisorVariable( + index=0, type=VisorVtkVariableType.POINT, name="pressure", num_components=3, + num_points=50, ranges=[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)], magnitude_range=(0.0, 7.0), + ) + + def _update_variables(dataset_id, variables): + _seed_part_variables(registry, [wide_pressure]) + + with patch.object(registry, "update_variables", side_effect=_update_variables): + scene.update_variables_for_dataset(1, []) + + +def test_rebuild_at_add_reapplies_the_widened_range_to_a_colored_part(scene, registry, pipeline): + """A part coloured at the default follows the widened default, re-serialized under the lock. + + No trigger is sent after the colouring and nothing is pushed: the rebuild alone reaches the mapper. + """ + _color_part_by_pressure_component(scene, registry) + assert pipeline.mapper.GetScalarRange() == pytest.approx((10.0, 20.0)) + scene._vtk_lock = _LockSpy() + serialized = _record_mapper_serializations( + scene, pipeline.mapper, lambda: tuple(pipeline.mapper.GetScalarRange()) + ) + scene._push_runtime_state = MagicMock(name="push_runtime_state") + + _add_dataset_carrying(scene, registry, _wider_pressure_variable()) + + assert pipeline.mapper.GetScalarRange() == pytest.approx((-5.0, 30.0)) + assert len(serialized) == 1 + object_id, depth, served_range = serialized[0] + assert object_id == 8150003 + assert depth >= 1 + assert served_range == pytest.approx((-5.0, 30.0)) + scene._push_runtime_state.assert_not_called() + + +def test_rebuild_at_update_clears_a_reference_whose_variable_is_gone(scene, registry, pipeline): + """Store half: the reference is cleared, and the next load logs no WARNING for it.""" + _color_part_by_pressure_component(scene, registry) + + _update_pressure_to_three_components(scene, registry) + + state = registry.get_part_state(NODE_ID) + assert state.variable_id is None + assert state.variable_component is None + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply(scene, _runtime_state({NODE_ID: state.model_copy()})) + assert mock_logger.warning.call_count == 0 + + +def test_rebuild_at_update_resets_the_mapper_of_a_cleared_part(scene, registry, pipeline): + """Apply half: scalar colouring is off, and the mapper is re-serialized after that, under the lock.""" + _color_part_by_pressure_component(scene, registry) + assert pipeline.mapper.GetScalarVisibility() == 1 + scene._vtk_lock = _LockSpy() + serialized = _record_mapper_serializations( + scene, pipeline.mapper, lambda: pipeline.mapper.GetScalarVisibility() + ) + + _update_pressure_to_three_components(scene, registry) + + assert pipeline.mapper.GetScalarVisibility() == 0 + assert len(serialized) == 1 + object_id, depth, served_visibility = serialized[0] + assert object_id == 8150003 + assert depth >= 1 + assert served_visibility == 0 + + +def test_rebuild_with_unchanged_records_writes_no_mapper_and_serializes_nothing( + scene, registry, pipeline +): + """A rebuild over the same registry leaves a coloured part's mapper alone.""" + _color_part_by_pressure_component(scene, registry) + object_manager = scene._renderer._object_manager + object_manager.UpdateStateFromObject.reset_mock() + scene._renderer.apply_color_variable = MagicMock( + name="apply_color_variable", wraps=scene._renderer.apply_color_variable + ) + scene._renderer.clear_color_variable = MagicMock( + name="clear_color_variable", wraps=scene._renderer.clear_color_variable + ) + + scene._rebuild_variable_records_from_registry() + + scene._renderer.apply_color_variable.assert_not_called() + scene._renderer.clear_color_variable.assert_not_called() + object_manager.UpdateStateFromObject.assert_not_called() + + +def test_rebuild_keeps_a_custom_range_on_a_colored_part(scene, registry, pipeline): + """A custom range survives a rebuild that widens the default.""" + _color_part_by_pressure_component(scene, registry) + scene.set_variable_range(VARIABLE_ID, 0, 3.25, 6.75) + + _add_dataset_carrying(scene, registry, _wider_pressure_variable()) + + assert pipeline.mapper.GetScalarRange() == pytest.approx((3.25, 6.75)) + + def test_load_overlays_the_file_range_before_the_part_restore(scene, registry): """#14: the file's range is held after load. From be7deda2ecb865d296668bb6969fbf26b2ff720a Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 6 Oct 2026 04:37:47 -0700 Subject: [PATCH 10/12] adding dataset with wider variable value ranges --- ...tk_scene_sphere_l2_b3_r32_v3_c1_s2_z6.json | 35 ++++++++++++++++++ ...vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6.vtm | 19 ++++++++++ ...k_scene_sphere_l2_b3_r32_v3_c1_s2_z6_0.vtp | Bin 0 -> 23772 bytes ...k_scene_sphere_l2_b3_r32_v3_c1_s2_z6_1.vtp | Bin 0 -> 24297 bytes ...k_scene_sphere_l2_b3_r32_v3_c1_s2_z6_2.vtp | Bin 0 -> 24295 bytes ...k_scene_sphere_l2_b3_r32_v3_c1_s2_z6_3.vtp | Bin 0 -> 24305 bytes ...k_scene_sphere_l2_b3_r32_v3_c1_s2_z6_4.vtp | Bin 0 -> 24290 bytes ...k_scene_sphere_l2_b3_r32_v3_c1_s2_z6_5.vtp | Bin 0 -> 24305 bytes ...k_scene_sphere_l2_b3_r32_v3_c1_s2_z6_6.vtp | Bin 0 -> 24264 bytes ...k_scene_sphere_l2_b3_r32_v3_c1_s2_z6_7.vtp | Bin 0 -> 24269 bytes ...k_scene_sphere_l2_b3_r32_v3_c1_s2_z6_8.vtp | Bin 0 -> 24250 bytes 11 files changed, 54 insertions(+) create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6.json create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6.vtm create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_0.vtp create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_1.vtp create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_2.vtp create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_3.vtp create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_4.vtp create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_5.vtp create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_6.vtp create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_7.vtp create mode 100644 examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_8.vtp diff --git a/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6.json b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6.json new file mode 100644 index 00000000..023c06c8 --- /dev/null +++ b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6.json @@ -0,0 +1,35 @@ +{ + "name": "vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6", + "unit": "m", + "state": { + "parts": { + "level_1_block_0_part_0": { + "opacity": 0.1111111111111111 + }, + "level_1_block_0_part_1": { + "opacity": 0.1111111111111111 + }, + "level_1_block_0_part_2": { + "opacity": 0.1111111111111111 + }, + "level_1_block_1_part_0": { + "opacity": 0.2222222222222222 + }, + "level_1_block_1_part_1": { + "opacity": 0.2222222222222222 + }, + "level_1_block_1_part_2": { + "opacity": 0.2222222222222222 + }, + "level_1_block_2_part_0": { + "opacity": 0.3333333333333333 + }, + "level_1_block_2_part_1": { + "opacity": 0.3333333333333333 + }, + "level_1_block_2_part_2": { + "opacity": 0.3333333333333333 + } + } + } +} \ No newline at end of file diff --git a/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6.vtm b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6.vtm new file mode 100644 index 00000000..baabcf0d --- /dev/null +++ b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6.vtm @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_0.vtp b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_0.vtp new file mode 100644 index 0000000000000000000000000000000000000000..0cdf4e558fd5f30333e8ca41dc2637ca7fe12702 GIT binary patch literal 23772 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 z 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 NA$akhw>RYb{{WvcCJ6ul literal 0 HcmV?d00001 diff --git a/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_2.vtp b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_2.vtp new file mode 100644 index 0000000000000000000000000000000000000000..635c06503414b104fb9088f4d7106b28afe768db GIT binary patch literal 24295 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 literal 0 HcmV?d00001 diff --git a/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_3.vtp b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_3.vtp new file mode 100644 index 0000000000000000000000000000000000000000..dcafdabc6b44f4e4f26e750096526aa3c6030196 GIT binary patch literal 24305 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 X_@AEN{~J#OLGYh~7yr$@f#ClE$<{=K literal 0 HcmV?d00001 diff --git a/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_4.vtp b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_4.vtp new file mode 100644 index 0000000000000000000000000000000000000000..9dd6792b3ebb8d657e365f85496d421f5a43f6c9 GIT binary patch literal 24290 zcmd742|UzY`}p5-7bUbPBosx9ofr)6Hc3T03S&u=EHl<2V+en#V#`vAXy*|(Lz3=D#|9{W#`FeSs>vNrR-q*R#xz6WYpU;d!o;bGO z+}+b{y?-#)%|z)C#xr=gv%j;_`am~dKX;6`iISFvw$gf+V1G9h#`m{pc-JQLa)}M8A2DPX;{P;d^e_b7=^{yB%tgoA&9|m*}^!GSyf{V1{J8fyScipw+irbar3o4a|q+^?eAxzWT+1e{?pGdy-v9K!cJQMqRaYUO&xIe zcKgdtNBw=>v47bMx(qsL8|wV?kaN?RofmK&PI&!MS7%RWUjXL9dw_Kr{Ua>K0<5zugf;q?i>WBrlB?Wqo-~7 zYx8={nKOQFz~Vp88hEVv%gn*t|H5;$uk⪙OB2E0WAKhyTAGHyG`($yZ>r)tpC+r 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 AxBvhE literal 0 HcmV?d00001 diff --git a/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_5.vtp b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_5.vtp new file mode 100644 index 0000000000000000000000000000000000000000..a055cfec9fc3423f526aafebe825274a785cc675 GIT binary patch literal 24305 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 literal 0 HcmV?d00001 diff --git a/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_6.vtp b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_6.vtp new file mode 100644 index 0000000000000000000000000000000000000000..60286930fff2c5a5cf92b553fcb1ac0b66677203 GIT binary patch literal 24264 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 literal 0 HcmV?d00001 diff --git a/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_8.vtp b/examples/assets/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6/vtk_scene_sphere_l2_b3_r32_v3_c1_s2_z6_8.vtp new file mode 100644 index 0000000000000000000000000000000000000000..9cf5fdbd2bc5627f358792a066f9615eead3bf6c GIT binary patch literal 24250 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 QqgCL){9X9}>Wx Date: Wed, 7 Oct 2026 12:43:39 -0700 Subject: [PATCH 11/12] feat: move serialize_part_state to renderer --- src/ansys/visor/viewer/renderer/base.py | 15 ++++-- .../visor/viewer/renderer/local_renderer.py | 8 +++ src/ansys/visor/viewer/vtk/scene/base.py | 10 ++-- tests/unit/renderer/test_local_renderer.py | 51 +++++++++++++++++++ 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/ansys/visor/viewer/renderer/base.py b/src/ansys/visor/viewer/renderer/base.py index a8aafa48..096a159a 100644 --- a/src/ansys/visor/viewer/renderer/base.py +++ b/src/ansys/visor/viewer/renderer/base.py @@ -149,11 +149,20 @@ def apply_color_variable( Component index. ``-1`` means magnitude. min_val / max_val: Scalar display range. + + Re-serializes the part's served state after the write. **Serialize + only; do not notify.** No-op on a renderer that serves the client no + VTK object state. """ @abstractmethod def clear_color_variable(self, node_id: int) -> None: - """Disable scalar colouring on *node_id*, reverting to solid diffuse.""" + """Disable scalar colouring on *node_id*, reverting to solid diffuse. + + Re-serializes the part's served state after the write. **Serialize + only; do not notify.** No-op on a renderer that serves the client no + VTK object state. + """ @abstractmethod def serialize_part_state(self, node_id: int) -> None: @@ -229,8 +238,8 @@ def set_projection(self, parallel: bool) -> None: def serialize_camera_state(self) -> None: """Make the state served to the client current for the camera. - The camera alone; the node pipelines are - :meth:`serialize_pipeline_states`'s job. Writing the pipeline camera + The camera alone; a part's mapper is + :meth:`serialize_part_state`'s job. Writing the pipeline camera makes the server correct: it does not make the state the client is served correct, and the two are separate steps that can each silently do nothing. diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index 3e8fb609..45492354 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -246,6 +246,9 @@ def apply_color_variable( *variable_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. + + Ends with :meth:`serialize_part_state`, after the mapper write, so + the state served to the client carries the new range. """ pipe = self._pipelines.get(node_id) if pipe is None: @@ -254,6 +257,7 @@ def apply_color_variable( ) return pipe.set_color_variable(array_type, array_name, component, min_val, max_val) + self.serialize_part_state(node_id) def clear_color_variable(self, node_id: int) -> None: """See :meth:`IRenderer.clear_color_variable`. @@ -261,6 +265,9 @@ def clear_color_variable(self, node_id: int) -> None: Resolves the pipeline and delegates to :meth:`VtkNodePipeline.clear_color_variable`. An unknown *node_id* is a logged no-op, never a raise. + + Ends with :meth:`serialize_part_state`, after the mapper write, so + the state served to the client has scalar colouring off. """ pipe = self._pipelines.get(node_id) if pipe is None: @@ -269,6 +276,7 @@ def clear_color_variable(self, node_id: int) -> None: ) return pipe.clear_color_variable() + self.serialize_part_state(node_id) def serialize_part_state(self, node_id: int) -> None: """See :meth:`IRenderer.serialize_part_state`. diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 7dda5dde..62821897 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -65,7 +65,6 @@ def apply_to(self, renderer: IRenderer) -> None: self.min_val, self.max_val, ) - renderer.serialize_part_state(self.part_id) class VisorSceneBase(ABC): @@ -950,9 +949,9 @@ def clear_part_color_variable(self, node_id: int) -> None: Stop colouring the part identified by *node_id* by a scalar variable. The variable reference is cleared atomically in the store (id and - component together), matching the atomic set. The mapper is then - re-serialized inside the lock, as :meth:`_ColorVariableBinding.apply_to` - does after an apply, so the state served to the client is current. + component together), matching the atomic set. The renderer's clear + then re-serializes the mapper inside the lock, as its apply does, so + the state served to the client is current. """ with self._vtk_lock: if not self._dataset_registry.clear_part_color_variable(node_id): @@ -961,7 +960,6 @@ def clear_part_color_variable(self, node_id: int) -> None: ) return self._renderer.clear_color_variable(node_id) - self._renderer.serialize_part_state(node_id) # ========================================================================= # Widget state — coordinator surface @@ -1226,7 +1224,7 @@ def _restore_part_color_variable(self, part_id: int, part_state: RuntimePartProp Otherwise the reference resolves against the held records, which the load path has rebuilt and overlaid with the file's ranges before this runs. A refused reference is a logged no-op; the mapper is re-serialized - after an applied one. + after an applied or cleared one. """ variable_id = part_state.variable_id component = part_state.variable_component diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index 3ec38521..2b1cf77e 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -21,12 +21,15 @@ from unittest.mock import MagicMock, patch import pytest +from vtkmodules.vtkCommonCore import vtkFloatArray +from vtkmodules.vtkFiltersSources import vtkSphereSource from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState 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 +from ansys.visor.viewer.vtk.node_pipeline import VtkNodePipeline # --------------------------------------------------------------------------- # Lightweight VTK-data stubs (no real VTK objects required for pick tests) @@ -642,6 +645,35 @@ def test_apply_color_variable_unknown_node_id_is_logged_no_op(self, renderer): mock_logger.debug.assert_called_once() pipe.set_color_variable.assert_not_called() + def test_apply_color_variable_serializes_the_mapper_after_the_write(self, renderer): + """The mapper is re-serialized by its own id, once, with the new range already written.""" + source = vtkSphereSource() + source.Update() + dataset = source.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) + pipe = VtkNodePipeline.from_dataset(dataset) + pipe.mapper.SetScalarRange(11.0, 22.0) + renderer._pipelines[4] = pipe + renderer._object_manager.GetId.side_effect = ( + lambda obj: 8150003 if obj is pipe.mapper else WRONG_OBJECT_WASM_ID + ) + recorded = [] + renderer._object_manager.UpdateStateFromObject.side_effect = ( + lambda object_id: recorded.append((object_id, tuple(pipe.mapper.GetScalarRange()))) + ) + + renderer.apply_color_variable( + 4, "sp-1", VisorVtkVariableType.POINT, "pressure", 0, 2.0, 8.0 + ) + + assert recorded == [(8150003, (2.0, 8.0))] + # ------------------------------------------------------------------ # clear_color_variable # ------------------------------------------------------------------ @@ -667,6 +699,25 @@ def test_clear_color_variable_unknown_node_id_is_logged_no_op(self, renderer): mock_logger.debug.assert_called_once() pipe.clear_color_variable.assert_not_called() + def test_clear_color_variable_serializes_the_mapper_after_the_write(self, renderer): + """The mapper is re-serialized by its own id, once, with scalar colouring already off.""" + source = vtkSphereSource() + source.Update() + pipe = VtkNodePipeline.from_dataset(source.GetOutput()) + pipe.mapper.SetScalarVisibility(True) + renderer._pipelines[4] = pipe + renderer._object_manager.GetId.side_effect = ( + lambda obj: 8150003 if obj is pipe.mapper else WRONG_OBJECT_WASM_ID + ) + recorded = [] + renderer._object_manager.UpdateStateFromObject.side_effect = ( + lambda object_id: recorded.append((object_id, pipe.mapper.GetScalarVisibility())) + ) + + renderer.clear_color_variable(4) + + assert recorded == [(8150003, 0)] + # =========================================================================== # 4. Camera From a92f394594c2cfddfc88d086c876906c4b449bbb Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Wed, 7 Oct 2026 13:17:16 -0700 Subject: [PATCH 12/12] chore: make the part re-serialize private to the local renderer --- src/ansys/visor/viewer/renderer/base.py | 24 ++++------------- .../visor/viewer/renderer/local_renderer.py | 27 ++++++++++++------- .../visor/viewer/renderer/null_renderer.py | 6 ----- 3 files changed, 22 insertions(+), 35 deletions(-) diff --git a/src/ansys/visor/viewer/renderer/base.py b/src/ansys/visor/viewer/renderer/base.py index 096a159a..231bb00d 100644 --- a/src/ansys/visor/viewer/renderer/base.py +++ b/src/ansys/visor/viewer/renderer/base.py @@ -150,29 +150,16 @@ def apply_color_variable( min_val / max_val: Scalar display range. - Re-serializes the part's served state after the write. **Serialize - only; do not notify.** No-op on a renderer that serves the client no - VTK object state. + On return, the state this renderer serves to the client reflects the + write, and the client is not notified. """ @abstractmethod def clear_color_variable(self, node_id: int) -> None: """Disable scalar colouring on *node_id*, reverting to solid diffuse. - Re-serializes the part's served state after the write. **Serialize - only; do not notify.** No-op on a renderer that serves the client no - VTK object state. - """ - - @abstractmethod - def serialize_part_state(self, node_id: int) -> None: - """Make the state served to the client current for one part's mapper. - - A mapper write without this leaves the served cache holding the old - content under a new version number, so the next client fetch gets the - pre-write range. **Serialize only; do not notify.** An unknown - *node_id* is a logged no-op. No-op on a renderer that serves the - client no VTK object state. + On return, the state this renderer serves to the client reflects the + write, and the client is not notified. """ @abstractmethod @@ -238,8 +225,7 @@ def set_projection(self, parallel: bool) -> None: def serialize_camera_state(self) -> None: """Make the state served to the client current for the camera. - The camera alone; a part's mapper is - :meth:`serialize_part_state`'s job. Writing the pipeline camera + The camera alone. Writing the pipeline camera makes the server correct: it does not make the state the client is served correct, and the two are separate steps that can each silently do nothing. diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index 45492354..1cf30c9e 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -247,8 +247,9 @@ def apply_color_variable( registry and is not needed to configure the mapper. An unknown *node_id* is a logged no-op, never a raise. - Ends with :meth:`serialize_part_state`, after the mapper write, so - the state served to the client carries the new range. + Ends with :meth:`_serialize_part_state`, after the mapper write, so + the state served to the client carries the new range. Does not + notify the client. """ pipe = self._pipelines.get(node_id) if pipe is None: @@ -257,7 +258,7 @@ def apply_color_variable( ) return pipe.set_color_variable(array_type, array_name, component, min_val, max_val) - self.serialize_part_state(node_id) + self._serialize_part_state(node_id) def clear_color_variable(self, node_id: int) -> None: """See :meth:`IRenderer.clear_color_variable`. @@ -266,8 +267,9 @@ def clear_color_variable(self, node_id: int) -> None: :meth:`VtkNodePipeline.clear_color_variable`. An unknown *node_id* is a logged no-op, never a raise. - Ends with :meth:`serialize_part_state`, after the mapper write, so - the state served to the client has scalar colouring off. + Ends with :meth:`_serialize_part_state`, after the mapper write, so + the state served to the client has scalar colouring off. Does not + notify the client. """ pipe = self._pipelines.get(node_id) if pipe is None: @@ -276,10 +278,15 @@ def clear_color_variable(self, node_id: int) -> None: ) return pipe.clear_color_variable() - self.serialize_part_state(node_id) + self._serialize_part_state(node_id) - def serialize_part_state(self, node_id: int) -> None: - """See :meth:`IRenderer.serialize_part_state`. + def _serialize_part_state(self, node_id: int) -> None: + """Make the state served to the client current for one part's mapper. + + A mapper write without this leaves the served cache holding the old + content under a new version number, so the next client fetch gets the + pre-write range. **Serialize only; do not notify.** An unknown + *node_id* is a logged no-op. Names the mapper's id alone, derived with ``GetId`` on the object as :meth:`serialize_camera_state` derives the camera's. An id of ``0`` @@ -290,13 +297,13 @@ def serialize_part_state(self, node_id: int) -> None: pipe = self._pipelines.get(node_id) if pipe is None: logger.debug( - "serialize_part_state: no pipeline for node %s; skipping.", node_id + "_serialize_part_state: no pipeline for node %s; skipping.", node_id ) return mapper_id = self._object_manager.GetId(pipe.mapper) if mapper_id == 0: logger.debug( - "serialize_part_state: mapper of node %s is not registered yet; skipping.", + "_serialize_part_state: mapper of node %s is not registered yet; skipping.", node_id, ) return diff --git a/src/ansys/visor/viewer/renderer/null_renderer.py b/src/ansys/visor/viewer/renderer/null_renderer.py index 6dd5e51b..20eaeba7 100644 --- a/src/ansys/visor/viewer/renderer/null_renderer.py +++ b/src/ansys/visor/viewer/renderer/null_renderer.py @@ -101,12 +101,6 @@ def apply_color_variable( def clear_color_variable(self, node_id: int) -> None: pass - def serialize_part_state(self, node_id: int) -> None: - """See :meth:`IRenderer.serialize_part_state`. - - No-op: this renderer serves the client no VTK object state. - """ - def refresh_color_variable_range( self, node_id: int,