diff --git a/doc/changelog.d/166.maintenance.md b/doc/changelog.d/166.maintenance.md new file mode 100644 index 00000000..935a8fa4 --- /dev/null +++ b/doc/changelog.d/166.maintenance.md @@ -0,0 +1 @@ +[Remote rendering 3.6b] Resolve state on server and apply as given on the client diff --git a/src/ansys/visor/viewer/core/visor_colors.py b/src/ansys/visor/viewer/core/visor_colors.py index 2823061a..7a244882 100644 --- a/src/ansys/visor/viewer/core/visor_colors.py +++ b/src/ansys/visor/viewer/core/visor_colors.py @@ -7,3 +7,11 @@ class VisorColors: DefaultSelectionColor: tuple[float, float, float] = (0.184, 0.427, 0.620) DefaultSelectedMeshEdgeColor: tuple[float, float, float] = (0, 0, 0) + +class VisorPartDefaults: + """Values a part record holds for a property nothing has set.""" + Visible: bool = True + Opacity: float = 1.0 + Selected: bool = False + DiffuseRgb: tuple[float, float, float] = VisorColors.DefaultMeshColor + 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 9d77413e..1158a72d 100644 --- a/src/ansys/visor/viewer/models/persist/persisted_viewer_state.py +++ b/src/ansys/visor/viewer/models/persist/persisted_viewer_state.py @@ -7,10 +7,10 @@ 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.persist.dataset.persisted_dataset_state import PersistedDatasetState from ansys.visor.viewer.models.persist.scene.persisted_scene_state import PersistedSceneState +from ansys.visor.viewer.models.persist.visor_ui_state import PersistedUIState class PersistedViewerStateV1(BaseModel): @@ -27,12 +27,12 @@ class PersistedViewerStateV1(BaseModel): version: Literal["1.0"] = Field(default="1.0") - ui: VisorUIState = Field(default_factory=VisorUIState) + ui: PersistedUIState = Field(default_factory=PersistedUIState) scene: PersistedSceneState = Field(default_factory=PersistedSceneState) @classmethod def from_components(cls, - ui_state: VisorUIState, + ui_state: PersistedUIState, unit: str | None, orthographic_enabled: bool | None, cross_section_enabled: bool | None, @@ -48,7 +48,7 @@ def from_components(cls, Create a PersistedViewerStateV1 instance from UI settings and dataset states. Args: - ui_state (VisorUIState): UI settings object. + ui_state (PersistedUIState): UI settings object. unit (str | None): Scene unit (or None). datasets (Dict[str, PersistedDatasetState]): Dataset states keyed by dataset name. camera (VisorCameraState | None): Camera state (or None). diff --git a/src/ansys/visor/viewer/models/common/visor_ui_state.py b/src/ansys/visor/viewer/models/persist/visor_ui_state.py similarity index 95% rename from src/ansys/visor/viewer/models/common/visor_ui_state.py rename to src/ansys/visor/viewer/models/persist/visor_ui_state.py index fefa93b3..b474527f 100644 --- a/src/ansys/visor/viewer/models/common/visor_ui_state.py +++ b/src/ansys/visor/viewer/models/persist/visor_ui_state.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field -class VisorUIState(BaseModel): +class PersistedUIState(BaseModel): """ Represents the UI state of a Visor visualization, such as theme preference. """ diff --git a/src/ansys/visor/viewer/models/runtime/dataset/runtime_dataset_state.py b/src/ansys/visor/viewer/models/runtime/dataset/runtime_dataset_state.py index 78cf2736..fdf57d01 100644 --- a/src/ansys/visor/viewer/models/runtime/dataset/runtime_dataset_state.py +++ b/src/ansys/visor/viewer/models/runtime/dataset/runtime_dataset_state.py @@ -1,99 +1,147 @@ """Model for runtime dataset state serialization.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer +from pydantic import BaseModel, ConfigDict, Field, field_validator +from ansys.visor.viewer.core.visor_colors import VisorPartDefaults +from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.models.common.part_properties import PartProperties +logger = VisorDefaultLogger(__name__) + + +class VariableReference(BaseModel): + """The variable and component a part is coloured by.""" + model_config = ConfigDict(populate_by_name=True) + + variable_id: str = Field(alias="variableId") + variable_component: int = Field(alias="variableComponent") + class RuntimePartProperties(BaseModel): """ - Frontend-facing model of part properties for runtime state serialization. + Frontend-facing record of one part's properties, every field resolved. - This class mirrors the fields of ``PartProperties`` using snake_case Python - attribute names, but serializes with camelCase aliases (``variableId``, - ``variableComponent``, ``diffuseRgb``) for parity with the frontend wire - format. Callers must pass ``by_alias=True`` when dumping (e.g., in - ``get_scene_details_json``) so the emitted keys stay camelCase. + No field holds ``None`` except ``color_variable``, whose ``None`` is the + resolved value "not coloured by a variable". - Serialization semantics (aligned with the frontend state model): + The wire carries the reference nested, as ``colorVariable`` holding + ``variableId`` and ``variableComponent``, or ``null`` with no reference. + Callers pass ``by_alias=True`` so the keys stay camelCase. - - ``variableId`` is **always included** in the serialized output, even when - ``None``. On the frontend, ``null`` means "no variable applied" (a real - value), while an absent key means "leave this property unchanged". - - All other optional fields (``opacity``, ``visible``, ``selected``, - ``variableComponent``, ``diffuseRgb``) are **omitted from the serialized - output when ``None``**, so that the frontend treats them as pass-through - / unchanged. - - id: int - opacity: Optional[float]: The opacity of the part, between 0.0 (fully transparent) and 1.0 (fully opaque). - visible: Optional[bool]: Whether the part is visible in the scene. - selected: Optional[bool]: Whether the part is currently selected by the user. - variable_id: Optional[str]: The ID of the variable used to colour this part, - or ``None`` to indicate that no variable is applied. Always serialized. - variable_component: Optional[int]: If the variable specified by variable_id has multiple components, - this specifies which component to use for coloring. + id: The part's scene-graph node ID. + opacity: Between 0.0 (fully transparent) and 1.0 (fully opaque). + visible: Whether the part is visible in the scene. + selected: Whether the part is selected by the user. + diffuse_rgb: The part's diffuse colour, each channel between 0 and 1. + color_variable: The variable the part is coloured by, or ``None``. """ model_config = ConfigDict(populate_by_name=True) id: int - opacity: Optional[float] = Field(default=None, ge=0.0, le=1.0) - visible: Optional[bool] = Field(default=None) - selected: Optional[bool] = Field(default=None) - - # variable_id=None means "no variable applied" — a real, meaningful value. - # It is always included in serialized output so the frontend can act on it. - variable_id: Optional[str] = Field(default=None, alias="variableId") - # If the variable with ID variable_id has multiple components, this specifies which component to use for coloring. - variable_component: Optional[int] = Field(default=None, alias="variableComponent") - - # If set, this part's color is determined by the specified RGB values (each between 0 and 1). - diffuse_rgb: Optional[List[float]] = Field(default=None, alias="diffuseRgb") - - @model_serializer(mode="wrap") - def _serialize(self, handler: Any, info: Any = None) -> dict: - """Custom serializer that implements the frontend undefined-vs-null contract. - - ``variableId`` is always present in the output (``null`` is meaningful). - All other optional fields are omitted when their value is ``None``, so - the frontend interprets them as pass-through / undefined. - - The set of "omit-when-None" keys is checked against **both** the - snake_case attribute names and their camelCase aliases so this works - regardless of whether the caller uses ``by_alias=True``. - """ - data: dict = handler(self) - keys_to_omit_when_none = { - "opacity", "visible", "selected", - "variable_component", "variableComponent", - "diffuse_rgb", "diffuseRgb", - } - return {k: v for k, v in data.items() if not (k in keys_to_omit_when_none and v is None)} + opacity: float = Field(ge=0.0, le=1.0) + visible: bool + selected: bool + diffuse_rgb: List[float] = Field(alias="diffuseRgb") + color_variable: VariableReference | None = Field(alias="colorVariable") + + @classmethod + def default_for(cls, part_id: int) -> "RuntimePartProperties": + """The record of a part none of whose properties has been set.""" + return cls( + id=part_id, + opacity=VisorPartDefaults.Opacity, + visible=VisorPartDefaults.Visible, + selected=VisorPartDefaults.Selected, + diffuse_rgb=list(VisorPartDefaults.DiffuseRgb), + color_variable=None, + ) def to_part_properties(self) -> PartProperties: + """The persisted entry for this record; the reference maps back to color_by/color_by_component.""" + reference = self.color_variable return PartProperties( opacity=self.opacity, visible=self.visible, selected=self.selected, - color_by=self.variable_id, - color_by_component=self.variable_component, + color_by=reference.variable_id if reference is not None else None, + color_by_component=reference.variable_component if reference is not None else None, diffuse_rgb=self.diffuse_rgb, ) @classmethod def from_part_properties(cls, id: int, props: PartProperties) -> "RuntimePartProperties": - return cls( - id=id, - opacity=props.opacity, - visible=props.visible, - selected=props.selected, - variable_id=props.color_by, - variable_component=props.color_by_component, - diffuse_rgb=props.diffuse_rgb, + """The default record for the part, overlaid with the persisted entry.""" + return cls.default_for(id).overlaid_with(props) + + def overlaid_with(self, props: PartProperties) -> "RuntimePartProperties": + """A new record: a field the entry names takes its value, or its default where null; an unnamed field + keeps this record's. + + A key is named when it is in ``props.model_fields_set``. A diffuse + colour that is not three elements resolves as a null does, with one + WARNING. color_by and color_by_component are one field: with neither + named the reference is kept; with either named the pair resolves from + the entry alone, and a pair with only one half resolves to ``None`` + with one WARNING. This record is not modified. + """ + named = props.model_fields_set + if "diffuse_rgb" in named: + diffuse_rgb = self._resolve_diffuse_rgb(self.id, props) + else: + diffuse_rgb = list(self.diffuse_rgb) + if "color_by" in named or "color_by_component" in named: + color_variable = self._resolve_color_variable(self.id, props) + else: + color_variable = self.color_variable.model_copy() if self.color_variable is not None else None + return type(self)( + id=self.id, + opacity=self._overlaid(self.opacity, props, "opacity", VisorPartDefaults.Opacity), + visible=self._overlaid(self.visible, props, "visible", VisorPartDefaults.Visible), + selected=self._overlaid(self.selected, props, "selected", VisorPartDefaults.Selected), + diffuse_rgb=diffuse_rgb, + color_variable=color_variable, ) + @staticmethod + def _overlaid(current: Any, props: BaseModel, field: str, default: Any) -> Any: + """The current value where the entry does not name the field; else the entry's value, or the default + where null. + + *props* is any persisted model whose field names match the record being resolved; the part, + scene and UI records all resolve through this one rule. + """ + if field not in props.model_fields_set: + return current + value = getattr(props, field) + return value if value is not None else default + + @staticmethod + def _resolve_diffuse_rgb(part_id: int, props: PartProperties) -> List[float]: + """The persisted colour, or the default when it is null or not three elements.""" + if props.diffuse_rgb is None: + return list(VisorPartDefaults.DiffuseRgb) + if len(props.diffuse_rgb) != 3: + logger.warning( + "part %s stores a diffuse colour of %s elements, not 3; resolved as the default.", + part_id, len(props.diffuse_rgb) + ) + return list(VisorPartDefaults.DiffuseRgb) + return list(props.diffuse_rgb) + + @staticmethod + def _resolve_color_variable(part_id: int, props: PartProperties) -> VariableReference | None: + """The reference a persisted entry names, or None when it names none or only half of one.""" + if props.color_by is not None and props.color_by_component is not None: + return VariableReference(variable_id=props.color_by, variable_component=props.color_by_component) + if props.color_by is not None or props.color_by_component is not None: + logger.warning( + "part %s stores color_by %r with color_by_component %r; resolved as not coloured.", + part_id, props.color_by, props.color_by_component + ) + return None + class RuntimeDatasetState(BaseModel): """ 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 af5f88b3..76c29688 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 @@ -1,65 +1,20 @@ """Model for the runtime application state for a Visor visualizer instance.""" -from typing import Dict +from pydantic import BaseModel, ConfigDict -from pydantic import BaseModel, ConfigDict, Field - -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_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 +from ansys.visor.viewer.models.runtime.scene.runtime_ui_state import RuntimeUIState +from ansys.visor.viewer.models.runtime.scene.runtime_view_state import RuntimeViewState class RuntimeAppState(BaseModel): - """ - Represents the runtime, in-memory state of a Visor visualization, - including properties for individual parts. - - The part dictionary is keyed by internal dataset IDs. + """The save and load carrier: ui, scene, view. - This class is for serialization of the runtime state that is sent - to the frontend viewer. + Each record has one owner on the server: the UI record and the scene + record are the scene coordinator's, the view records are the renderer's. """ model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) - ui: VisorUIState = Field(default_factory=VisorUIState) - scene: RuntimeSceneState = Field(default_factory=RuntimeSceneState) - - @classmethod - def from_components( - cls, - ui: VisorUIState, - unit: str | None, - dataset_states: Dict[int, RuntimeDatasetState], - orthographic_enabled: bool | None = None, - cross_section_enabled: bool | None = None, - edges_enabled: bool | None = None, - bounding_box_enabled: bool | None = None, - cross_section: VisorCrossSectionState | None = None, - camera: VisorCameraState | None = None, - *, - variable_states: Dict[str, VisorVariableRecord], - ) -> "RuntimeAppState": - """Construct a RuntimeAppState from the given components. - - ``ui`` is taken whole and stored as-is: the server owns every one of - its fields (theme plus the four panel-layout fields) and assembles - them in one place, so this constructor never reaches into it. - """ - runtime_scene_state = RuntimeSceneState( - unit=unit, - camera=camera, - cross_section=cross_section, - orthographic_enabled=orthographic_enabled, - cross_section_enabled=cross_section_enabled, - edges_enabled=edges_enabled, - bounding_box_enabled=bounding_box_enabled, - dataset_states=dataset_states, - variable_states=variable_states, - ) - return cls( - ui=ui, - scene=runtime_scene_state, - ) + ui: RuntimeUIState + view: RuntimeViewState + scene: RuntimeSceneState 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 32188726..4f19dd3b 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 @@ -4,33 +4,62 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -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_record import VisorVariableRecord -from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimeDatasetState +from ansys.visor.viewer.models.persist.scene.persisted_scene_state import PersistedSceneState +from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( + RuntimeDatasetState, + RuntimePartProperties, +) + +#: The scene-level fields a loaded file can set, each resolved by the overlay rule. +_OVERLAID_FIELDS = ("unit", "cross_section_enabled", "edges_enabled", "bounding_box_enabled") class RuntimeSceneState(BaseModel): - """ - Represents the runtime, in-memory state of a Visor visualization scene, - including properties for individual datasets. + """Scene state the server owns: unit, the three toggles, + orthographic_enabled, dataset and variable records. + The dataset dictionary is keyed by internal dataset IDs. - This class is for serialization of the runtime state that is sent - to the frontend viewer (not to be confused with persisted state, which is stable between viewer sessions). + ``orthographic_enabled`` is derived from the camera record, which this + record does not hold; ``None`` means no camera was ever written. """ model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) - unit: str | None = None - camera: VisorCameraState | None = None - cross_section: VisorCrossSectionState | None = Field(default=None, alias="crossSection") - orthographic_enabled: bool | None = Field(default=None, alias="orthographicEnabled") - cross_section_enabled: bool | None = Field(default=None, alias="crossSectionEnabled") - edges_enabled: bool | None = Field(default=None, alias="edgesEnabled") - bounding_box_enabled: bool | None = Field(default=None, alias="boundingBoxEnabled") + unit: str + orthographic_enabled: bool | None = Field(alias="orthographicEnabled") + cross_section_enabled: bool = Field(alias="crossSectionEnabled") + edges_enabled: bool = Field(alias="edgesEnabled") + bounding_box_enabled: bool = Field(alias="boundingBoxEnabled") dataset_states: Dict[int, "RuntimeDatasetState"] = Field(default_factory=dict, alias="datasetStates") # 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") + @classmethod + def default(cls) -> "RuntimeSceneState": + """The scene record before anything has been set: no unit, every toggle off, no camera.""" + return cls( + unit="", + cross_section_enabled=False, + edges_enabled=False, + bounding_box_enabled=False, + orthographic_enabled=None, + ) + + def overlaid_with(self, persisted: PersistedSceneState, defaults: "RuntimeSceneState") -> "RuntimeSceneState": + """A new record: the unit and each toggle the file names takes its value, or the default where null; + an unnamed one keeps this record's. + + ``orthographic_enabled`` and the dataset and variable records are + carried from this record unchanged: the first is derived from the + camera, and the records have their own overlays. This record is not + modified. + """ + resolved = { + field: RuntimePartProperties._overlaid(getattr(self, field), persisted, field, getattr(defaults, field)) + for field in _OVERLAID_FIELDS + } + return self.model_copy(update=resolved) + @field_validator("dataset_states", mode="before") @classmethod def _coerce_dataset_states(cls, v: Any) -> Any: diff --git a/src/ansys/visor/viewer/models/runtime/scene/runtime_ui_state.py b/src/ansys/visor/viewer/models/runtime/scene/runtime_ui_state.py new file mode 100644 index 00000000..090c3bcd --- /dev/null +++ b/src/ansys/visor/viewer/models/runtime/scene/runtime_ui_state.py @@ -0,0 +1,31 @@ +"""Model for the server's UI record.""" + +from pydantic import BaseModel, ConfigDict, Field + +from ansys.visor.viewer.models.persist.visor_ui_state import PersistedUIState +from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimePartProperties + + +class RuntimeUIState(BaseModel): + """The server's UI record, every field required.""" + model_config = ConfigDict(populate_by_name=True) + + dark_theme: bool = Field(alias="darkTheme") + panel_top_left_panel_collapsed: bool = Field(alias="panelTopLeftPanelCollapsed") + panel_top_right_panel_collapsed: bool = Field(alias="panelTopRightPanelCollapsed") + panel_top_right_legend_collapsed: bool = Field(alias="panelTopRightLegendCollapsed") + panel_top_right_tab_index: int = Field(alias="panelTopRightTabIndex") + + def overlaid_with(self, persisted: PersistedUIState, defaults: "RuntimeUIState") -> "RuntimeUIState": + """A new record: a field the file names takes its value, or the default where null; an unnamed field + keeps this record's. This record is not modified.""" + return type(self)(**{ + field: RuntimePartProperties._overlaid( + getattr(self, field), persisted, field, getattr(defaults, field) + ) + for field in type(self).model_fields + }) + + def to_persisted(self) -> PersistedUIState: + """The persisted record carrying all five values.""" + return PersistedUIState(**{field: getattr(self, field) for field in type(self).model_fields}) diff --git a/src/ansys/visor/viewer/models/runtime/scene/runtime_view_state.py b/src/ansys/visor/viewer/models/runtime/scene/runtime_view_state.py new file mode 100644 index 00000000..12eff036 --- /dev/null +++ b/src/ansys/visor/viewer/models/runtime/scene/runtime_view_state.py @@ -0,0 +1,18 @@ +"""Model for the view records the renderer tracks.""" + +from pydantic import BaseModel, ConfigDict, Field + +from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState +from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState + + +class RuntimeViewState(BaseModel): + """The two records the renderer tracks: camera and plane. + + Neither has a default value: a file that carries either as null leaves the + server's record as it is, the same as a file that omits it. + """ + model_config = ConfigDict(populate_by_name=True) + + camera: VisorCameraState | None # None until the renderer first sets it + cross_section: VisorCrossSectionState | None = Field(alias="crossSection") # None until the renderer first sets it 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 9fab9687..5ac955bc 100644 --- a/src/ansys/visor/viewer/models/runtime/visor_scene_details.py +++ b/src/ansys/visor/viewer/models/runtime/visor_scene_details.py @@ -1,69 +1,46 @@ """Model for visor scene details, the data transfer object sent to the client.""" -from typing import Dict - 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.scene.runtime_scene_state import RuntimeSceneState +from ansys.visor.viewer.models.runtime.scene.runtime_ui_state import RuntimeUIState from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import RendererAnnotation from ansys.visor.viewer.models.runtime.vtk.runtime_vtk_info import RuntimeVTKInfo from ansys.visor.viewer.models.runtime.vtk.scene_graph_node_info import SceneGraphNodeInfo # Bumped whenever the scene-details wire shape changes incompatibly. -SCENE_DETAILS_SCHEMA_VERSION: int = 3 +SCENE_DETAILS_SCHEMA_VERSION: int = 4 class VisorSceneDetails(BaseModel): + """What a rebuilt or reconnecting client is served: the UI and scene records and the VTK info. + + Never carries the view records; the client receives the camera and the + plane through the VTK object state instead. + """ model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) schema_version: int = Field( default=SCENE_DETAILS_SCHEMA_VERSION, alias="schemaVersion" ) - app_state: RuntimeAppState | None = Field(default=None, alias="appState") + ui: RuntimeUIState + scene: RuntimeSceneState vtk_info: RuntimeVTKInfo | None = Field(default=None, alias="vtkInfo") @classmethod def from_components( cls, - ui: VisorUIState, - unit: str | None, - dataset_states: Dict[int, RuntimeDatasetState], + ui: RuntimeUIState, + scene: RuntimeSceneState, scene_graph_state: SceneGraphNodeInfo | None = None, renderer_annotation: RendererAnnotation | None = None, - orthographic_enabled: bool | None = None, - 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. - - The UI record is forwarded whole to - :meth:`RuntimeAppState.from_components`; this class does not build one - and does not read any field of it. - """ + """Assemble the details from the server's UI and scene records and the VTK info.""" vtk_info = RuntimeVTKInfo( scene_graph=scene_graph_state, renderer_annotation=renderer_annotation, ) - app_state = RuntimeAppState.from_components( - ui=ui, - unit=unit, - dataset_states=dataset_states, - orthographic_enabled=orthographic_enabled, - 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, - vtk_info=vtk_info, - ) + return cls(ui=ui, scene=scene, vtk_info=vtk_info) VisorSceneDetails.model_rebuild() diff --git a/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py b/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py index cdcaad60..a90b5a06 100644 --- a/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py +++ b/src/ansys/visor/viewer/vtk/datasets/visor_dataset.py @@ -1,6 +1,5 @@ """Class representing a Visor dataset.""" -import copy from collections.abc import Sequence from typing import Dict, List @@ -17,7 +16,10 @@ from ansys.visor.viewer.models.common.part_properties import PartProperties from ansys.visor.viewer.models.info.visor_dataset_info import VisorDatasetInfo from ansys.visor.viewer.models.persist.dataset.persisted_dataset_state import PersistedDatasetState -from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimeDatasetState +from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( + RuntimeDatasetState, + RuntimePartProperties, +) from ansys.visor.viewer.vtk.datasets.part_index import PartIndex from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables from ansys.visor.viewer.vtk.variables.visor_variable_update import VisorVariableUpdate @@ -44,8 +46,9 @@ def __init__(self, id: int, name: str, data: vtkDataObject, node_ids: Sequence[i # Get the dataset info from metadata self.info: VisorDatasetInfo = self._build_dataset_info(id, name, metadata) - # Build the initial runtime dataset state from persisted part names -> IDs via PartIndex - self.state: RuntimeDatasetState = self.persisted_to_runtime_state(metadata.state.parts) + # No records yet, so the metadata's part entries are overlaid onto the defaults. + self.state: RuntimeDatasetState = RuntimeDatasetState(id=id) + self.state = self.persisted_to_runtime_state(metadata.state.parts) # Per-part variable metadata, keyed by part_id. # Works for both composite and non-composite datasets. @@ -90,22 +93,6 @@ def list_variables(self) -> List[VisorPartVariables]: )) return result - def set_state(self, new_state: PersistedDatasetState) -> None: - """ - Set the state of the dataset from persisted, name-keyed part state. - - This is the persisted-state conversion path: it converts - new_state.parts (keyed by part name) into this dataset's runtime - state (keyed by part ID) via persisted_to_runtime_state. - - Note: a second, id-keyed replacement path also exists, on the - registry rather than here: VisorDatasetRegistry.replace_part_states - replaces a dataset's .state directly with an already-runtime, - id-keyed RuntimeDatasetState, without going through this method or - its name-to-id conversion. - """ - self.state = self.persisted_to_runtime_state(new_state.parts) - def mark_clean(self) -> None: """Clear the dirty flag after a successful snapshot write.""" self.is_dirty = False @@ -290,14 +277,24 @@ def persisted_to_runtime_state( self, parts_by_name: dict[str, PartProperties], ) -> RuntimeDatasetState: - """Convert persisted part states (keyed by part name) to runtime part states (keyed by part ID).""" + """A record for every part: its current record, or the default where it has none, overlaid with the + entry the file names for it. + + Entries are keyed by part name; names matching no part are dropped. + ``self.state`` is not modified: the records returned are new objects. + """ name_to_id = self.part_index.name_to_id_map + entries_by_id = { + name_to_id[part_name]: part_properties + for part_name, part_properties in parts_by_name.items() + if part_name in name_to_id + } + current = self.state.part_states part_states = {} - for part_name, part_properties in parts_by_name.items(): - part_id = name_to_id.get(part_name) - if part_id is not None: - part_states[part_id] = copy.deepcopy(part_properties) - return RuntimeDatasetState.from_components(id=self.id, part_states=part_states) + for part_id in self.part_index.part_ids: + record = current.get(part_id) or RuntimePartProperties.default_for(part_id) + part_states[part_id] = record.overlaid_with(entries_by_id.get(part_id, PartProperties())) + return RuntimeDatasetState(id=self.id, part_states=part_states) def runtime_to_persisted_state(self, runtime_state: RuntimeDatasetState) -> PersistedDatasetState: """Convert runtime state to persisted state (keyed by run ID).""" diff --git a/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py b/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py index ef89fb07..1a693688 100644 --- a/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py +++ b/src/ansys/visor/viewer/vtk/datasets/visor_dataset_registry.py @@ -5,11 +5,13 @@ from typing import Dict, List from ansys.visor.viewer.core.metadata import ExtendedMetadata +from ansys.visor.viewer.core.visor_colors import VisorPartDefaults from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.core.visor_types import VisorDatasetType from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( RuntimeDatasetState, RuntimePartProperties, + VariableReference, ) from ansys.visor.viewer.vtk.datasets.visor_dataset import VisorDataset from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables @@ -155,8 +157,8 @@ def _get_or_create_part_state(self, part_id: int) -> RuntimePartProperties | Non Resolve the live part-state record for part_id, upserting if needed. If the owning dataset has no part_states entry yet for part_id (e.g. - a freshly added dataset with no persisted part state), a - RuntimePartProperties(id=part_id) record is created and inserted + a freshly added dataset with no persisted part state), the default + record RuntimePartProperties.default_for(part_id) is created and inserted into the dataset's part_states dict first. This is mandatory: a freshly added dataset has an empty part_states dict, so without this upsert every setter would silently no-op for it. @@ -171,7 +173,7 @@ def _get_or_create_part_state(self, part_id: int) -> RuntimePartProperties | Non dataset = self.datasets[dataset_id] part_state = dataset.state.part_states.get(part_id) if part_state is None: - part_state = RuntimePartProperties(id=part_id) + part_state = RuntimePartProperties.default_for(part_id) dataset.state.part_states[part_id] = part_state return part_state @@ -203,9 +205,9 @@ def set_part_opacity(self, part_id: int, opacity: float) -> bool: part_state.opacity = opacity return True - def set_part_diffuse_color(self, part_id: int, diffuse_rgb: list[float] | None) -> bool: + def set_part_diffuse_color(self, part_id: int, diffuse_rgb: list[float]) -> bool: """ - Set a part's custom diffuse colour, or clear it with None. + Set a part's diffuse colour. Returns: bool: True when the record was written, False when part_id @@ -217,6 +219,20 @@ def set_part_diffuse_color(self, part_id: int, diffuse_rgb: list[float] | None) part_state.diffuse_rgb = diffuse_rgb return True + def reset_part_diffuse_color(self, part_id: int) -> bool: + """ + Write the default colour into the part's record. + + Returns: + bool: True when the record was written, False when part_id + resolves to no dataset. Never raises. + """ + part_state = self._get_or_create_part_state(part_id) + if part_state is None: + return False + part_state.diffuse_rgb = list(VisorPartDefaults.DiffuseRgb) + return True + def set_part_selected(self, part_id: int, selected: bool) -> bool: """ Set whether a part is selected. @@ -231,12 +247,12 @@ def set_part_selected(self, part_id: int, selected: bool) -> bool: part_state.selected = selected return True - def set_part_color_variable(self, part_id: int, variable_id: str, component: int | None) -> bool: + def set_part_color_variable(self, part_id: int, variable_id: str, component: int) -> bool: """ Set the variable a part is coloured by, and its component. - variable_id and component are set together, atomically, in this one - call, mirroring clear_part_color_variable's atomic clear. + Writes one VariableReference, so the identifier and the component + are only ever set together. Returns: bool: True when the record was written, False when part_id @@ -245,17 +261,14 @@ def set_part_color_variable(self, part_id: int, variable_id: str, component: int part_state = self._get_or_create_part_state(part_id) if part_state is None: return False - part_state.variable_id = variable_id - part_state.variable_component = component + part_state.color_variable = VariableReference(variable_id=variable_id, variable_component=component) return True def clear_part_color_variable(self, part_id: int) -> bool: """ Clear the variable a part is coloured by. - Sets variable_id and variable_component to None together, in one - call — the compound class is only ever set or cleared atomically, - never field by field. + Writes None, the resolved value "not coloured by a variable". Returns: bool: True when the record was written, False when part_id @@ -264,17 +277,18 @@ def clear_part_color_variable(self, part_id: int) -> bool: part_state = self._get_or_create_part_state(part_id) if part_state is None: return False - part_state.variable_id = None - part_state.variable_component = None + part_state.color_variable = None return True def replace_part_states(self, dataset_states: Dict[int, RuntimeDatasetState]) -> None: """ - Replace the runtime state of registered datasets in bulk. + Install runtime dataset states on registered datasets in bulk. For each dataset ID present in both dataset_states and the - registry, that dataset's .state is replaced directly with the - supplied RuntimeDatasetState (already-runtime, id-keyed input). + registry, that dataset's .state is set to the supplied + RuntimeDatasetState. On the load path that state is the dataset's + current records overlaid with the loaded entries, one record per + part (VisorDataset.persisted_to_runtime_state). Dataset IDs in dataset_states that are not present in the registry are skipped silently; processing continues for the remaining entries. Never raises. @@ -381,13 +395,19 @@ def _validate_unit(self, metadata: ExtendedMetadata) -> bool: return True + def set_unit(self, unit: str) -> None: + """ + Set the scene unit. + """ + self.unit = unit + def _update_unit(self, metadata: ExtendedMetadata): """ Update the unit to the specified value. """ if self._validate_unit(metadata): # store unit to this class - self.unit = metadata.unit + self.set_unit(metadata.unit) else: # reset unit to empty string self._reset_unit() @@ -396,5 +416,5 @@ def _reset_unit(self): """ Reset the unit to an empty string. """ - self.unit = "" + self.set_unit("") diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 4b39f67c..16ab53bb 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -11,12 +11,10 @@ from ansys.visor.viewer.core.metadata import ExtendedMetadata from ansys.visor.viewer.core.perf_timer import PerfTimer -from ansys.visor.viewer.core.visor_colors import VisorColors from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.core.visor_types import VisorDatasetType from ansys.visor.viewer.models.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 ( VisorVariableRecord, VisorVariableRecords, @@ -25,6 +23,9 @@ 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.scene.runtime_app_state import RuntimeAppState +from ansys.visor.viewer.models.runtime.scene.runtime_scene_state import RuntimeSceneState +from ansys.visor.viewer.models.runtime.scene.runtime_ui_state import RuntimeUIState +from ansys.visor.viewer.models.runtime.scene.runtime_view_state import RuntimeViewState 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 @@ -103,7 +104,7 @@ class VisorSceneBase(ABC): _cross_section_enabled: bool _edges_enabled: bool _bounding_box_enabled: bool - _ui_state: VisorUIState + _ui_state: RuntimeUIState _variable_records: VisorVariableRecords def __init__( @@ -130,13 +131,17 @@ def __init__( # Written only by the four panel triggers and the load path, always # as absolute values, never toggles. Projection is deliberately # absent here: it's derived from the camera record, not stored twice. - self._ui_state = VisorUIState( + # + # The same values are the defaults a loaded file's null UI field + # resolves to; the mapper keeps its own copy of them below. + ui_defaults = RuntimeUIState( dark_theme=dark_mode, panel_top_left_panel_collapsed=False, panel_top_right_panel_collapsed=False, panel_top_right_legend_collapsed=False, panel_top_right_tab_index=0, ) + self._ui_state = ui_defaults.model_copy() # The server's variable records, keyed by composed identifier. Created once # here and never rebound. Written only by _rebuild_variable_records_from_registry @@ -183,7 +188,7 @@ def __init__( ) self._initialize_scene_graph() - self._state_mapper = VisorStateMapper(self._dataset_registry) + self._state_mapper = VisorStateMapper(self._dataset_registry, ui_defaults) # ========================================================================= # Abstract hook — subclasses differ on state delivery @@ -210,20 +215,16 @@ def dataset_count(self) -> int: return self._dataset_registry.count @property - def dark_mode(self) -> bool | None: + def dark_mode(self) -> bool: """Whether the viewer is in dark theme. A property over the UI record's ``dark_theme`` field rather than a separate attribute, so the theme has exactly one holder. - - Typed ``bool | None`` to match that field: the constructor always - supplies a ``bool``, but :meth:`apply_state` writes - ``state.ui.dark_theme`` through unguarded, and that field is optional. """ return self._ui_state.dark_theme @dark_mode.setter - def dark_mode(self, dark_mode: bool | None) -> None: + def dark_mode(self, dark_mode: bool) -> None: """Set the theme on the UI record.""" self._ui_state.dark_theme = dark_mode @@ -242,71 +243,69 @@ def get_state(self) -> PersistedViewerStateV1: return it as a :class:`PersistedViewerStateV1`. No client is consulted, so a save works with no browser connected. - The runtime state starts empty and every one of its fields is assigned - below; a field left unassigned here would be saved at its model default. + The records are read by :meth:`_current_runtime_state`, which the load + path also reads as the base it overlays a file onto. + """ + return self._state_mapper.runtime_to_persisted(self._current_runtime_state()) + + def _current_runtime_state(self) -> RuntimeAppState: + """ + Snapshot the server's own records as a :class:`RuntimeAppState`; takes ``_vtk_lock``. Per-part state comes from the registry. The registry hands out live ``RuntimeDatasetState`` objects that the per-part setters mutate from the - trame daemon thread, so each one is deep-copied under ``_vtk_lock``. + trame daemon thread, so each one is deep-copied under the lock. The widget toggles come from this object's own store, and the UI record is a copy of this object's own record, so a panel trigger landing after - the call cannot mutate the state already returned. + the call cannot mutate the snapshot. The camera comes from the renderer's record, which is authoritative, rather than from the pipeline ``vtkCamera``: the pipeline is the record's projection, and reading it back would re-import whatever drift - VTK introduced -- ``ResetCamera`` rewrites ``clipping_range``. The - assignment is unconditional. A ``None`` record means no camera was ever - written, and writing that ``None`` through is what says so; the guard for - "absent says nothing" belongs to the load path, in :meth:`apply_state`, - not here. - - ``orthographic_enabled`` is derived from that same record, not stored - separately, so it can't disagree with the camera. ``None`` means - "nothing was ever written." + VTK introduced -- ``ResetCamera`` rewrites ``clipping_range``. A + ``None`` record means no camera was ever written. The cross-section + plane is taken from the renderer's record on the same terms. Both are + deep copies: the renderer writes its camera record in place on a + projection change. - The cross-section plane is the camera's twin, taken from the - renderer's record on exactly the same terms -- see the camera - paragraph above for why the assignment is unconditional and what - ``None`` means. In normal operation there is no ``None`` case to - guard because the renderer seeds the record from its own widget the - first time bounds are pushed. + ``orthographic_enabled`` is derived from the camera record, not stored + separately, so it can't disagree with the camera. Variables and unit are the server's: the variable records and the registry's unit. """ - runtime_state = RuntimeAppState() - with self._vtk_lock: - registry_dataset_states = { + dataset_states = { dataset_id: dataset_state.model_copy(deep=True) for dataset_id, dataset_state in self._dataset_registry.runtime_state_dict.items() } - camera_record = self._renderer.get_camera_state() - runtime_state.scene.camera = camera_record - runtime_state.scene.orthographic_enabled = ( - camera_record.parallel_projection if camera_record is not None else None + camera = self._renderer.get_camera_state() + cross_section = self._renderer.get_cross_section_plane() + camera = camera.model_copy(deep=True) if camera is not None else None + cross_section = cross_section.model_copy(deep=True) if cross_section is not None else None + return RuntimeAppState( + ui=self._ui_state.model_copy(), + view=RuntimeViewState(camera=camera, cross_section=cross_section), + scene=RuntimeSceneState( + unit=self._dataset_registry.unit, + orthographic_enabled=camera.parallel_projection if camera is not None else None, + cross_section_enabled=self._cross_section_enabled, + edges_enabled=self._edges_enabled, + bounding_box_enabled=self._bounding_box_enabled, + dataset_states=dataset_states, + variable_states=self._variable_records.model_copy(deep=True).variables, + ), ) - runtime_state.scene.cross_section = self._renderer.get_cross_section_plane() - runtime_state.scene.cross_section_enabled = self._cross_section_enabled - 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() - 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) - return persisted def apply_state(self, state: PersistedViewerStateV1): """ Apply a saved viewer state. - One ``_restore_*`` step per state class, each making the server's own - copy of that class match the loaded state: its stored state, and the - VTK objects that the state drives. + The mapper overlays the file onto the server's current state, once; + each ``_restore_*`` step then writes what it resolved, unconditionally: + the server's own copy of that class, and the VTK objects that the + state drives. The renderer-speific delivery step is delegated to :meth:`_push_runtime_state`, and runs last, once every record above it has been written. @@ -314,8 +313,7 @@ def apply_state(self, state: PersistedViewerStateV1): Holds ``_vtk_lock`` for the whole body, including the delegated render step. """ with self._vtk_lock: - # Transform the frontend PersistedViewerStateV1 -> RuntimeAppState - runtime_app_state = self._state_mapper.persisted_to_runtime(state) + runtime_app_state = self._state_mapper.persisted_to_runtime(state, self._current_runtime_state()) # Variable records: rebuilt from the registry and overlaid with the file's # ranges, then placed on the runtime state for the push, all before @@ -325,9 +323,9 @@ def apply_state(self, state: PersistedViewerStateV1): # 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) + self._restore_scene_state(runtime_app_state.scene) + self._restore_ui_state(runtime_app_state.ui) + self._restore_view_state(runtime_app_state.view) # 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 @@ -341,7 +339,7 @@ def apply_state(self, state: PersistedViewerStateV1): # fire-and-forget set_state. See VisorLocalScene._push_runtime_state, which is written on # the assumption that the full render and wasm sync have already happened by the time it runs. # - # Correct only after _restore_widget_state: populate_scene reaches IRenderer.update_bounds, + # Correct only after _restore_view_state: populate_scene reaches IRenderer.update_bounds, # which writes an existing cross-section record back to the widget rather than seeding from # the widget's defaults. With no record -- which is what the load path had on a cold load -- # that branch seeds the defaults instead. @@ -371,7 +369,9 @@ def get_scene_details(self) -> VisorSceneDetails: aren't consumed as a mutually consistent snapshot, and locking a request-path read against the trigger thread belongs with the round-trip/thread-affinity work, not here. Accepted exposure: one - stale field in a delivered payload. + stale field in a delivered payload. For the same reason this does not + read :meth:`_current_runtime_state`, which takes the lock and also + reads the plane, a record the details never carry. 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 @@ -388,22 +388,24 @@ def get_scene_details(self) -> VisorSceneDetails: camera_record = self._renderer.get_camera_state() return VisorSceneDetails.from_components( ui=self._ui_state.model_copy(), - unit=self._dataset_registry.unit, - dataset_states=self._dataset_registry.runtime_state_dict, + scene=RuntimeSceneState( + unit=self._dataset_registry.unit, + orthographic_enabled=( + camera_record.parallel_projection if camera_record is not None else None + ), + cross_section_enabled=self._cross_section_enabled, + edges_enabled=self._edges_enabled, + bounding_box_enabled=self._bounding_box_enabled, + dataset_states=self._dataset_registry.runtime_state_dict, + variable_states=self._variable_records.model_copy(deep=True).variables, + ), scene_graph_state=scene_graph_state, renderer_annotation=annotation, - orthographic_enabled=( - camera_record.parallel_projection if camera_record is not None else None - ), - 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: """Return the VisorVtkPipelineState as JSON string.""" - return json.dumps(self.get_scene_details().model_dump(exclude_none=True, by_alias=True)) + return json.dumps(self.get_scene_details().model_dump(by_alias=True)) def clear(self): """Remove all actors from the renderer and reset the scene. @@ -466,6 +468,12 @@ def update_widgets(self): def add_dataset(self, input: VisorDatasetType, metadata: ExtendedMetadata) -> int: """Set the input dataset and metadata for the scene graph. + Each part's record, seeded from the metadata when the dataset is + registered, is applied to that part's pipeline through + :meth:`_apply_part_state` once the variable records are rebuilt, so a + colour reference resolves. Each apply re-serializes the object it + wrote; nothing is pushed from here. + Holds ``_vtk_lock``: node registration attaches actors to the VTK renderer. """ @@ -503,6 +511,15 @@ def add_dataset(self, input: VisorDatasetType, metadata: ExtendedMetadata) -> in self._dataset_registry.add(dataset_id, dataset_name, input, node_ids, metadata) self._rebuild_variable_records_from_registry() + dataset = self._dataset_registry.datasets.get(dataset_id) + if dataset is None: + logger.warning( + "add_dataset: dataset %s is not registered; its part state was not applied.", dataset_id + ) + else: + for part_id, part_state in dataset.state.part_states.items(): + self._apply_part_state(part_id, part_state) + return dataset_id def remove_dataset(self, dataset_id: int): @@ -586,8 +603,8 @@ def _reconcile_part_color_variables(self, previous: Dict[str, VisorVariableRecor """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 + Only a part with a colour-variable reference is reconciled. 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 @@ -595,10 +612,10 @@ def _reconcile_part_color_variables(self, previous: Dict[str, VisorVariableRecor mapper and re-serializes it. """ references = [ - (part_id, part_state.variable_id, part_state.variable_component) + (part_id, part_state.color_variable.variable_id, part_state.color_variable.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 + if part_state.color_variable is not None ] for part_id, variable_id, component in references: binding = self._resolve_color_variable(part_id, variable_id, component) @@ -754,20 +771,20 @@ def set_part_opacity(self, node_id: int, opacity: float) -> None: def set_part_diffuse_color(self, node_id: int, diffuse_rgb: list[float] | None) -> None: """ Set the custom diffuse colour of the part identified by *node_id*, or - clear it with ``None``. + reset it with ``None``. - The store records the absence as absence: ``diffuse_rgb=None`` is - written through as ``None``. The pipeline needs a concrete colour, so - the apply falls back to :attr:`VisorColors.DefaultMeshColor` — "no - custom colour" means "the scene-wide default". + A reset writes the default colour into the record. Either way the + pipeline is given the colour the record then holds. """ with self._vtk_lock: - if not self._dataset_registry.set_part_diffuse_color(node_id, diffuse_rgb): + if diffuse_rgb is None: + written = self._dataset_registry.reset_part_diffuse_color(node_id) + else: + written = self._dataset_registry.set_part_diffuse_color(node_id, diffuse_rgb) + if not written: logger.debug("set_part_diffuse_color: no dataset owns node %s; skipping.", node_id) return - applied_rgb = ( - diffuse_rgb if diffuse_rgb is not None else list(VisorColors.DefaultMeshColor) - ) + applied_rgb = self._dataset_registry.get_part_state(node_id).diffuse_rgb self._renderer.apply_diffuse_color( node_id, applied_rgb[0], applied_rgb[1], applied_rgb[2] ) @@ -777,9 +794,8 @@ def set_part_selected(self, node_id: int, selected: bool) -> None: Select or deselect the part identified by *node_id*. No colour crosses the trigger for this class: the server reads the - part's stored ``diffuse_rgb`` from its own record and falls back to - :attr:`VisorColors.DefaultMeshColor` when it is ``None``. The record - is guaranteed to exist here — the setter above returned ``True``, + part's stored ``diffuse_rgb`` from its own record. The record is + guaranteed to exist here — the setter above returned ``True``, which means it either found the record or upserted one — so ``get_part_state`` cannot return ``None`` at this point. """ @@ -787,10 +803,7 @@ def set_part_selected(self, node_id: int, selected: bool) -> None: if not self._dataset_registry.set_part_selected(node_id, selected): logger.debug("set_part_selected: no dataset owns node %s; skipping.", node_id) return - stored_rgb = self._dataset_registry.get_part_state(node_id).diffuse_rgb - diffuse_rgb = ( - stored_rgb if stored_rgb is not None else list(VisorColors.DefaultMeshColor) - ) + diffuse_rgb = self._dataset_registry.get_part_state(node_id).diffuse_rgb self._renderer.apply_selected(node_id, selected, diffuse_rgb) def set_part_color_variable( @@ -877,7 +890,12 @@ def set_variable_range( 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: + reference = part_state.color_variable + if ( + reference is None + or reference.variable_id != variable_id + or reference.variable_component != component + ): continue binding = self._resolve_color_variable(part_id, variable_id, component) if binding is not None: @@ -1041,8 +1059,9 @@ def _restore_part_states(self, runtime_app_state: "RuntimeAppState") -> None: """ Restore per-part state from a runtime app state, on the load path. - Replaces the part states of each dataset named in the supplied states, - then applies every part to this process's VTK pipeline through ``IRenderer``. + Installs the part records of each dataset named in the supplied states, which + are that dataset's current records overlaid with the loaded entries, one per + part. Then applies every part to this process's VTK pipeline through ``IRenderer``. Datasets the registry does not hold are skipped and logged. Every other failure is a logged no-op. @@ -1062,163 +1081,93 @@ def _restore_part_states(self, runtime_app_state: "RuntimeAppState") -> None: continue for part_id, part_state in dataset_state.part_states.items(): - self._restore_one_part_state(part_id, part_state) + self._apply_part_state(part_id, part_state) - def _restore_camera_state(self, runtime_app_state: "RuntimeAppState") -> None: + def _restore_scene_state(self, scene: RuntimeSceneState) -> None: """ - Restore the camera state from a runtime app state, on the load path. - - Write the loaded camera to the record and the pipeline camera, so a client rebuilt - from server state (refresh) gets it. Must precede the render step. The renderer's - ``sync_camera`` leaves the served state current on return: the server advertises the - camera's live MTime but serves its cached state, so a stale cache would hand a client - the pre-load camera. A state with no camera leaves both alone. + Write the resolved unit and the three widget toggles, on the load path. + + The unit is written unconditionally; the mapper has already resolved + it against the server's current state. Each toggle is written only + when it differs from the server's current value: the mapper resolves + an absent file field to the current one, so a no-op restore of an + unnamed toggle would otherwise still re-apply and re-serialize it -- + for edges, every registered part's actor property -- interleaving + unrelated writes between the camera and plane steps + :meth:`_restore_view_state` performs next. A toggle the file does + change still goes through its coordinator, store first, renderer + second. + + ``orthographic_enabled`` is not written. Projection arrives on the + camera, restored by :meth:`_restore_view_state`. Callers must hold ``_vtk_lock``. """ - if runtime_app_state.scene.camera is not None: - self._renderer.sync_camera(runtime_app_state.scene.camera) - - def _restore_widget_state(self, runtime_app_state: "RuntimeAppState") -> None: + self._dataset_registry.set_unit(scene.unit) + if scene.cross_section_enabled != self._cross_section_enabled: + self.set_cross_section_visibility(scene.cross_section_enabled) + if scene.edges_enabled != self._edges_enabled: + self.set_edges_visible(scene.edges_enabled) + if scene.bounding_box_enabled != self._bounding_box_enabled: + self.set_bounding_box_visibility(scene.bounding_box_enabled) + + def _restore_view_state(self, view: RuntimeViewState) -> None: """ - Restore the camera state from a runtime app state, on the load path. - - The widget toggles. Absent says nothing: a state that does not - carry a toggle leaves the server's value alone, which is this - path's guard and not get_state's. Store first, renderer second, - matching the coordinator surface below. - + Write the resolved plane and camera, on the load path. - ``orthographic_enabled`` is deliberately not read. Projection - arrives on the camera, whose sync_camera writes it to the - record and the pipeline; the persisted toggle is emitted for - compatibility and ignored here, because two readers of one - property is the divergence this story removed. + Each goes through its coordinator, which writes the renderer's record + and the VTK objects; the renderer leaves the state it serves current + on return, so a client rebuilt from server state gets them. Must + precede the render step. - The cross-section plane is restored here too, and both-or-neither: - ``sync_cross_section_plane`` takes an origin and a normal together - and the model allows either to be absent, so a half-plane says - nothing rather than half-applying. The renderer's - ``sync_cross_section_plane`` leaves the served state current on - return, for the reason :meth:`sync_cross_section_plane` gives -- a - loaded plane whose served state is stale leaves the client fetching - the pre-load one. + A ``None`` record is skipped: the renderer had none to resolve against + and the file supplied none, so there is nothing to write. Callers must hold ``_vtk_lock``. """ - scene = runtime_app_state.scene - if scene.cross_section_enabled is not None: - self._cross_section_enabled = scene.cross_section_enabled - self._renderer.set_cross_section_visibility(scene.cross_section_enabled) - if scene.edges_enabled is not None: - self._edges_enabled = scene.edges_enabled - self._renderer.set_edges_visible(scene.edges_enabled) - if scene.bounding_box_enabled is not None: - self._bounding_box_enabled = scene.bounding_box_enabled - self._renderer.set_bounding_box_visibility(scene.bounding_box_enabled) - if scene.cross_section is not None: - cs = scene.cross_section - if cs.origin is not None and cs.normal is not None: - self._renderer.sync_cross_section_plane(cs.origin, cs.normal) - - def _restore_ui_state(self, runtime_app_state: "RuntimeAppState") -> None: - """ - Restore the UI panel layout from a runtime app state, on the load path. + if view.cross_section is not None: + self.sync_cross_section_plane(view.cross_section.origin, view.cross_section.normal) + if view.camera is not None: + self.sync_camera(view.camera) - Absent says nothing: a state that does not carry a panel field leaves - the server's value alone. Four independent guards and not one, because - a file can carry any subset -- anything written before this record - existed carries none of them. + def _restore_ui_state(self, ui: RuntimeUIState) -> None: + """ + Replace the UI record with a copy of the resolved one, on the load path. Callers must hold ``_vtk_lock``. """ - ui = runtime_app_state.ui - - if ui.dark_theme is not None: - self.dark_mode = ui.dark_theme - if ui.panel_top_left_panel_collapsed is not None: - self._ui_state.panel_top_left_panel_collapsed = ui.panel_top_left_panel_collapsed - if ui.panel_top_right_panel_collapsed is not None: - self._ui_state.panel_top_right_panel_collapsed = ui.panel_top_right_panel_collapsed - if ui.panel_top_right_legend_collapsed is not None: - self._ui_state.panel_top_right_legend_collapsed = ui.panel_top_right_legend_collapsed - 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: RuntimePartProperties) -> None: - """ - Apply one restored part record to the pipeline. + self._ui_state = ui.model_copy() - A ``None`` field means ""this record says nothing about that property", - not "reset it to the default", so nothing is applied for it. - - The color is validated to exactly three elements here; a malformed - color is a logged no-op and the same guard covers the color the - selection branch reads. The registry keeps the malformed value it was - loaded with; requiring on load would make the next save silently - rewrite the user's file. + def _apply_part_state(self, part_id: int, part_state: RuntimePartProperties) -> None: """ - if part_state.visible is not None: - self._renderer.apply_visibility(part_id, part_state.visible) - - if part_state.opacity is not None: - self._renderer.apply_opacity(part_id, part_state.opacity) - - stored_rgb = part_state.diffuse_rgb - valid_rgb = None - if stored_rgb is not None: - if len(stored_rgb) == 3: - valid_rgb = stored_rgb - else: - logger.warning( - "_restore_one_part_state: part %s has a diffuse colour of %s elements, " - "not 3; leaving the pipeline colour unchanged.", part_id, len(stored_rgb) - ) - - if valid_rgb is not None: - self._renderer.apply_diffuse_color(part_id, valid_rgb[0], valid_rgb[1], valid_rgb[2]) + Apply one part record to the pipeline, every field; caller holds ``_vtk_lock``. + """ + self._renderer.apply_visibility(part_id, part_state.visible) + self._renderer.apply_opacity(part_id, part_state.opacity) - if part_state.selected is not None: - selection_rgb = ( - valid_rgb if valid_rgb is not None else list(VisorColors.DefaultMeshColor) - ) - self._renderer.apply_selected(part_id, part_state.selected, selection_rgb) + rgb = part_state.diffuse_rgb + self._renderer.apply_diffuse_color(part_id, rgb[0], rgb[1], rgb[2]) + self._renderer.apply_selected(part_id, part_state.selected, rgb) - self._restore_part_color_variable(part_id, part_state) + self._apply_part_color_variable(part_id, part_state) - def _restore_part_color_variable(self, part_id: int, part_state: RuntimePartProperties) -> None: + def _apply_part_color_variable(self, part_id: int, part_state: RuntimePartProperties) -> None: """ - 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. + Apply one part's color-variable reference through the resolve helper, or clear it. - 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 + No reference clears the mapper. A reference resolves against the + held records, which must already be current when this runs. A + refused reference is a logged no-op; the mapper is re-serialized after an applied or cleared one. """ - variable_id = part_state.variable_id - component = part_state.variable_component - - if variable_id is None: - if component is not None: - logger.warning( - "_restore_part_color_variable: part %s stores component %s with no variable " - "identifier; not clearing and not applying.", part_id, component - ) - return + reference = part_state.color_variable + if reference is None: self._renderer.clear_color_variable(part_id) return - if component is None: - logger.warning( - "_restore_part_color_variable: part %s stores variable '%s' with no component; " - "skipping.", part_id, variable_id - ) - return - - binding = self._resolve_color_variable(part_id, variable_id, component) + binding = self._resolve_color_variable( + part_id, reference.variable_id, reference.variable_component + ) if binding is None: return binding.apply_to(self._renderer) 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 9a322beb..e881af0c 100644 --- a/src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py +++ b/src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py @@ -3,7 +3,7 @@ This module defines :class:`VisorStateMapper`, which is responsible for converting between: -- frontend/runtime state (:class:`~ansys.visor.viewer.models.runtime.scene.runtime_app_state.RuntimeAppState`) +- the server's runtime state (:class:`~ansys.visor.viewer.models.runtime.scene.runtime_app_state.RuntimeAppState`) - persisted viewer state (:class:`~ansys.visor.viewer.models.persist.persisted_viewer_state.PersistedViewerStateV1`) The dataset registry is required to look up :class:`VisorDataset` objects by ID or @@ -15,55 +15,61 @@ from __future__ import annotations 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.common.visor_cross_section_state import VisorCrossSectionState from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState +from ansys.visor.viewer.models.runtime.scene.runtime_scene_state import RuntimeSceneState +from ansys.visor.viewer.models.runtime.scene.runtime_ui_state import RuntimeUIState +from ansys.visor.viewer.models.runtime.scene.runtime_view_state import RuntimeViewState from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry logger = VisorDefaultLogger(__name__) class VisorStateMapper: - """Convert between runtime and persisted viewer state.""" + """Convert between runtime and persisted viewer state. - def __init__(self, dataset_registry: VisorDatasetRegistry): - """Initialize the mapper.""" + Loading resolves the file against the server's current state here and + nowhere else. + """ + + def __init__(self, dataset_registry: VisorDatasetRegistry, ui_defaults: RuntimeUIState): + """Initialize the mapper. + + *ui_defaults* is the UI record a null field in a loaded file resolves + to. It is copied, so later writes to the caller's record do not reach it. + """ if dataset_registry is None: raise ValueError("dataset_registry is required") + if ui_defaults is None: + raise ValueError("ui_defaults is required") self._dataset_registry = dataset_registry + self._ui_defaults = ui_defaults.model_copy() def runtime_to_persisted(self, runtime_app_state: RuntimeAppState) -> PersistedViewerStateV1: - """Convert frontend RuntimeAppState to PersistedViewerStateV1. - - Logic parity with ``VisorScene._runtime_to_persisted``. - """ - # UI - ui_state = runtime_app_state.ui - - # Scene + """Convert the server's RuntimeAppState to PersistedViewerStateV1.""" scene_state = runtime_app_state.scene - # Scene - unit - unit = scene_state.unit - # Scene - camera - camera = scene_state.camera + view_state = runtime_app_state.view + # 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 = {} - for dataset_id, dataset_state in runtime_dataset_states.items(): + for dataset_id, dataset_state in scene_state.dataset_states.items(): dataset = self._dataset_registry.datasets.get(dataset_id) if dataset is None: continue persisted_dataset_states[dataset.name] = dataset.runtime_to_persisted_state(dataset_state) return PersistedViewerStateV1.from_components( - ui_state=ui_state, - camera=camera, - unit=unit, - cross_section=scene_state.cross_section, + ui_state=runtime_app_state.ui.to_persisted(), + camera=view_state.camera, + unit=scene_state.unit, + cross_section=view_state.cross_section, orthographic_enabled=scene_state.orthographic_enabled, cross_section_enabled=scene_state.cross_section_enabled, edges_enabled=scene_state.edges_enabled, @@ -72,56 +78,63 @@ def runtime_to_persisted(self, runtime_app_state: RuntimeAppState) -> PersistedV variable_states=variable_states, ) - def persisted_to_runtime(self, state: PersistedViewerStateV1) -> RuntimeAppState: - """Convert PersistedViewerStateV1 back to runtime RuntimeAppState. - - Logic parity with ``VisorScene._persisted_to_runtime``. + def persisted_to_runtime(self, state: PersistedViewerStateV1, current: RuntimeAppState) -> RuntimeAppState: + """Overlay a loaded file onto the server's *current* state. - Returns the runtime dataset states on the ``RuntimeAppState``; it does not - assign them to ``VisorDataset.state``. The registry is populated by - :meth:`VisorSceneBase._restore_part_states`. - - """ - # UI settings. Crossed whole: the server owns every field on the - # record (theme and the four panel-layout fields alike), so it is - # never split here. - ui_state = state.ui + Each scene-level field the file does not name keeps its current value; + one it names as null takes its default; one it names with a value + takes that value. The UI defaults are the ones this mapper was built + with, the scene defaults are :meth:`RuntimeSceneState.default`. - # scene state - scene_state = state.scene + The camera and the plane have no default value: null is treated as + absent and the current record is kept. A plane missing its origin or + its normal is treated the same way. - # scene - unit - unit = scene_state.unit + ``orthographic_enabled`` is derived from the resolved camera; the + file's flag is not read. - # scene - camera - camera = scene_state.camera + The dataset states are the file's datasets only, each the dataset's + current records overlaid with its loaded entries; a dataset the file + does not name is left to its current records. They are returned, not + assigned to ``VisorDataset.state``; :meth:`VisorSceneBase._restore_part_states` + installs them. The variable states are empty: the load path fills + them from the server's rebuilt records. + """ + ui = current.ui.overlaid_with(state.ui, self._ui_defaults) - # 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 = {} + view = RuntimeViewState( + camera=self._resolved_camera(state.scene.camera, current.view.camera), + cross_section=self._resolved_plane(state.scene.cross_section, current.view.cross_section), + ) - # scene - datasets: Convert persisted dataset states to runtime runtime_dataset_states = {} - for dataset_name, persisted_dataset_state in scene_state.dataset_states.items(): - # dataset_key is the dataset name - - # Get the VisorDataset object by name from the registry + for dataset_name, persisted_dataset_state in state.scene.dataset_states.items(): dataset = self._dataset_registry.get_by_name(dataset_name) if dataset is None: logger.warning(f"Dataset with name '{dataset_name}' not found in registry. Skipping.") continue - runtime_state = dataset.persisted_to_runtime_state(persisted_dataset_state.parts) - runtime_dataset_states[dataset.id] = runtime_state - - return RuntimeAppState.from_components( - ui=ui_state, - unit=unit, - camera=camera, - cross_section=scene_state.cross_section, - orthographic_enabled=scene_state.orthographic_enabled, - cross_section_enabled=scene_state.cross_section_enabled, - edges_enabled=scene_state.edges_enabled, - bounding_box_enabled=scene_state.bounding_box_enabled, - dataset_states=runtime_dataset_states, - variable_states=variable_states, - ) + runtime_dataset_states[dataset.id] = dataset.persisted_to_runtime_state(persisted_dataset_state.parts) + + scene = current.scene.overlaid_with(state.scene, RuntimeSceneState.default()).model_copy(update={ + "orthographic_enabled": view.camera.parallel_projection if view.camera is not None else None, + "dataset_states": runtime_dataset_states, + "variable_states": {}, + }) + + return RuntimeAppState(ui=ui, view=view, scene=scene) + + @staticmethod + def _resolved_camera( + persisted: VisorCameraState | None, current: VisorCameraState | None + ) -> VisorCameraState | None: + """The file's camera, or the current one where the file has none.""" + return persisted if persisted is not None else current + + @staticmethod + def _resolved_plane( + persisted: VisorCrossSectionState | None, current: VisorCrossSectionState | None + ) -> VisorCrossSectionState | None: + """The file's plane, or the current one where the file has none or only half of one.""" + if persisted is None or persisted.origin is None or persisted.normal is None: + return current + return persisted diff --git a/src/ansys/visor/visor-client/src/App.tsx b/src/ansys/visor/visor-client/src/App.tsx index 12f3bbde..d43ee812 100644 --- a/src/ansys/visor/visor-client/src/App.tsx +++ b/src/ansys/visor/visor-client/src/App.tsx @@ -140,6 +140,7 @@ function App() { newFrontend = new VisorFrontend( renderer, sceneDetails.vtkInfo.sceneGraph, + sceneDetails.appState.scene, wasmView.current.trameTriggerAsync ); // Release the frontend being replaced, here and not later: the diff --git a/src/ansys/visor/visor-client/src/VisorFrontend.tsx b/src/ansys/visor/visor-client/src/VisorFrontend.tsx index dc8b75f5..3a8f5974 100644 --- a/src/ansys/visor/visor-client/src/VisorFrontend.tsx +++ b/src/ansys/visor/visor-client/src/VisorFrontend.tsx @@ -4,6 +4,7 @@ import { getPromiseResolver } from './utils/JsHelpers'; import VisorAppState from './state/appstate/VisorAppState.tsx'; import VisorDatasetState from './state/appstate/VisorDatasetState.tsx'; import VisorPartState from './state/appstate/VisorPartState.tsx'; +import VisorSceneState from './state/appstate/VisorSceneState.tsx'; import { TreeViewUtil } from './treeview/TreeView.tsx'; import { StateInput } from './state/appstate/VisorStateCommon.tsx'; import VisorVtkSceneNode from './state/appstate/vtkInfo/VisorVtkSceneNode.tsx'; @@ -21,10 +22,16 @@ export class VisorFrontend { constructor( renderer: IRenderer, sceneGraphNode: VisorVtkSceneNode, + deliveredScene: VisorSceneState, triggerSender: TrameTriggerSender ) { const variableManager = getVariableManager(); - const sceneGraph = CreateVisorSceneGraph(sceneGraphNode, variableManager, renderer); + const sceneGraph = CreateVisorSceneGraph( + sceneGraphNode, + deliveredScene, + variableManager, + renderer + ); variableManager.finishAddingDataArrayMetadata(); renderer.attachSceneGraph(sceneGraph); @@ -486,13 +493,13 @@ export class VisorFrontend { } // `selected` is applied to the node, like every other // part field, so the node owns the value and both the - // renderer and the tree read it from there. `=== true` - // holds the current semantics: a part state carrying - // null (never touched) deselects, and the node always - // receives a boolean. - partPromise = partPromise.then(() => - partNode.setSelectedAsync(part_state.selected === true) - ); + // renderer and the tree read it from there. + if (part_state.selected !== undefined) { + const selected = part_state.selected; + partPromise = partPromise.then(() => + partNode.setSelectedAsync(selected) + ); + } promises.push(partPromise); if (part_state.opacity !== undefined) { const promise = node.setOpacityAsync(part_state.opacity); @@ -502,10 +509,8 @@ export class VisorFrontend { const promise = node.setVisibilityAsync(part_state.visible); promises.push(promise); } - if ( - part_state.variableId !== undefined && - part_state.variableComponent !== undefined - ) { + // A null reference is delivered without a component. + if (part_state.variableId !== undefined) { let promise; if (part_state.variableId !== null) { promise = node.setColorVariableAsync( diff --git a/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts b/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts index 382762ee..e56dc68e 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts +++ b/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts @@ -1,6 +1,7 @@ import { VisorFrontend } from '../VisorFrontend'; import type { IRenderer } from '../renderer/IRenderer'; import type VisorVtkSceneNode from '../state/appstate/vtkInfo/VisorVtkSceneNode.tsx'; +import { deliveredScene } from './deliveredSceneFixture.ts'; /** * The projection load path: `setAppStateAsync` applies projection from @@ -8,7 +9,7 @@ import type VisorVtkSceneNode from '../state/appstate/vtkInfo/VisorVtkSceneNode. * widget flag. * * Two persisted fields used to write the same wasm camera property -- - * `scene.orthographicEnabled` and `scene.camera.parallelProjection` -- both + * `scene.orthographicEnabled` and `view.camera.parallelProjection` -- both * pushed into one unordered promise array, with only the first also setting * the cached flag `getAppStateAsync` reads back. Which one won was * unspecified. This module pins that there is now exactly one writer, that it @@ -97,6 +98,7 @@ function makeFrontend(): { frontend: VisorFrontend; renderer: RendererDouble } { const frontend = new VisorFrontend( renderer as unknown as IRenderer, makeSceneGraphNode() as unknown as VisorVtkSceneNode, + deliveredScene(), jest.fn(async () => undefined) ); return { frontend, renderer }; @@ -110,6 +112,8 @@ describe('setAppStateAsync applies projection from the camera alone', () => { { scene: { orthographicEnabled: PERSISTED_ORTHOGRAPHIC_ENABLED, + }, + view: { camera: { parallelProjection: CAMERA_PARALLEL_PROJECTION }, }, }, @@ -131,6 +135,8 @@ describe('setAppStateAsync applies projection from the camera alone', () => { { scene: { orthographicEnabled: true, + }, + view: { camera: {}, }, }, diff --git a/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx new file mode 100644 index 00000000..72c1a8cc --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx @@ -0,0 +1,221 @@ +import { VisorFrontend } from '../VisorFrontend'; +import type { IRenderer } from '../renderer/IRenderer'; +import type VisorVtkSceneNode from '../state/appstate/vtkInfo/VisorVtkSceneNode.tsx'; +import type VisorSceneState from '../state/appstate/VisorSceneState.tsx'; +import { deliveredPart, deliveredScene } from './deliveredSceneFixture.ts'; + +/** + * The client applies the state it is delivered and holds no values of its + * own: a part node is built from its delivered record, a delivered null + * colour variable removes the colouring, a delivered colour that matches the + * node sends nothing, and a reference to a variable the client does not hold + * is recorded and reported rather than applied. + * + * Expected values are hand-written literals. + */ + +const PART_A_ID = 1; +const PRESSURE_ID = 'POINT::pressure::1'; +const TENSOR5_ID = 'POINT::tensor5::5'; + +/** A complete wire record for `pressure`, listing part A. */ +function pressureRecord(): Record { + return { + id: PRESSURE_ID, + arrayName: 'pressure', + type: 'POINT', + numComponents: 1, + partIds: [PART_A_ID], + defaultMagnitudeRange: [0, 10], + defaultRanges: [[0, 10]], + magnitudeRange: [2, 8], + ranges: [[2, 8]], + }; +} + +/** A complete wire record of a width the client has no labels for, so it is not held. */ +function tensor5Record(): Record { + return { + id: TENSOR5_ID, + arrayName: 'tensor5', + type: 'POINT', + numComponents: 5, + partIds: [PART_A_ID], + defaultMagnitudeRange: [0, 1], + defaultRanges: [ + [0, 1], + [0, 1], + [0, 1], + [0, 1], + [0, 1], + ], + magnitudeRange: [0, 1], + ranges: [ + [0, 1], + [0, 1], + [0, 1], + [0, 1], + [0, 1], + ], + }; +} + +function makeSceneGraphNode() { + return { + id: 0, + dataArrays: [], + name: '', + isGroupNode: true, + isActorNode: false, + nodeType: 'root', + diffuseColor: [1, 1, 1], + bounds: [], + children: [ + { + id: PART_A_ID, + dataArrays: [], + name: 'part-a', + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkUnstructuredGrid', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + ], + }; +} + +/** Every renderer member the constructor, the part loop and its node methods reach. */ +function makeRendererDouble() { + return { + attachSceneGraph: jest.fn(), + domElement: document.createElement('div'), + addCameraSettledListener: jest.fn(() => jest.fn()), + addViewerClickedListener: jest.fn(() => jest.fn()), + resizeAsync: jest.fn(async () => undefined), + setVisibilityAsync: jest.fn(async () => undefined), + sendPartVisibilityAsync: jest.fn(async () => undefined), + setOpacityAsync: jest.fn(async () => undefined), + sendPartOpacityAsync: jest.fn(async () => undefined), + setSelectedAsync: jest.fn(async () => undefined), + sendPartSelectedAsync: jest.fn(async () => undefined), + setDiffuseColorRgbAsync: jest.fn(async () => undefined), + sendPartDiffuseColorAsync: jest.fn(async () => undefined), + setColorVariableAsync: jest.fn(async () => undefined), + sendPartColorVariableAsync: jest.fn(async () => undefined), + clearColorVariableAsync: jest.fn(async () => undefined), + sendClearPartColorVariableAsync: jest.fn(async () => undefined), + setScalarRangeAsync: jest.fn(async () => undefined), + }; +} + +function makeFrontend(scene: VisorSceneState) { + const renderer = makeRendererDouble(); + const frontend = new VisorFrontend( + renderer as unknown as IRenderer, + makeSceneGraphNode() as unknown as VisorVtkSceneNode, + scene, + jest.fn(async () => undefined) + ); + const partA = frontend.sceneGraph.descendantActorNodesOrSelfDictionary[PART_A_ID]; + return { frontend, renderer, partA }; +} + +/** A delivery holding the given variable records and part A's record. */ +function delivery(variableStates: Record, partA: Record) { + return { + scene: { + variableStates, + datasetStates: { '0': { id: '0', partStates: { '1': partA } } }, + }, + }; +} + +/** A delivery referencing the variable the client does not hold. */ +function deliveryReferencingTensor5() { + return delivery( + { [TENSOR5_ID]: tensor5Record() }, + deliveredPart(PART_A_ID, { + colorVariable: { variableId: TENSOR5_ID, variableComponent: -1 }, + }) + ); +} + +const TENSOR5_NOT_HELD_WARNING = + "Part 1 references variable 'POINT::tensor5::5', which is not held; the part is not coloured by it"; + +describe('the client applies delivered state as given', () => { + test('a delivered record with visible false, opacity 0.25 and selected false applies as given on a fresh node', () => { + const { partA } = makeFrontend( + deliveredScene( + deliveredPart(PART_A_ID, { visible: false, opacity: 0.25, selected: false }) + ) + ); + + expect(partA.visible).toBe(false); + expect(partA.opacity).toBe(0.25); + expect(partA.selected).toBe(false); + }); + + test('a delivered colorVariable null turns scalar coloring off and the delivered diffuse color shows', async () => { + const { frontend, renderer } = makeFrontend( + deliveredScene( + deliveredPart(PART_A_ID, { + colorVariable: { variableId: PRESSURE_ID, variableComponent: -1 }, + }) + ) + ); + + await frontend.setAppStateAsync( + delivery( + { [PRESSURE_ID]: pressureRecord() }, + deliveredPart(PART_A_ID, { colorVariable: null, diffuseRgb: [0.25, 0.5, 0.75] }) + ), + false + ); + + expect(renderer.clearColorVariableAsync).toHaveBeenCalledWith(1); + expect(renderer.setDiffuseColorRgbAsync).toHaveBeenCalledWith(1, 0.25, 0.5, 0.75, false); + }); + + test("setDiffuseColorRgbAsync with the node's current color sends nothing", async () => { + const { renderer, partA } = makeFrontend( + deliveredScene(deliveredPart(PART_A_ID, { diffuseRgb: [0.25, 0.5, 0.75] })) + ); + + await partA.setDiffuseColorRgbAsync(0.25, 0.5, 0.75); + + expect(renderer.sendPartDiffuseColorAsync).not.toHaveBeenCalled(); + }); + + test('a delivered reference to a hidden variable warns once and sends nothing', async () => { + const { frontend, renderer } = makeFrontend(deliveredScene(deliveredPart(PART_A_ID))); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await frontend.setAppStateAsync(deliveryReferencingTensor5(), false); + + const nodeWarnings = warn.mock.calls.filter( + ([message]) => message === TENSOR5_NOT_HELD_WARNING + ); + expect(nodeWarnings).toHaveLength(1); + expect(renderer.sendPartColorVariableAsync).not.toHaveBeenCalled(); + expect(renderer.setColorVariableAsync).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + test('a delivered reference to a hidden variable is recorded on the node', async () => { + const { frontend, partA } = makeFrontend(deliveredScene(deliveredPart(PART_A_ID))); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await frontend.setAppStateAsync(deliveryReferencingTensor5(), false); + + expect(partA.variableId).toBe('POINT::tensor5::5'); + expect(partA.variableComponent).toBe(-1); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/PartStateColorVariable.test.ts b/src/ansys/visor/visor-client/src/jest-tests/PartStateColorVariable.test.ts new file mode 100644 index 00000000..9c0b0009 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/PartStateColorVariable.test.ts @@ -0,0 +1,20 @@ +import VisorPartState from '../state/appstate/VisorPartState.tsx'; + +describe('a part state parses the delivered colorVariable', () => { + test('a nested colorVariable parses onto the node reference', () => { + const part = new VisorPartState({ + id: '1', + colorVariable: { variableId: 'POINT::pressure::1', variableComponent: 2 }, + }); + + expect(part.variableId).toBe('POINT::pressure::1'); + expect(part.variableComponent).toBe(2); + }); + + test('a null colorVariable parses as no reference', () => { + const part = new VisorPartState({ id: '1', colorVariable: null }); + + expect(part.variableId).toBeNull(); + expect(part.variableComponent).toBeUndefined(); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/SchemaVersion.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/SchemaVersion.test.tsx index 8a4596a7..6bc2dff9 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/SchemaVersion.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/SchemaVersion.test.tsx @@ -26,6 +26,43 @@ describe('SchemaVersion', () => { expect(error.expectedVersion).toBe(SCENE_DETAILS_SCHEMA_VERSION); }); + test('scene details at schema version 3 are rejected', () => { + let thrown: unknown = null; + try { + new VisorSceneDetails({ schemaVersion: 3, appState: undefined, vtkInfo: undefined }); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(SchemaVersionMismatchError); + const error = thrown as SchemaVersionMismatchError; + expect(error.payloadVersion).toBe(3); + expect(error.expectedVersion).toBe(4); + }); + + test('scene details with a null orthographicEnabled parse and leave it undefined', () => { + const details = new VisorSceneDetails({ + schemaVersion: 4, + ui: {}, + scene: { unit: '', orthographicEnabled: null }, + vtkInfo: { + sceneGraph: { + id: 0, + dataArrays: [], + name: '', + isGroupNode: true, + isActorNode: false, + nodeType: 'root', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + rendererAnnotation: null, + }, + }); + + expect(details.appState.scene.orthographicEnabled).toBeUndefined(); + }); + test('parseRendererAnnotation returns null for a null or undefined annotation', () => { expect(parseRendererAnnotation(null)).toBeNull(); expect(parseRendererAnnotation(undefined)).toBeNull(); diff --git a/src/ansys/visor/visor-client/src/jest-tests/TreeView.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/TreeView.test.tsx index a2693174..6c04a147 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/TreeView.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/TreeView.test.tsx @@ -6,6 +6,7 @@ import { VisorSceneNodeSimple, } from '../state/VisorSceneGraph.tsx'; import { NullRenderer } from '../renderer/NullRenderer.ts'; +import { deliveredPart, deliveredScene } from './deliveredSceneFixture.ts'; describe('TreeView', () => { it('renders without a scene graph', () => { @@ -109,33 +110,36 @@ describe('TreeView', () => { }); function getSmallSceneGraph(): VisorSceneNodeExtended { - return CreateVisorSceneGraph({ - id: 0, - dataArrays: [], - name: '', - isGroupNode: true, - isActorNode: false, - nodeType: 'root', - diffuseColor: [1, 1, 1], - bounds: [], - children: [ - { - id: 1, - dataArrays: [], - name: 'some-polydata-file.vtp', - isGroupNode: false, - isActorNode: true, - nodeType: 'vtkUnstructuredGrid', - diffuseColor: [1, 1, 1], - bounds: [], - children: [], - }, - ], - }); + return CreateVisorSceneGraph( + { + id: 0, + dataArrays: [], + name: '', + isGroupNode: true, + isActorNode: false, + nodeType: 'root', + diffuseColor: [1, 1, 1], + bounds: [], + children: [ + { + id: 1, + dataArrays: [], + name: 'some-polydata-file.vtp', + isGroupNode: false, + isActorNode: true, + nodeType: 'vtkUnstructuredGrid', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + ], + }, + deliveredScene(deliveredPart(1)) + ); } function getLargeSceneGraph(): VisorSceneNodeExtended { - return CreateVisorSceneGraph(getSceneNodeSimple()); + return CreateVisorSceneGraph(getSceneNodeSimple(), getLargeDeliveredScene()); } /** @@ -143,7 +147,23 @@ function getLargeSceneGraph(): VisorSceneNodeExtended { * setVisibilityAsync/setSelectedAsync can run in a test. */ function getLargeSceneGraphWithRenderer(): VisorSceneNodeExtended { - return CreateVisorSceneGraph(getSceneNodeSimple(), undefined, new NullRenderer()); + return CreateVisorSceneGraph( + getSceneNodeSimple(), + getLargeDeliveredScene(), + undefined, + new NullRenderer() + ); +} + +/** The delivered records for the large graph's five parts. */ +function getLargeDeliveredScene() { + return deliveredScene( + deliveredPart(3), + deliveredPart(4), + deliveredPart(5), + deliveredPart(6), + deliveredPart(7) + ); } function getSceneNodeSimple(): VisorSceneNodeSimple { diff --git a/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx index 74af8398..3ffd0e47 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx @@ -7,6 +7,7 @@ import type { IRenderer } from '../renderer/IRenderer'; import type VisorVtkSceneNode from '../state/appstate/vtkInfo/VisorVtkSceneNode.tsx'; import type { Panel_TopLeft_Util } from '../components/ui-panels/Panel_TopLeft_Util.tsx'; import type { Panel_TopRight_Util } from '../components/ui-panels/Panel_TopRight_Util.tsx'; +import { deliveredPart, deliveredScene } from './deliveredSceneFixture.ts'; /** * The client half of server-owned UI panel state: the four `VisorFrontend` @@ -108,6 +109,7 @@ function makeFrontend(): { const frontend = new VisorFrontend( makeRendererDouble() as unknown as IRenderer, makeSceneGraphNode() as unknown as VisorVtkSceneNode, + deliveredScene(), triggerSender ); return { frontend, triggerSender }; @@ -156,6 +158,7 @@ function makeSceneGraph(renderer?: IRenderer): VisorSceneNodeExtended { }, ], }, + deliveredScene(deliveredPart(1)), undefined, renderer ); diff --git a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx index 069acfba..c5584c58 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx @@ -9,6 +9,7 @@ import type { VisorSceneNodeExtended } from '../state/VisorSceneGraph.tsx'; import type { TreeViewUtil } from '../treeview/TreeView.tsx'; import type { IRenderer } from '../renderer/IRenderer'; import type VisorVtkSceneNode from '../state/appstate/vtkInfo/VisorVtkSceneNode.tsx'; +import { deliveredPart, deliveredScene } from './deliveredSceneFixture.ts'; /** * The client as a projection of server-owned variable records. @@ -205,6 +206,7 @@ function makeFrontend(): { const frontend = new VisorFrontend( renderer as unknown as IRenderer, makeSceneGraphNode() as unknown as VisorVtkSceneNode, + deliveredScene(deliveredPart(PART_A_ID)), triggerSender ); return { frontend, renderer, triggerSender }; @@ -380,7 +382,13 @@ describe('setAppStateAsync holds a delivered variable block', () => { '0': { id: '0', partStates: { - '1': { id: '1', variableId: PRESSURE_ID, variableComponent: -1 }, + '1': { + id: '1', + colorVariable: { + variableId: PRESSURE_ID, + variableComponent: -1, + }, + }, }, }, }, @@ -529,7 +537,10 @@ function deliveryColouringPartA(record: Record) { '0': { id: '0', partStates: { - '1': { id: '1', variableId: PRESSURE_ID, variableComponent: -1 }, + '1': { + id: '1', + colorVariable: { variableId: PRESSURE_ID, variableComponent: -1 }, + }, }, }, }, diff --git a/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx index 4ff9b0aa..1f2ae936 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx @@ -3,6 +3,7 @@ import { getVariableManager } from '../state/VisorVariableManager.tsx'; import VisorVariableState from '../state/appstate/VisorVariableState.tsx'; import VisorVtkDataArray from '../state/appstate/vtkInfo/VisorVtkDataArray.tsx'; import type { IRenderer } from '../renderer/IRenderer'; +import { deliveredPart, deliveredScene } from './deliveredSceneFixture.ts'; /** * The scene graph's six user actions, each of which must now do two things: @@ -80,7 +81,10 @@ function makeRendererDouble() { type RendererDouble = ReturnType; -function makeGraph(renderer: RendererDouble): VisorSceneNodeExtended { +function makeGraph( + renderer: RendererDouble, + partAOverrides: Record +): VisorSceneNodeExtended { const variableManager = getVariableManager(); variableManager.setRecords([makePressureRecord()]); return CreateVisorSceneGraph( @@ -118,14 +122,18 @@ function makeGraph(renderer: RendererDouble): VisorSceneNodeExtended { }, ], }, + deliveredScene(deliveredPart(PART_A_ID, partAOverrides), deliveredPart(PART_B_ID)), variableManager, renderer as unknown as IRenderer ); } -function setUp() { +/** Part A delivered coloured by the pressure variable, so a clear has something to remove. */ +const PART_A_COLOURED = { colorVariable: { variableId: VARIABLE_ID, variableComponent: 0 } }; + +function setUp(partAOverrides: Record = {}) { const renderer = makeRendererDouble(); - const graph = makeGraph(renderer); + const graph = makeGraph(renderer, partAOverrides); const partA = graph.descendantActorNodesOrSelfDictionary[PART_A_ID]; return { renderer, graph, partA }; } @@ -263,7 +271,7 @@ describe('colour by variable', () => { describe('clear colour by variable', () => { test('sends clear_part_color_variable for the part', async () => { - const { renderer, partA } = setUp(); + const { renderer, partA } = setUp(PART_A_COLOURED); await partA.clearColorVariableAsync(); @@ -271,7 +279,7 @@ describe('clear colour by variable', () => { }); test('still clears the colour variable on the client renderer', async () => { - const { renderer, partA } = setUp(); + const { renderer, partA } = setUp(PART_A_COLOURED); await partA.clearColorVariableAsync(); diff --git a/src/ansys/visor/visor-client/src/jest-tests/deliveredSceneFixture.ts b/src/ansys/visor/visor-client/src/jest-tests/deliveredSceneFixture.ts new file mode 100644 index 00000000..b043967e --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/deliveredSceneFixture.ts @@ -0,0 +1,29 @@ +import VisorSceneState from '../state/appstate/VisorSceneState.tsx'; + +/** + * One delivered part record as the wire carries it, every field resolved: + * shown, opaque, not selected, white, and not coloured by a variable. + */ +export function deliveredPart( + id: number, + overrides: Record = {} +): Record { + return { + id: id.toString(), + visible: true, + opacity: 1, + selected: false, + diffuseRgb: [1, 1, 1], + colorVariable: null, + ...overrides, + }; +} + +/** A delivered scene holding the given part records in one dataset. */ +export function deliveredScene(...parts: Record[]): VisorSceneState { + const partStates: Record = {}; + for (const part of parts) { + partStates[part.id as string] = part; + } + return new VisorSceneState({ datasetStates: { '0': { id: '0', partStates } } }); +} diff --git a/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx b/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx index 3d94363d..ce3bfb37 100644 --- a/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx +++ b/src/ansys/visor/visor-client/src/state/VisorSceneGraph.tsx @@ -5,6 +5,7 @@ import { VisorVariableManager, } from './VisorVariableManager.tsx'; import VisorVtkDataArray from './appstate/vtkInfo/VisorVtkDataArray.tsx'; +import VisorSceneState from './appstate/VisorSceneState.tsx'; import { IRenderer } from '../renderer/IRenderer'; export type VisorSceneNodeSimple = Readonly<{ @@ -44,6 +45,13 @@ export type VisorSceneNodeExtended = Readonly<{ setVisibilityAsync: (visible: boolean) => Promise; setSelectedAsync: (selected: boolean) => Promise; setOpacityAsync: (opacity: number) => Promise; + /** + * Removes the part's colour variable. A part is always coloured by its + * diffuse colour, and a colour variable overrides it while set; clearing + * the variable only removes the override, so the part shows the diffuse + * colour its property already holds. Does nothing on a part with no + * colour variable. + */ clearColorVariableAsync: () => Promise; /** Colours the part by a variable slot at its held range; on the slot already applied, re-applies only a changed range and sends nothing. */ setColorVariableAsync: (id: string, component?: number | null) => Promise; @@ -68,6 +76,7 @@ export type VisorSceneNodeExtended = Readonly<{ export const CreateVisorSceneGraph = (() => { return ( string_or_object: string | VisorSceneNodeSimple, + deliveredScene: VisorSceneState, variableManager?: VisorVariableManager, renderer?: IRenderer ) => { @@ -79,14 +88,30 @@ export const CreateVisorSceneGraph = (() => { } const localVariableManager = variableManager == null; variableManager ??= getVariableManager(); - const rootNode = extendSimpleNode(simpleState, variableManager, null, null, renderer); + const rootNode = extendSimpleNode( + simpleState, + variableManager, + deliveredScene, + null, + null, + renderer + ); localVariableManager && variableManager.finishAddingDataArrayMetadata(); return rootNode; }; + /** A delivered field of a part's record; throws naming the part and field when the delivery omits it. */ + function requireDelivered(value: T | undefined, partId: number, field: string): T { + if (value === undefined) { + throw new Error(`Part ${partId} was delivered without '${field}'`); + } + return value; + } + function extendSimpleNode( simpleNode: VisorSceneNodeSimple, variableManager: VisorVariableManager, + deliveredScene: VisorSceneState, rootNodeSimple?: VisorSceneNodeSimple | null, rootNodeExtended?: VisorSceneNodeExtended | null, renderer?: IRenderer @@ -97,6 +122,9 @@ export const CreateVisorSceneGraph = (() => { } else if (rootNodeSimple.nodeType !== 'root') { throw new Error(`rootNodeSimple.nodeType must be 'root'`); } + const nodeId = simpleNode.id; + // A group node's values are fan-out caches for its own guards; an + // actor node's are overwritten below from its delivered record. let _visible: boolean = true; let _selected: boolean = false; let _opacity: number = 1; @@ -113,9 +141,31 @@ export const CreateVisorSceneGraph = (() => { simpleNode.diffuseColor[2], true ); - customDiffuseColor.setHex(defaultDiffuseColor.hex); + // An actor node is built from its delivered record, so a delivery + // that matches it changes nothing and sends nothing. + const record = deliveredScene.findPartState(nodeId, simpleNode.name); + if (record == null) { + throw new Error(`Part ${nodeId} '${simpleNode.name}' has no delivered record`); + } + _visible = requireDelivered(record.visible, nodeId, 'visible'); + _selected = requireDelivered(record.selected, nodeId, 'selected'); + _opacity = requireDelivered(record.opacity, nodeId, 'opacity'); + const [r, g, b] = requireDelivered(record.diffuseRgb, nodeId, 'diffuseRgb'); + customDiffuseColor.setRgb(r, g, b, true); + _variableId = requireDelivered(record.variableId, nodeId, 'colorVariable'); + if (_variableId !== null) { + _variableComponent = requireDelivered( + record.variableComponent, + nodeId, + 'colorVariable.variableComponent' + ); + } } - const nodeId = simpleNode.id; + const warnVariableNotHeld = (id: string) => { + console.warn( + `Part ${nodeId} references variable '${id}', which is not held; the part is not coloured by it` + ); + }; // A live view: the node is frozen, and the records it reads are // replaced on every delivery. @@ -176,6 +226,10 @@ export const CreateVisorSceneGraph = (() => { await renderer!.sendPartDiffuseColorAsync(nodeId, rgbNormalized); }, async setDiffuseColorRgbAsync(r, g, b) { + const current = customDiffuseColor.rgbNormalized; + if (current[0] === r && current[1] === g && current[2] === b) { + return; + } customDiffuseColor.setRgb(r, g, b); const rgbNormalized = customDiffuseColor.rgbNormalized; await renderer!.setDiffuseColorRgbAsync( @@ -188,6 +242,9 @@ export const CreateVisorSceneGraph = (() => { await renderer!.sendPartDiffuseColorAsync(nodeId, rgbNormalized); }, async clearColorVariableAsync() { + if (_variableId === null) { + return; + } _variableId = null; _variableComponent = -1; _variableMin = -1; @@ -201,6 +258,9 @@ export const CreateVisorSceneGraph = (() => { // differs from the one last applied reaches the actor. const held = variableCollection.getVariable(id)?.getRangeInfo(component); if (held == null) { + if (variableCollection.getVariable(id) == null) { + warnVariableNotHeld(id); + } return; } const [heldMin, heldMax] = held.customRange; @@ -213,6 +273,11 @@ export const CreateVisorSceneGraph = (() => { } const variable = variableCollection.getVariable(id); if (variable == null) { + // The reference is held so the node reports what was + // delivered; the part keeps whatever the scene shows. + _variableId = id; + _variableComponent = component; + warnVariableNotHeld(id); return; } const rangeInfo = variable.getRangeInfo(component); @@ -338,6 +403,7 @@ export const CreateVisorSceneGraph = (() => { const extendedChild = extendSimpleNode( simpleChild, variableManager, + deliveredScene, rootNodeSimple, rootNodeExtended, renderer diff --git a/src/ansys/visor/visor-client/src/state/appstate/VisorAppState.tsx b/src/ansys/visor/visor-client/src/state/appstate/VisorAppState.tsx index a81c3de5..1c4f7c63 100644 --- a/src/ansys/visor/visor-client/src/state/appstate/VisorAppState.tsx +++ b/src/ansys/visor/visor-client/src/state/appstate/VisorAppState.tsx @@ -30,6 +30,10 @@ export default class VisorAppState { this._ui.copy(data.ui, replace); this._scene.copy(data.scene, replace); + // The camera and the plane arrive under view and are held on the + // scene's camera and crossSection records. + this._scene.camera.copy(data.view?.camera, replace); + this._scene.crossSection.copy(data.view?.crossSection, replace); } return this; } @@ -42,6 +46,10 @@ export default class VisorAppState { return { ui: this.ui.toDict(), scene: this.scene.toDict(), + view: { + camera: this.scene.camera.toDict(), + crossSection: this.scene.crossSection.toDict(), + }, }; } } diff --git a/src/ansys/visor/visor-client/src/state/appstate/VisorPartState.tsx b/src/ansys/visor/visor-client/src/state/appstate/VisorPartState.tsx index 406342de..c878c97c 100644 --- a/src/ansys/visor/visor-client/src/state/appstate/VisorPartState.tsx +++ b/src/ansys/visor/visor-client/src/state/appstate/VisorPartState.tsx @@ -197,14 +197,15 @@ export default class VisorPartState { replace ); this.setSelected(data.selected === undefined ? this._selected : data.selected, replace); + const reference = VisorPartState.referenceFields(data.colorVariable); this.setVariableId( - data.variableId === undefined ? this._variableId : data.variableId, + reference.variableId === undefined ? this._variableId : reference.variableId, replace ); this.setVariableComponent( - data.variableComponent === undefined + reference.variableComponent === undefined ? this._variableComponent - : data.variableComponent, + : reference.variableComponent, replace ); } @@ -224,8 +225,49 @@ export default class VisorPartState { visible: this.visible, diffuseRgb: this.diffuseRgb, selected: this.selected, - variableId: this.variableId, - variableComponent: this.variableComponent, + colorVariable: VisorPartState.colorVariableOf(this.variableId, this.variableComponent), }; } + + /** + * The flat fields a delivered `colorVariable` stands for: absent gives + * neither, null gives a null id, an object gives both. + */ + static referenceFields(colorVariable: unknown): { + variableId?: string | null; + variableComponent?: number; + } { + if (colorVariable === undefined) { + return {}; + } + if (colorVariable === null) { + return { variableId: null }; + } + const reference = colorVariable as JsonDict; + return { + variableId: ensureString(reference.variableId, 'colorVariable.variableId'), + variableComponent: ensureNumber( + reference.variableComponent, + 'colorVariable.variableComponent' + ), + }; + } + + /** + * The inverse of `referenceFields`: no id gives absent, a null id gives + * null, an id with a component gives the object. An id without a + * component gives absent. + */ + static colorVariableOf( + variableId: string | null | undefined, + variableComponent: number | undefined + ): JsonDict | null | undefined { + if (variableId === null) { + return null; + } + if (variableId === undefined || variableComponent === undefined) { + return undefined; + } + return { variableId, variableComponent }; + } } diff --git a/src/ansys/visor/visor-client/src/state/appstate/VisorSceneDetails.tsx b/src/ansys/visor/visor-client/src/state/appstate/VisorSceneDetails.tsx index dc4ba29e..778b28f1 100644 --- a/src/ansys/visor/visor-client/src/state/appstate/VisorSceneDetails.tsx +++ b/src/ansys/visor/visor-client/src/state/appstate/VisorSceneDetails.tsx @@ -7,7 +7,7 @@ import VisorAppState from './VisorAppState.tsx'; * (`ansys.visor.viewer.models.runtime.visor_scene_details`). Bumped whenever * the scene-details wire shape changes incompatibly. See proposal §2. */ -export const SCENE_DETAILS_SCHEMA_VERSION = 3; +export const SCENE_DETAILS_SCHEMA_VERSION = 4; /** * Thrown when the payload's `schemaVersion` does not match the client's @@ -40,7 +40,8 @@ export default class VisorSceneDetails { throw new SchemaVersionMismatchError(obj.schemaVersion, SCENE_DETAILS_SCHEMA_VERSION); } this.schemaVersion = obj.schemaVersion; - this.appState = new VisorAppState(obj.appState); + // ui and scene sit beside vtkInfo; the details never carry the view. + this.appState = new VisorAppState({ ui: obj.ui, scene: obj.scene }); this.vtkInfo = new VisorVtkInfo(obj.vtkInfo); } diff --git a/src/ansys/visor/visor-client/src/state/appstate/VisorSceneState.tsx b/src/ansys/visor/visor-client/src/state/appstate/VisorSceneState.tsx index bc4b4825..cc2c0d86 100644 --- a/src/ansys/visor/visor-client/src/state/appstate/VisorSceneState.tsx +++ b/src/ansys/visor/visor-client/src/state/appstate/VisorSceneState.tsx @@ -1,4 +1,5 @@ import VisorDatasetState from './VisorDatasetState.tsx'; +import VisorPartState from './VisorPartState.tsx'; import { ensureBoolean, ensureString, @@ -89,6 +90,25 @@ export default class VisorSceneState { return delete this._datasetStates[idStr]; } + /** + * The delivered record for a part: matched by id, or by name for a + * name-keyed record; null when the delivery carries none. + */ + findPartState(partId: number, partName: string): VisorPartState | null { + const idStr = partId.toString(); + for (const datasetState of this.getDatasetStates()) { + for (const partState of datasetState.getPartStates()) { + const matches = partState.keyedByName + ? partState.name === partName + : partState.id === idStr; + if (matches) { + return partState; + } + } + } + return null; + } + /** * Whether this state carries a variable block at all. An empty block is a * delivery of zero variables; an absent one delivers nothing. @@ -201,10 +221,6 @@ export default class VisorSceneState { data = parseState(state)!; } - this._camera.copy(data.camera, replace); - - this._crossSection.copy(data.crossSection, replace); - this.setUnit(data.unit === undefined ? this._unit : data.unit, replace); this.setCrossSectionEnabled( data.crossSectionEnabled === undefined @@ -222,8 +238,9 @@ export default class VisorSceneState { data.edgesEnabled === undefined ? this._edgesEnabled : data.edgesEnabled, replace ); + // A null orthographicEnabled means no camera record exists yet: not delivered. this.setOrthographicEnabled( - data.orthographicEnabled === undefined + data.orthographicEnabled == null ? this._orthographicEnabled : data.orthographicEnabled, replace @@ -281,8 +298,6 @@ export default class VisorSceneState { } return { unit: this.unit, - camera: this.camera.toDict(), - crossSection: this.crossSection.toDict(), orthographicEnabled: this.orthographicEnabled, crossSectionEnabled: this.crossSectionEnabled, edgesEnabled: this.edgesEnabled, diff --git a/tests/integration/test_save_load_state.py b/tests/integration/test_save_load_state.py index d54a2f17..f31fcfff 100644 --- a/tests/integration/test_save_load_state.py +++ b/tests/integration/test_save_load_state.py @@ -45,10 +45,10 @@ 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.persist.visor_ui_state import PersistedUIState from ansys.visor.viewer.vtk.io.file_to_dataset import file_to_dataset from ansys.visor.viewer.vtk.io.visor_file_io import VisorFileIO @@ -248,7 +248,7 @@ class TestLoadDatasetsFromState: def _make_state(self, dataset_states: dict, unit: str = "m") -> PersistedViewerStateV1: return PersistedViewerStateV1.from_components( - ui_state=VisorUIState(), + ui_state=PersistedUIState(), unit=unit, orthographic_enabled=None, cross_section_enabled=None, @@ -455,7 +455,7 @@ def test_reloading_the_saved_state_restores_the_registry(self, file_io, iface, t file_io.write_dataset(snap, original) state = PersistedViewerStateV1.from_components( - ui_state=VisorUIState(), + ui_state=PersistedUIState(), unit="m", orthographic_enabled=None, cross_section_enabled=None, @@ -496,8 +496,8 @@ def test_reloading_the_saved_state_restores_the_registry(self, file_io, iface, t assert record.visible is False assert record.selected is True assert record.diffuse_rgb == [1.0, 0.0, 0.0] - assert record.variable_id == "POINT::pressure::1" - assert record.variable_component == 0 + assert record.color_variable.variable_id == "POINT::pressure::1" + assert record.color_variable.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. @@ -512,7 +512,7 @@ def test_reloading_a_colored_part_restores_its_file_range(self, file_io, iface, file_io.write_dataset(snap, original) state = PersistedViewerStateV1.from_components( - ui_state=VisorUIState(), + ui_state=PersistedUIState(), unit="m", orthographic_enabled=None, cross_section_enabled=None, diff --git a/tests/integration/test_visor_file_io.py b/tests/integration/test_visor_file_io.py index 62268ca9..ad902533 100644 --- a/tests/integration/test_visor_file_io.py +++ b/tests/integration/test_visor_file_io.py @@ -159,8 +159,8 @@ def test_round_trip_preserves_version(self, file_io, minimal_state, tmp_path): def test_round_trip_preserves_ui_state(self, file_io, tmp_path): """Test that round trip preserves ui state.""" - from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState - state = PersistedViewerStateV1(ui=VisorUIState(dark_theme=True)) + from ansys.visor.viewer.models.persist.visor_ui_state import PersistedUIState + state = PersistedViewerStateV1(ui=PersistedUIState(dark_theme=True)) file_io.write_state(str(tmp_path), state) result = file_io.read_state(str(tmp_path)) assert result.ui.dark_theme is True @@ -184,9 +184,9 @@ def test_read_raises_for_corrupt_json(self, file_io, tmp_path): def test_write_overwrites_existing_state_file(self, file_io, tmp_path): """Test that write_state overwrites existing state file.""" - from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState - state_a = PersistedViewerStateV1(ui=VisorUIState(dark_theme=False)) - state_b = PersistedViewerStateV1(ui=VisorUIState(dark_theme=True)) + from ansys.visor.viewer.models.persist.visor_ui_state import PersistedUIState + state_a = PersistedViewerStateV1(ui=PersistedUIState(dark_theme=False)) + state_b = PersistedViewerStateV1(ui=PersistedUIState(dark_theme=True)) file_io.write_state(str(tmp_path), state_a) file_io.write_state(str(tmp_path), state_b) result = file_io.read_state(str(tmp_path)) @@ -333,8 +333,8 @@ def test_vtkhdf_multiblock_snapshot_can_be_deleted_after_load(self, file_io, tmp so that save_state can delete it as a stale snapshot.""" import shutil - from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 + from ansys.visor.viewer.models.persist.visor_ui_state import PersistedUIState src = _vtkhdf_multiblock_path() copy = tmp_path / "mb_copy.vtkhdf" @@ -345,7 +345,7 @@ def test_vtkhdf_multiblock_snapshot_can_be_deleted_after_load(self, file_io, tmp assert dataset.GetNumberOfBlocks() > 0 state = PersistedViewerStateV1.from_components( - ui_state=VisorUIState(), unit="m", orthographic_enabled=None, + ui_state=PersistedUIState(), unit="m", orthographic_enabled=None, cross_section_enabled=None, edges_enabled=None, bounding_box_enabled=None, datasets={}, variable_states={} ) diff --git a/tests/integration/test_visor_local_scene.py b/tests/integration/test_visor_local_scene.py index 10aa8797..85bdc21b 100644 --- a/tests/integration/test_visor_local_scene.py +++ b/tests/integration/test_visor_local_scene.py @@ -85,11 +85,11 @@ def test_get_scene_details_and_json(pipeline_instance): metadata = ExtendedMetadata(name="test_model", unit="mm") pipeline_instance.add_dataset(dataset, metadata) details = pipeline_instance.get_scene_details() - assert details.app_state.scene.unit == "mm" + assert details.scene.unit == "mm" payload = json.loads(pipeline_instance.get_scene_details_json()) - assert payload["appState"]["scene"]["unit"] == "mm" + assert payload["scene"]["unit"] == "mm" assert "vtkInfo" in payload - assert "appState" in payload + assert "ui" in payload def test_clear_resets_registry_and_graph(pipeline_instance): diff --git a/tests/unit/app/test_trame_server_manager.py b/tests/unit/app/test_trame_server_manager.py index 1f58f1c3..6020cad4 100644 --- a/tests/unit/app/test_trame_server_manager.py +++ b/tests/unit/app/test_trame_server_manager.py @@ -4,14 +4,7 @@ import pytest from ansys.visor.viewer.app.trame.trame_server_manager import TrameServerManager -from ansys.visor.viewer.models.runtime.visor_scene_details import VisorSceneDetails -from ansys.visor.viewer.models.runtime.vtk.runtime_vtk_info import RuntimeVTKInfo -visor_vtk_pipeline_state = VisorSceneDetails( - vtk_info=RuntimeVTKInfo( - scene_graph=None, - ), - ) class DummyServer: def __init__(self, running=True, controller=True): diff --git a/tests/unit/app/test_visor_vtk_local.py b/tests/unit/app/test_visor_vtk_local.py index 7fac3541..5ce7c719 100644 --- a/tests/unit/app/test_visor_vtk_local.py +++ b/tests/unit/app/test_visor_vtk_local.py @@ -12,9 +12,9 @@ ) from ansys.visor.viewer.core.errors import ServerNotStartedError from ansys.visor.viewer.core.metadata import ExtendedMetadata -from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState 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.persist.visor_ui_state import PersistedUIState @pytest.fixture @@ -405,7 +405,7 @@ def _make_state_with_datasets(snapshot_path: str | None, name="model", unit="m") """Helper function to create a PersistedViewerStateV1 with a dataset state.""" ds_state = PersistedDatasetState(serialized_dataset_path=snapshot_path) return PersistedViewerStateV1.from_components( - ui_state=VisorUIState(), unit=unit, orthographic_enabled=None, + ui_state=PersistedUIState(), unit=unit, orthographic_enabled=None, cross_section_enabled=None, edges_enabled=None, bounding_box_enabled=None, datasets={name: ds_state}, variable_states={} ) @@ -517,7 +517,7 @@ def test_load_state_passes_correct_metadata_to_add_dataset(tmp_path, iface): serialized_dataset_path=str(snapshot), source_file_path="/orig/mesh.vtu" ) state = PersistedViewerStateV1.from_components( - ui_state=VisorUIState(), unit="mm", orthographic_enabled=None, + ui_state=PersistedUIState(), unit="mm", orthographic_enabled=None, cross_section_enabled=None, edges_enabled=None, bounding_box_enabled=None, datasets={"mesh": ds_state}, variable_states={} ) diff --git a/tests/unit/models/test_dataset_state.py b/tests/unit/models/test_dataset_state.py index 9820ac8f..caa1b230 100644 --- a/tests/unit/models/test_dataset_state.py +++ b/tests/unit/models/test_dataset_state.py @@ -1,9 +1,21 @@ -from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimeDatasetState, RuntimePartProperties +from unittest.mock import patch + +from ansys.visor.viewer.models.common.part_properties import PartProperties +from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( + RuntimeDatasetState, + RuntimePartProperties, + VariableReference, +) + + +def _record(part_id, **overrides): + """A complete record for *part_id* with *overrides* applied.""" + return RuntimePartProperties.default_for(part_id).model_copy(update=overrides) def test_parts_accepts_partproperties_instances(): """Validate that RuntimeDatasetState.part_states accepts RuntimePartProperties instances.""" - p = RuntimePartProperties(id=123, opacity=0.75) + p = _record(123, opacity=0.75) ds = RuntimeDatasetState(id=345, part_states={10: p}) assert 10 in ds.part_states assert isinstance(ds.part_states[10], RuntimePartProperties) @@ -12,66 +24,55 @@ def test_parts_accepts_partproperties_instances(): def test_parts_key_types_preserved(): """Validate that RuntimeDatasetState.part_states preserves key types.""" # ensure that integer keys remain integers (pydantic won't coerce dict keys automatically) - ds = RuntimeDatasetState(id=345, part_states={1: RuntimePartProperties(id=123, opacity=1.0)}) + ds = RuntimeDatasetState(id=345, part_states={1: _record(123, opacity=1.0)}) keys = list(ds.part_states.keys()) assert all(isinstance(k, int) for k in keys) # --- Serialization: undefined-vs-null contract --- -def test_variable_id_null_always_serialized(): - """variable_id=None must appear in the output as null (not be omitted). - - Frontend distinguishes null ("remove variable") from absent ("pass-through"). - The wire format uses the camelCase alias ``variableId``. - """ - p = RuntimePartProperties(id=1, variable_id=None) +def test_no_color_variable_dumps_as_null(): + """A record with no reference dumps ``colorVariable`` as null, not omitted.""" + p = _record(1, color_variable=None) data = p.model_dump(by_alias=True) - assert "variableId" in data - assert data["variableId"] is None - - -def test_variable_id_value_serialized(): - """variable_id with a real value must be present.""" - p = RuntimePartProperties(id=1, variable_id="pressure") - data = p.model_dump(by_alias=True) - assert data["variableId"] == "pressure" - + assert "colorVariable" in data + assert data["colorVariable"] is None -def test_optional_fields_omitted_when_none(): - """opacity, visible, selected, variableComponent, diffuseRgb must be omitted when None. - The frontend interprets an absent key as undefined / pass-through. - """ - p = RuntimePartProperties(id=1) # all optional fields default to None +def test_part_record_dumps_the_nested_color_variable(): + """A reference dumps nested under ``colorVariable``; no flat keys are emitted.""" + p = _record(1, color_variable=VariableReference(variable_id="POINT::pressure::1", variable_component=2)) data = p.model_dump(by_alias=True) - for field in ("opacity", "visible", "selected", "variableComponent", "diffuseRgb"): - assert field not in data, f"Expected '{field}' to be omitted when None, but it was present" + assert data["colorVariable"] == {"variableId": "POINT::pressure::1", "variableComponent": 2} + assert "variableId" not in data + assert "variableComponent" not in data def test_optional_fields_present_when_set(): """Optional fields must appear when they carry an actual value.""" - p = RuntimePartProperties(id=1, opacity=0.5, visible=True, selected=False, variable_component=2, - diffuse_rgb=[0.1, 0.2, 0.3]) + p = _record( + 1, opacity=0.5, visible=True, selected=False, + color_variable=VariableReference(variable_id="pressure", variable_component=2), + diffuse_rgb=[0.1, 0.2, 0.3], + ) data = p.model_dump(by_alias=True) assert data["opacity"] == 0.5 assert data["visible"] is True assert data["selected"] is False - assert data["variableComponent"] == 2 assert data["diffuseRgb"] == [0.1, 0.2, 0.3] def test_clear_variable_round_trip(): - """A part with variable_id=None serializes correctly and round-trips through PartProperties.""" + """A part with no reference serializes correctly and round-trips through PartProperties.""" from ansys.visor.viewer.models.common.part_properties import PartProperties props = PartProperties(color_by=None) p = RuntimePartProperties.from_part_properties(id=42, props=props) data = p.model_dump(by_alias=True) - # variableId must be present and null so the frontend removes the variable - assert "variableId" in data - assert data["variableId"] is None + # colorVariable must be present and null: the resolved "not coloured" + assert "colorVariable" in data + assert data["colorVariable"] is None # Round-trip back to PartProperties back = p.to_part_properties() @@ -94,3 +95,73 @@ def test_diffuse_rgb_round_trip(): # Round-trip back to PartProperties back = p.to_part_properties() assert back.diffuse_rgb == orig + + +# --- Resolution: a record never holds None --- + +def test_default_for_yields_a_record_with_every_field_set(): + """The default record carries the part's id and the four literals, and no variable.""" + p = RuntimePartProperties.default_for(7) + + assert p.id == 7 + assert p.visible is True + assert p.opacity == 1.0 + assert p.selected is False + assert p.diffuse_rgb == [0.8, 0.8, 0.8] + assert p.color_variable is None + + +def test_from_part_properties_resolves_each_null_to_its_literal(): + """A persisted entry with every field null resolves to the literals.""" + props = PartProperties( + opacity=None, visible=None, selected=None, color_by=None, color_by_component=None, diffuse_rgb=None + ) + p = RuntimePartProperties.from_part_properties(id=5, props=props) + + assert p.visible is True + assert p.opacity == 1.0 + assert p.selected is False + assert p.diffuse_rgb == [0.8, 0.8, 0.8] + + +def test_from_part_properties_resolves_a_short_colour_to_the_default(): + """A persisted colour that is not three elements resolves as a null does, with one WARNING.""" + with patch( + "ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state.logger" + ) as mock_logger: + p = RuntimePartProperties.from_part_properties(id=5, props=PartProperties(diffuse_rgb=[1.0, 0.0])) + + assert mock_logger.warning.call_count == 1 + assert p.diffuse_rgb == [0.8, 0.8, 0.8] + + +def test_color_variable_round_trips_through_color_by(): + """color_by / color_by_component map to the reference, and the reference maps back.""" + props = PartProperties(color_by="POINT::pressure::1", color_by_component=2) + + p = RuntimePartProperties.from_part_properties(id=5, props=props) + assert p.color_variable == VariableReference(variable_id="POINT::pressure::1", variable_component=2) + + back = p.to_part_properties() + assert back.color_by == "POINT::pressure::1" + assert back.color_by_component == 2 + + +def test_overlaid_with_keeps_an_absent_field_and_resolves_a_named_one(): + """An absent key keeps the record's value; a named value is taken; a named null resolves to the default.""" + current = _record( + 5, opacity=0.3, color_variable=VariableReference(variable_id="POINT::pressure::1", variable_component=2) + ) + props = PartProperties( + visible=False, selected=True, diffuse_rgb=[0.1, 0.2, 0.3], color_by=None, color_by_component=None + ) + + p = current.overlaid_with(props) + + assert p.opacity == 0.3 + assert p.visible is False + assert p.selected is True + assert p.diffuse_rgb == [0.1, 0.2, 0.3] + assert p.color_variable is None + + diff --git a/tests/unit/models/test_runtime_scene_state.py b/tests/unit/models/test_runtime_scene_state.py index c88dc3f9..6e614d31 100644 --- a/tests/unit/models/test_runtime_scene_state.py +++ b/tests/unit/models/test_runtime_scene_state.py @@ -12,6 +12,19 @@ RuntimeSceneState, ) + +def _scene(**overrides) -> RuntimeSceneState: + """A scene record with the required scene-level fields filled from literals.""" + fields = { + "unit": "", + "orthographic_enabled": None, + "cross_section_enabled": False, + "edges_enabled": False, + "bounding_box_enabled": False, + } + fields.update(overrides) + return RuntimeSceneState(**fields) + # ------------------------------------------------------------------ # dataset_states validator # ------------------------------------------------------------------ @@ -19,7 +32,7 @@ def test_dataset_states_none_becomes_empty(): """None dataset_states should be coerced to empty dict.""" - state = RuntimeSceneState(dataset_states=None) + state = _scene(dataset_states=None) assert state.dataset_states == {} @@ -32,7 +45,7 @@ def test_dataset_states_coerce_string_keys_to_int(): "2": MagicMock(spec=RuntimeDatasetState), } - state = RuntimeSceneState(dataset_states=data) + state = _scene(dataset_states=data) assert set(state.dataset_states.keys()) == {1, 2} @@ -45,14 +58,14 @@ def test_dataset_states_invalid_keys_raise(): } with pytest.raises(ValidationError): - RuntimeSceneState(dataset_states=data) + _scene(dataset_states=data) def test_dataset_states_passthrough_non_dict(): """Non-dict dataset_states should fail validation.""" with pytest.raises(ValidationError): - RuntimeSceneState(dataset_states=123) + _scene(dataset_states=123) # ------------------------------------------------------------------ @@ -60,19 +73,18 @@ def test_dataset_states_passthrough_non_dict(): # ------------------------------------------------------------------ def test_default_fields(): - """Default construction should initialize expected defaults.""" + """The records default to empty.""" - state = RuntimeSceneState() + state = _scene() assert state.dataset_states == {} assert state.variable_states == {} - assert state.unit is None def test_setting_basic_fields(): """Basic fields should be stored correctly.""" - state = RuntimeSceneState( + state = _scene( unit="m", orthographic_enabled=True, edges_enabled=False, @@ -90,7 +102,7 @@ def test_dataset_states_accept_valid_mapping(): "1": MagicMock(spec=RuntimeDatasetState), } - state = RuntimeSceneState(dataset_states=data) + state = _scene(dataset_states=data) assert 1 in state.dataset_states assert isinstance(state.dataset_states[1], RuntimeDatasetState) @@ -115,7 +127,7 @@ def test_variable_states_default_and_assignment(): ranges=[(2.0, 3.0)], ) - state = RuntimeSceneState(variable_states={"POINT::pressure::1": record}) + state = _scene(variable_states={"POINT::pressure::1": record}) stored = state.variable_states["POINT::pressure::1"] assert isinstance(stored, VisorVariableRecord) diff --git a/tests/unit/models/test_visor_scene_details.py b/tests/unit/models/test_visor_scene_details.py new file mode 100644 index 00000000..06009e56 --- /dev/null +++ b/tests/unit/models/test_visor_scene_details.py @@ -0,0 +1,6 @@ +from ansys.visor.viewer.models.runtime.visor_scene_details import VisorSceneDetails + + +def test_scene_details_has_no_view_field(): + """The details carry the UI and scene records beside the VTK info, and no view.""" + assert set(VisorSceneDetails.model_fields) == {"schema_version", "ui", "scene", "vtk_info"} diff --git a/tests/unit/vtk/datasets/test_visor_dataset.py b/tests/unit/vtk/datasets/test_visor_dataset.py index c4c6e5fb..b14bf7ec 100644 --- a/tests/unit/vtk/datasets/test_visor_dataset.py +++ b/tests/unit/vtk/datasets/test_visor_dataset.py @@ -5,6 +5,7 @@ from vtkmodules.util.vtkConstants import VTK_FLOAT from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.models.common.part_properties import PartProperties # Target import from ansys.visor.viewer.vtk.datasets.visor_dataset import VisorDataset @@ -67,7 +68,7 @@ def make_dataset(is_composite=False, variables_instance=None): "ansys.visor.viewer.vtk.datasets.visor_dataset.is_composite_dataset", return_value=is_composite, ) - is_comp_ctx = is_comp_patch.start() + is_comp_patch.start() # Patch `VisorVariables` to return provided or mocked instance if variables_instance is None and not is_composite: @@ -76,7 +77,7 @@ def make_dataset(is_composite=False, variables_instance=None): "ansys.visor.viewer.vtk.datasets.visor_dataset.VisorVariables", return_value=variables_instance, ) - tv_ctx = tv_patch.start() + tv_patch.start() # Patch PartIndex so it doesn't try to traverse the mock VTK object mock_part_index = MagicMock() @@ -88,7 +89,7 @@ def make_dataset(is_composite=False, variables_instance=None): "ansys.visor.viewer.vtk.datasets.visor_dataset.PartIndex", return_value=mock_part_index, ) - pi_ctx = pi_patch.start() + pi_patch.start() # Create mock metadata with required structure mock_metadata = MagicMock() @@ -108,9 +109,9 @@ def make_dataset(is_composite=False, variables_instance=None): # Cleanup function to stop patches def _cleanup(): - is_comp_ctx.stop() - tv_ctx.stop() - pi_ctx.stop() + is_comp_patch.stop() + tv_patch.stop() + pi_patch.stop() return ds, variables_instance, _cleanup @@ -192,7 +193,7 @@ def test_update_variables_on_composite_broadcast_applies_to_all_matching_parts(d "ansys.visor.viewer.vtk.datasets.visor_dataset.is_composite_dataset", return_value=True, ) - is_comp_ctx = is_comp_patch.start() + is_comp_patch.start() part_id_0, part_id_1 = 111, 222 mock_part_index = MagicMock() @@ -205,7 +206,7 @@ def test_update_variables_on_composite_broadcast_applies_to_all_matching_parts(d "ansys.visor.viewer.vtk.datasets.visor_dataset.PartIndex", return_value=mock_part_index, ) - pi_ctx = pi_patch.start() + pi_patch.start() call_count = [0] def fake_variables_factory(*args, **kwargs): @@ -215,7 +216,7 @@ def fake_variables_factory(*args, **kwargs): "ansys.visor.viewer.vtk.datasets.visor_dataset.VisorVariables", side_effect=fake_variables_factory, ) - tv_ctx = tv_patch.start() + tv_patch.start() mock_metadata = MagicMock() mock_metadata.unit = "mm" @@ -237,9 +238,9 @@ def fake_variables_factory(*args, **kwargs): mock_vars_0.reload.assert_called_once() mock_vars_1.reload.assert_called_once() finally: - is_comp_ctx.stop() - tv_ctx.stop() - pi_ctx.stop() + is_comp_patch.stop() + tv_patch.stop() + pi_patch.stop() def test_update_variables_on_composite_broadcast_skips_non_matching_parts(dataset_factory): @@ -253,7 +254,7 @@ def test_update_variables_on_composite_broadcast_skips_non_matching_parts(datase "ansys.visor.viewer.vtk.datasets.visor_dataset.is_composite_dataset", return_value=True, ) - is_comp_ctx = is_comp_patch.start() + is_comp_patch.start() part_id_ok, part_id_bad = 111, 222 mock_part_index = MagicMock() @@ -266,7 +267,7 @@ def test_update_variables_on_composite_broadcast_skips_non_matching_parts(datase "ansys.visor.viewer.vtk.datasets.visor_dataset.PartIndex", return_value=mock_part_index, ) - pi_ctx = pi_patch.start() + pi_patch.start() call_count = [0] def fake_variables_factory(*args, **kwargs): @@ -276,7 +277,7 @@ def fake_variables_factory(*args, **kwargs): "ansys.visor.viewer.vtk.datasets.visor_dataset.VisorVariables", side_effect=fake_variables_factory, ) - tv_ctx = tv_patch.start() + tv_patch.start() mock_metadata = MagicMock() mock_metadata.unit = "mm" @@ -297,9 +298,9 @@ def fake_variables_factory(*args, **kwargs): mock_vars_ok.reload.assert_called_once() mock_vars_bad.reload.assert_not_called() finally: - is_comp_ctx.stop() - tv_ctx.stop() - pi_ctx.stop() + is_comp_patch.stop() + tv_patch.stop() + pi_patch.stop() def test_update_variables_validation_failure_raises(dataset_factory): @@ -324,24 +325,30 @@ def test_update_variables_happy_path_calls_reload_and_modifies(dataset_factory, mock_update = MagicMock() # Patch the internal `_update_variable` to avoid touching private behavior but ensure it gets invoked via public API - with patch.object(ds, "_update_variable") as upd: - ds.update_variables(variable_list=[mock_update, mock_update]) - variables.validate_new_variables.assert_called_once_with([mock_update, mock_update]) - assert upd.call_count == 2 - variables.reload.assert_called_once_with(mock_data_object) - # Note: The implementation no longer calls data.Modified() after reload. - mock_data_object.Modified.assert_not_called() + try: + with patch.object(ds, "_update_variable") as upd: + ds.update_variables(variable_list=[mock_update, mock_update]) + variables.validate_new_variables.assert_called_once_with([mock_update, mock_update]) + assert upd.call_count == 2 + variables.reload.assert_called_once_with(mock_data_object) + # Note: The implementation no longer calls data.Modified() after reload. + mock_data_object.Modified.assert_not_called() + finally: + cleanup() def test_update_variables_empty_list_validates_and_reloads(dataset_factory, mock_data_object): """Test that updating variables empty list validates and reloads.""" ds, variables, cleanup = dataset_factory(is_composite=False) variables.validate_new_variables.return_value = True - with patch.object(ds, "_update_variable") as upd: - ds.update_variables(variable_list=[]) - variables.validate_new_variables.assert_called_once_with([]) - upd.assert_not_called() - variables.reload.assert_called_once_with(mock_data_object) + try: + with patch.object(ds, "_update_variable") as upd: + ds.update_variables(variable_list=[]) + variables.validate_new_variables.assert_called_once_with([]) + upd.assert_not_called() + variables.reload.assert_called_once_with(mock_data_object) + finally: + cleanup() @pytest.fixture @@ -391,7 +398,7 @@ def dataset_with_variables(mock_data_object): "ansys.visor.viewer.vtk.datasets.visor_dataset.is_composite_dataset", return_value=False, ) - is_comp_ctx = is_comp_patch.start() + is_comp_patch.start() # Provide a VisorVariables mock variables = MagicMock() @@ -399,7 +406,7 @@ def dataset_with_variables(mock_data_object): "ansys.visor.viewer.vtk.datasets.visor_dataset.VisorVariables", return_value=variables, ) - tv_ctx = tv_patch.start() + tv_patch.start() # Patch PartIndex so it doesn't traverse the mock VTK object mock_part_index = MagicMock() @@ -411,7 +418,7 @@ def dataset_with_variables(mock_data_object): "ansys.visor.viewer.vtk.datasets.visor_dataset.PartIndex", return_value=mock_part_index, ) - pi_ctx = pi_patch.start() + pi_patch.start() # Create mock metadata mock_metadata = MagicMock() @@ -429,9 +436,9 @@ def dataset_with_variables(mock_data_object): ) def _cleanup(): - is_comp_ctx.stop() - tv_ctx.stop() - pi_ctx.stop() + is_comp_patch.stop() + tv_patch.stop() + pi_patch.stop() return ds, variables, _cleanup @@ -614,3 +621,54 @@ def test_update_variables_marks_dataset_dirty(dataset_with_variables, mock_point finally: cleanup() + +# ================================================================== # +# Part records: one per part, overlaid with the persisted entries +# ================================================================== # + +_DEFAULT_FIELDS = (1.0, True, False, [0.8, 0.8, 0.8], None) + + +def _fields(record): + """The five resolved fields of a part record, in a fixed order.""" + return (record.opacity, record.visible, record.selected, record.diffuse_rgb, record.color_variable) + + +def _three_part_dataset(parts_by_name): + """A VisorDataset over a patched three-part index named a/b/c -> 1/2/3, built with *parts_by_name*.""" + mock_part_index = MagicMock() + mock_part_index.name_to_id_map = {"a": 1, "b": 2, "c": 3} + mock_part_index.part_ids = [1, 2, 3] + mock_part_index.get_leaf_block.return_value = MagicMock() + + mock_metadata = MagicMock() + mock_metadata.unit = "mm" + mock_metadata.file_path = None + mock_metadata.metadata_path = None + mock_metadata.state.parts = parts_by_name + + with patch("ansys.visor.viewer.vtk.datasets.visor_dataset.PartIndex", return_value=mock_part_index), \ + patch("ansys.visor.viewer.vtk.datasets.visor_dataset.VisorVariables", return_value=MagicMock()): + return VisorDataset(id=7, name="ds", data=MagicMock(), node_ids=[], metadata=mock_metadata) + + +def test_persisted_to_runtime_state_overlays_the_named_part_and_defaults_the_rest(): + """Three parts, one named entry: three records, the named one overlaid, the others at the defaults.""" + ds = _three_part_dataset({}) + + state = ds.persisted_to_runtime_state({"b": PartProperties(opacity=0.5)}) + + assert sorted(state.part_states) == [1, 2, 3] + assert _fields(state.part_states[2]) == (0.5, True, False, [0.8, 0.8, 0.8], None) + assert _fields(state.part_states[1]) == _DEFAULT_FIELDS + assert _fields(state.part_states[3]) == _DEFAULT_FIELDS + + +def test_dataset_constructed_with_no_part_metadata_has_a_record_per_part(): + """A dataset whose metadata names no part holds a default record for every part.""" + ds = _three_part_dataset({}) + + assert sorted(ds.state.part_states) == [1, 2, 3] + for part_id in (1, 2, 3): + assert ds.state.part_states[part_id].id == part_id + assert _fields(ds.state.part_states[part_id]) == _DEFAULT_FIELDS diff --git a/tests/unit/vtk/datasets/test_visor_dataset_registry.py b/tests/unit/vtk/datasets/test_visor_dataset_registry.py index b38197a7..1809a091 100644 --- a/tests/unit/vtk/datasets/test_visor_dataset_registry.py +++ b/tests/unit/vtk/datasets/test_visor_dataset_registry.py @@ -6,6 +6,7 @@ from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( RuntimeDatasetState, RuntimePartProperties, + VariableReference, ) from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry @@ -40,6 +41,11 @@ def make_part_dataset(dataset_id, part_ids, part_states=None): return dataset +def _record(part_id, **overrides): + """A complete record for *part_id* with *overrides* applied.""" + return RuntimePartProperties.default_for(part_id).model_copy(update=overrides) + + def test_count_returns_number_of_datasets(registry): """Verify that count returns the number of registered datasets.""" assert registry.count == 0 @@ -223,7 +229,7 @@ def test_find_dataset_id_for_part_not_found_returns_none(registry): def test_get_part_state_returns_existing_record(registry): """Verify get_part_state returns the recorded state for a known part.""" - existing = RuntimePartProperties(id=10, opacity=0.7) + existing = _record(10, opacity=0.7) ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: existing}) registry.datasets = {1: ds_a} @@ -251,7 +257,7 @@ def test_get_part_state_unknown_part_id_returns_none(registry): def test_set_part_visibility_mutates_record_and_rejects_unknown_part(registry): """Verify set_part_visibility writes True/False, and False + no raise for unknown part_id.""" - ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: RuntimePartProperties(id=10)}) + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: _record(10)}) registry.datasets = {1: ds_a} assert registry.set_part_visibility(10, False) is True @@ -262,7 +268,7 @@ def test_set_part_visibility_mutates_record_and_rejects_unknown_part(registry): def test_set_part_opacity_mutates_record_and_rejects_unknown_part(registry): """Verify set_part_opacity writes the value, and False + no raise for unknown part_id.""" - ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: RuntimePartProperties(id=10)}) + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: _record(10)}) registry.datasets = {1: ds_a} assert registry.set_part_opacity(10, 0.4) is True @@ -271,26 +277,9 @@ def test_set_part_opacity_mutates_record_and_rejects_unknown_part(registry): assert registry.set_part_opacity(999999, 0.4) is False -def test_set_part_diffuse_color_mutates_and_clears(registry): - """Verify set_part_diffuse_color writes an RGB list, clears with None, and rejects unknown parts.""" - ds_a = make_part_dataset( - dataset_id=1, part_ids=[10], - part_states={10: RuntimePartProperties(id=10, diffuse_rgb=[1.0, 0.0, 0.0])}, - ) - registry.datasets = {1: ds_a} - - assert registry.set_part_diffuse_color(10, [0.0, 1.0, 0.0]) is True - assert registry.get_part_state(10).diffuse_rgb == [0.0, 1.0, 0.0] - - assert registry.set_part_diffuse_color(10, None) is True - assert registry.get_part_state(10).diffuse_rgb is None - - assert registry.set_part_diffuse_color(999999, [1.0, 1.0, 1.0]) is False - - def test_set_part_selected_mutates_record_and_rejects_unknown_part(registry): """Verify set_part_selected writes the value, and False + no raise for unknown part_id.""" - ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: RuntimePartProperties(id=10)}) + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: _record(10)}) registry.datasets = {1: ds_a} assert registry.set_part_selected(10, True) is True @@ -301,32 +290,30 @@ def test_set_part_selected_mutates_record_and_rejects_unknown_part(registry): def test_set_part_color_variable_sets_id_and_component_together(registry): """Verify set_part_color_variable sets variable_id and variable_component in one call.""" - seed = RuntimePartProperties(id=10) - assert seed.variable_id is None - assert seed.variable_component is None + seed = _record(10) ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: seed}) registry.datasets = {1: ds_a} assert registry.set_part_color_variable(10, "POINT::pressure::1", 2) is True result = registry.get_part_state(10) - assert result.variable_id == "POINT::pressure::1" - assert result.variable_component == 2 + assert result.color_variable == VariableReference(variable_id="POINT::pressure::1", variable_component=2) assert registry.set_part_color_variable(999999, "POINT::x::1", 0) is False def test_clear_part_color_variable_clears_id_and_component_together(registry): """Verify clear_part_color_variable clears variable_id and variable_component in one call.""" - seed = RuntimePartProperties(id=10, variable_id="POINT::pressure::1", variable_component=2) + seed = _record( + 10, color_variable=VariableReference(variable_id="POINT::pressure::1", variable_component=2) + ) ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={10: seed}) registry.datasets = {1: ds_a} assert registry.clear_part_color_variable(10) is True result = registry.get_part_state(10) - assert result.variable_id is None - assert result.variable_component is None + assert result.color_variable is None assert registry.clear_part_color_variable(999999) is False @@ -350,6 +337,20 @@ def test_setter_upserts_part_state_when_part_id_known_but_absent_from_part_state assert created.opacity == 0.6 +def test_upsert_creates_a_complete_default_record(registry): + """A setter on a part with no record leaves every other field at its default literal.""" + ds_a = make_part_dataset(dataset_id=1, part_ids=[10], part_states={}) + registry.datasets = {1: ds_a} + + registry.set_part_opacity(10, 0.6) + + created = ds_a.state.part_states[10] + assert created.visible is True + assert created.selected is False + assert created.diffuse_rgb == [0.8, 0.8, 0.8] + assert created.color_variable is None + + def test_replace_part_states_replaces_known_and_skips_unknown_without_aborting(registry): """ Verify replace_part_states replaces the state of a known dataset id and @@ -360,7 +361,7 @@ def test_replace_part_states_replaces_known_and_skips_unknown_without_aborting(r original_state = ds_a.state registry.datasets = {1: ds_a} - new_state_for_known = RuntimeDatasetState(id=1, part_states={10: RuntimePartProperties(id=10, opacity=0.9)}) + new_state_for_known = RuntimeDatasetState(id=1, part_states={10: _record(10, opacity=0.9)}) new_state_for_unknown = RuntimeDatasetState(id=999, part_states={}) registry.replace_part_states({1: new_state_for_known, 999: new_state_for_unknown}) diff --git a/tests/unit/vtk/io/test_visor_file_io.py b/tests/unit/vtk/io/test_visor_file_io.py index c4f3135b..345a7af5 100644 --- a/tests/unit/vtk/io/test_visor_file_io.py +++ b/tests/unit/vtk/io/test_visor_file_io.py @@ -461,9 +461,9 @@ def test_propagates_other_errors(self, tmp_path): class TestDeleteStaleSnapshots: def _make_state(self, dataset_states: dict) -> PersistedViewerStateV1: - from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState + from ansys.visor.viewer.models.persist.visor_ui_state import PersistedUIState return PersistedViewerStateV1.from_components( - ui_state=VisorUIState(), unit="m", orthographic_enabled=None, + ui_state=PersistedUIState(), unit="m", orthographic_enabled=None, cross_section_enabled=None, edges_enabled=None, bounding_box_enabled=None, datasets=dataset_states, variable_states={} ) diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 7b6b4b5a..4cf7a223 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -23,19 +23,25 @@ from vtkmodules.vtkCommonCore import vtkFloatArray from vtkmodules.vtkCommonDataModel import vtkPolyData from vtkmodules.vtkFiltersSources import vtkSphereSource +from vtkmodules.vtkRenderingCore import vtkActor +from ansys.visor.viewer.core.metadata import ExtendedMetadata from ansys.visor.viewer.core.visor_colors import VisorColors from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType 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.persist.persisted_viewer_state import PersistedViewerStateV1 +from ansys.visor.viewer.models.persist.visor_ui_state import PersistedUIState from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( RuntimeDatasetState, RuntimePartProperties, + VariableReference, ) from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState +from ansys.visor.viewer.models.runtime.scene.runtime_scene_state import RuntimeSceneState +from ansys.visor.viewer.models.runtime.scene.runtime_ui_state import RuntimeUIState +from ansys.visor.viewer.models.runtime.scene.runtime_view_state import RuntimeViewState from ansys.visor.viewer.renderer.local_renderer import VisorLocalRenderer from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry from ansys.visor.viewer.vtk.node_pipeline import VtkNodePipeline @@ -124,6 +130,44 @@ def _make_part_dataset(dataset_id: int, part_ids, part_states=None, part_variabl return dataset +def _record(part_id, **overrides): + """A complete record for *part_id* with *overrides* applied.""" + return RuntimePartProperties.default_for(part_id).model_copy(update=overrides) + + +def _ui_record(**overrides) -> RuntimeUIState: + """A complete UI record, at the server's own initial values unless overridden.""" + fields = { + "dark_theme": False, + "panel_top_left_panel_collapsed": False, + "panel_top_right_panel_collapsed": False, + "panel_top_right_legend_collapsed": False, + "panel_top_right_tab_index": 0, + } + fields.update(overrides) + return RuntimeUIState(**fields) + + +def _app_state(dataset_states=None, ui=None, **scene_fields) -> RuntimeAppState: + """A real, fully resolved RuntimeAppState with no view records. + + Scene-level fields are the literals below unless overridden. + """ + fields = { + "unit": "m", + "orthographic_enabled": None, + "cross_section_enabled": False, + "edges_enabled": False, + "bounding_box_enabled": False, + } + fields.update(scene_fields) + return RuntimeAppState( + ui=ui if ui is not None else _ui_record(), + view=RuntimeViewState(camera=None, cross_section=None), + scene=RuntimeSceneState(dataset_states=dataset_states or {}, **fields), + ) + + @pytest.fixture def array_dataset() -> vtkPolyData: """Sphere output carrying one named point array and one named cell array.""" @@ -343,13 +387,13 @@ def test_set_part_diffuse_color_applies_to_the_vtk_property(scene, pipeline): assert pipeline.actor.GetProperty().GetDiffuseColor() == pytest.approx((1.0, 0.0, 0.0)) -def test_set_part_diffuse_color_none_clears_the_registry_record(scene, registry): - """Store half of a clear: absence is stored as absence, not as a colour.""" +def test_set_part_diffuse_color_none_writes_the_default_colour_into_the_record(scene, registry): + """Store half of a reset: the record holds the default colour, not None.""" registry.set_part_diffuse_color(NODE_ID, [1.0, 0.0, 0.0]) scene.set_part_diffuse_color(NODE_ID, None) - assert registry.get_part_state(NODE_ID).diffuse_rgb is None + assert registry.get_part_state(NODE_ID).diffuse_rgb == [0.8, 0.8, 0.8] def test_set_part_diffuse_color_none_applies_the_default_mesh_colour(scene, pipeline): @@ -407,14 +451,6 @@ def test_set_part_selected_uses_the_stored_diffuse_colour(scene, registry, pipel assert pipeline.actor.GetProperty().GetDiffuseColor() == pytest.approx((0.25, 0.5, 0.75)) -def test_set_part_selected_falls_back_to_the_default_mesh_colour(scene, pipeline): - """With no stored colour, the default constant is used.""" - scene.set_part_selected(NODE_ID, True) - - assert pipeline.actor.GetProperty().GetDiffuseColor() == pytest.approx( - tuple(VisorColors.DefaultMeshColor) - ) - def test_set_part_selected_flushes_under_the_lock_after_the_apply(scene, pipeline): """Lock held, and the highlight already applied, when the flush runs.""" @@ -477,8 +513,9 @@ def test_set_part_color_variable_writes_the_registry_record(scene, registry): ) state = registry.get_part_state(NODE_ID) - assert state.variable_id == "POINT::pressure::1" - assert state.variable_component == 0 + assert state.color_variable == VariableReference( + variable_id="POINT::pressure::1", variable_component=0 + ) def test_set_part_color_variable_applies_to_the_vtk_mapper(scene, pipeline): @@ -717,8 +754,7 @@ def test_clear_part_color_variable_clears_the_registry_record(scene, registry): scene.clear_part_color_variable(NODE_ID) state = registry.get_part_state(NODE_ID) - assert state.variable_id is None - assert state.variable_component is None + assert state.color_variable is None def test_clear_part_color_variable_disables_scalar_visibility_on_the_mapper(scene, pipeline): @@ -809,7 +845,7 @@ def test_unknown_node_id_is_a_logged_no_op(scene, registry, pipeline, name): def test_seeded_part_state_is_mutated_in_place(scene, registry): """An existing record is mutated in place, not replaced.""" - seeded = RuntimePartProperties(id=NODE_ID) + seeded = _record(NODE_ID) registry.datasets[1].state.part_states[NODE_ID] = seeded scene.set_part_opacity(NODE_ID, 0.25) @@ -832,14 +868,18 @@ def mocked_scene(renderer): s._scene_graph = MagicMock(name="scene_graph") s._dataset_registry = MagicMock(name="dataset_registry") s._dataset_registry.count = 1 + # apply_state snapshots the server's records into validated models, so the + # values it reads must be real: a MagicMock is absorbed for calls, not for values. + s._dataset_registry.unit = "m" + s._dataset_registry.runtime_state_dict = {} s._renderer = MagicMock(name="renderer") + s._renderer.get_camera_state.return_value = None + s._renderer.get_cross_section_plane.return_value = None s._state_mapper = MagicMock(name="state_mapper") - # apply_state now walks the mapped state's dataset_states, so the mapper + # apply_state walks the mapped state's dataset_states, so the mapper # 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={}, variable_states={} - ) + s._state_mapper.persisted_to_runtime.return_value = _app_state() s._vtk_lock = _LockSpy() return s @@ -1046,13 +1086,10 @@ def _runtime_state(part_states, dataset_id=1): 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", + return _app_state( dataset_states={ dataset_id: RuntimeDatasetState(id=dataset_id, part_states=part_states) }, - variable_states={}, ) @@ -1359,7 +1396,13 @@ def test_apply_state_pushes_a_json_encodable_runtime_state(scene, registry): _apply( scene, _runtime_state( - {NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.25, variable_id=VARIABLE_ID)}, + { + NODE_ID: _record( + NODE_ID, + opacity=0.25, + color_variable=VariableReference(variable_id=VARIABLE_ID, variable_component=0), + ) + }, ), file_variable_states={VARIABLE_ID: _variable_state()}, ) @@ -1377,7 +1420,7 @@ def test_apply_state_populates_the_registry_from_the_persisted_state(scene, regi _apply( scene, _runtime_state( - {NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.25, visible=False)} + {NODE_ID: _record(NODE_ID, opacity=0.25, visible=False)} ), ) @@ -1398,8 +1441,8 @@ def test_apply_state_applies_every_part_to_the_pipeline( scene, _runtime_state( { - NODE_ID: RuntimePartProperties(id=NODE_ID, visible=False), - SECOND_NODE_ID: RuntimePartProperties(id=SECOND_NODE_ID, opacity=0.25), + NODE_ID: _record(NODE_ID, visible=False), + SECOND_NODE_ID: _record(SECOND_NODE_ID, opacity=0.25), } ), ) @@ -1436,7 +1479,7 @@ def test_apply_state_flushes_before_the_bridge_call(scene, registry): scene._push_runtime_state = lambda state: order.append("bridge") scene._renderer._local_view.update = lambda: order.append("flush") - _apply(scene, _runtime_state({NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.25)})) + _apply(scene, _runtime_state({NODE_ID: _record(NODE_ID, opacity=0.25)})) assert order == ["flush", "bridge"] @@ -1444,19 +1487,12 @@ def test_apply_state_flushes_before_the_bridge_call(scene, registry): # --------------------------------------------------------------------------- # Load path — the camera # -# These are the only tests in this module that drive apply_state with a real -# PersistedViewerStateV1, through the real state mapper, rather than through -# the _apply helper (which stubs the mapper) or mocked_scene (which pins its -# return value). Two consequences, both deliberate: -# -# 1. They exercise the mapper's camera pass-through as well as the camera -# step, so they are the first tests here that would notice if the mapper -# stopped handing camera on verbatim. -# 2. A failure could in principle be the unstubbed path rather than the -# camera step. The camera-bearing and camera-free tests below are built -# from the SAME constructor call, differing in one argument, so the pair -# discriminates: both failing means the shared path, only the first -# failing means the camera step, only the second means the guard. +# These tests drive apply_state with a real PersistedViewerStateV1, through +# the real state mapper, rather than through the _apply helper (which stubs +# the mapper) or mocked_scene (which pins its return value). They exercise +# the mapper's camera resolution as well as the camera step, so they would +# notice if the mapper stopped handing a file's camera on verbatim. The +# null-camera case is pinned in the overlay section below. # # ``datasets={}`` keeps the mapper's per-dataset loop empty, so the registry # hop (get_by_name) is never taken and cannot contribute a failure. @@ -1474,14 +1510,9 @@ def test_apply_state_flushes_before_the_bridge_call(scene, registry): def _persisted_state(camera): - """A real PersistedViewerStateV1 carrying *camera*, or no camera at all. - - One constructor, one varying argument: the camera-bearing and camera-free - cases differ in nothing else, which is what makes the pair a discriminator - rather than two unrelated tests. - """ + """A real PersistedViewerStateV1 carrying *camera*.""" return PersistedViewerStateV1.from_components( - ui_state=VisorUIState(dark_theme=False), + ui_state=PersistedUIState(dark_theme=False), unit="m", orthographic_enabled=None, cross_section_enabled=None, @@ -1537,23 +1568,6 @@ def test_apply_state_projects_the_camera_onto_the_pipeline(scene): camera.SetParallelScale.assert_called_once_with(CAMERA_PARALLEL_SCALE) -def test_apply_state_without_a_camera_leaves_the_record_untouched(scene): - """Absent says nothing: a camera-free file does not reset the record. - - The negative twin of the two tests above -- same constructor, one - argument changed -- so a failure here against a pass there isolates the - guard, and a failure in both isolates the unstubbed path instead. - """ - seeded = _persisted_camera() - scene._renderer.sync_camera(seeded) - scene._renderer._vtk_renderer.GetActiveCamera.return_value.reset_mock() - - scene.apply_state(_persisted_state(None)) - - assert scene._renderer.get_camera_state() is seeded - scene._renderer._vtk_renderer.GetActiveCamera.return_value.SetPosition.assert_not_called() - - def test_apply_state_syncs_the_camera_under_the_lock_before_the_render_step(scene): """The camera step holds the lock, and precedes the delegated render. @@ -1844,7 +1858,7 @@ def _apply_opacity(node_id, opacity): registry.replace_part_states = _replace scene._renderer.apply_opacity = _apply_opacity - _apply(scene, _runtime_state({NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.25)})) + _apply(scene, _runtime_state({NODE_ID: _record(NODE_ID, opacity=0.25)})) assert observed["store_depth"] >= 1 assert observed["apply_depth"] >= 1 @@ -1859,7 +1873,7 @@ def test_apply_state_unregistered_dataset_id_is_a_logged_skip(scene, pipeline): _apply( scene, _runtime_state( - {NODE_ID: RuntimePartProperties(id=NODE_ID, visible=False)}, + {NODE_ID: _record(NODE_ID, visible=False)}, dataset_id=FRONTEND_DATASET_ID, ), ) @@ -1876,7 +1890,7 @@ def test_apply_state_unknown_node_is_a_logged_no_op(scene, registry, pipeline): result = _apply( scene, _runtime_state( - {UNKNOWN_NODE_ID: RuntimePartProperties(id=UNKNOWN_NODE_ID, visible=False)} + {UNKNOWN_NODE_ID: _record(UNKNOWN_NODE_ID, visible=False)} ), ) @@ -1885,21 +1899,33 @@ def test_apply_state_unknown_node_is_a_logged_no_op(scene, registry, pipeline): assert pipeline.actor.GetVisibility() == 1 -def test_apply_state_short_diffuse_color_is_a_logged_no_op(scene, registry, pipeline): - """A malformed colour leaves the VTK object alone; the record keeps it.""" - pipeline.actor.GetProperty().SetDiffuseColor(0.1, 0.2, 0.3) - with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply( - scene, - _runtime_state( - {NODE_ID: RuntimePartProperties(id=NODE_ID, diffuse_rgb=[1.0, 0.0])} - ), - ) +RESTORE_SELECTION_CASES = { + # case: (selected, stored colour, expected ambient, expected diffuse, expected colour) + "selected_custom_colour": (True, [0.25, 0.5, 0.75], 0.5, 0.5, (0.25, 0.5, 0.75)), + "selected_default_colour": (True, [0.8, 0.8, 0.8], 0.5, 0.5, (0.8, 0.8, 0.8)), + "unselected_custom_colour": (False, [0.25, 0.5, 0.75], 0.0, 1.0, (0.25, 0.5, 0.75)), + "unselected_default_colour": (False, [0.8, 0.8, 0.8], 0.0, 1.0, (0.8, 0.8, 0.8)), +} - assert mock_logger.warning.call_count == 1 - assert pipeline.actor.GetProperty().GetDiffuseColor() == pytest.approx((0.1, 0.2, 0.3)) - assert registry.get_part_state(NODE_ID).diffuse_rgb == [1.0, 0.0] + +@pytest.mark.parametrize("case", list(RESTORE_SELECTION_CASES)) +def test_apply_state_restores_selection_lighting_and_colour(scene, registry, pipeline, case): + """The restored actor carries the record's selection lighting and its stored colour.""" + selected, stored_rgb, ambient, diffuse, colour = RESTORE_SELECTION_CASES[case] + prop = pipeline.actor.GetProperty() + prop.SetAmbient(0.3) + prop.SetDiffuse(0.7) + prop.SetDiffuseColor(0.1, 0.2, 0.3) + + _apply( + scene, + _runtime_state({NODE_ID: _record(NODE_ID, selected=selected, diffuse_rgb=stored_rgb)}), + ) + + assert prop.GetAmbient() == pytest.approx(ambient) + assert prop.GetDiffuse() == pytest.approx(diffuse) + assert prop.GetDiffuseColor() == pytest.approx(colour) # --------------------------------------------------------------------------- @@ -1910,8 +1936,9 @@ 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 + NODE_ID: _record( + NODE_ID, + color_variable=VariableReference(variable_id=variable_id, variable_component=component), ) }, ) @@ -2072,43 +2099,11 @@ def test_apply_state_clears_the_color_variable_when_none_is_stored( """No stored identifier and no stored component: the clear branch.""" pipeline.mapper.SetScalarVisibility(1) - _apply(scene, _runtime_state({NODE_ID: RuntimePartProperties(id=NODE_ID)})) + _apply(scene, _runtime_state({NODE_ID: _record(NODE_ID, color_variable=None)})) assert pipeline.mapper.GetScalarVisibility() == 0 -def test_apply_state_variable_id_without_component_is_a_logged_no_op( - scene, registry, pipeline -): - """Half a compound value: an identifier with no component.""" - _seed_part_variables(registry, [_pressure_variable()]) - _seed_unconfigured_mapper(pipeline) - runtime = _runtime_state( - {NODE_ID: RuntimePartProperties(id=NODE_ID, variable_id=VARIABLE_ID)}, - ) - - with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply(scene, runtime, _file_entries()) - - assert mock_logger.warning.call_count == 1 - assert pipeline.mapper.GetScalarVisibility() == 0 - - -def test_apply_state_component_without_variable_id_does_not_clear( - scene, registry, pipeline -): - """The mirror half: a component with no identifier must not fall through.""" - pipeline.mapper.SetScalarVisibility(1) - runtime = _runtime_state( - {NODE_ID: RuntimePartProperties(id=NODE_ID, variable_component=0)}, - ) - - with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: - _apply(scene, runtime, _file_entries()) - - assert mock_logger.warning.call_count == 1 - assert pipeline.mapper.GetScalarVisibility() == 1 - # =========================================================================== # Widget state -- the server store, the coordinator surface, and delivery @@ -2141,9 +2136,11 @@ class _ToggleSpyRenderer: ``update_bounds``, ``update_actor_count`` and ``render`` are the scene surface ``apply_state`` reaches through its ``finalize_scene`` call, and - are the only three names added for it: with a toggle-only state the part + ``get_camera_state`` and ``get_cross_section_plane`` are the two record + reads of its current-state snapshot; those five are the only names added + beyond the toggles: with a toggle-only state the part loop makes no renderer call, the cross-section and camera restores are - both guarded off, and ``skip_reset_camera=True`` keeps ``reset_camera`` + both skipped on ``None`` records, and ``skip_reset_camera=True`` keeps ``reset_camera`` out. Anything else is still an AttributeError, which is the point of the double. They record into ``scene_calls`` rather than ``calls`` so that the toggle assertions keep their exact meaning; nothing asserts @@ -2180,6 +2177,15 @@ def update_actor_count(self, count): def render(self): self.scene_calls.append("render") + def get_camera_state(self): + """``apply_state`` snapshots the current records before overlaying; there are none.""" + self.scene_calls.append("get_camera_state") + return None + + def get_cross_section_plane(self): + self.scene_calls.append("get_cross_section_plane") + return None + class _SceneDetailsRenderer: """Renderer double for the scene-details delivery path. @@ -2358,14 +2364,8 @@ def test_get_state_takes_the_toggles_from_the_store(scene): # --------------------------------------------------------------------------- def _toggle_runtime_state(**toggles): - """A real RuntimeAppState carrying only the toggles named.""" - return RuntimeAppState.from_components( - ui=VisorUIState(dark_theme=False), - unit="m", - dataset_states={}, - **toggles, - variable_states={}, - ) + """A real, fully resolved RuntimeAppState with the toggles named; the others are False.""" + return _app_state(**toggles) def test_apply_state_writes_the_toggles_to_the_store(scene): @@ -2405,42 +2405,85 @@ def test_apply_state_applies_the_toggles_to_the_renderer(scene): ] -def test_apply_state_absent_toggles_leave_the_store_untouched(scene): - """Absent says nothing: a state with no toggles is not a state of False. +def test_apply_state_ignores_orthographic_enabled(scene): + """The persisted projection toggle is emitted, never read back. - The guard belongs to this path and not to get_state, and this is the test - that says so. The store is seeded to ``True`` first, so a body that wrote - the model's own ``None`` default through would be caught. + Two independent fields writing one camera property is the divergence this + story removes; projection arrives on the camera and nowhere else. Pinned + so that a later session does not restore the read as a bug fix: nothing on + the scene stores it, and a file whose flag says parallel over a perspective + camera loads as perspective. """ + pushed = [] + scene._push_runtime_state = pushed.append + + scene.apply_state( + _loaded_file(scene={"orthographic_enabled": True, "camera": _persisted_camera()}) + ) + + assert pushed[0].scene.orthographic_enabled is False + assert scene._renderer.get_camera_state().parallel_projection is False + assert not hasattr(scene, "_orthographic_enabled") + + +# --------------------------------------------------------------------------- +# apply_state -- a loaded file is overlaid onto the server's current state +# --------------------------------------------------------------------------- + +def _loaded_file(scene=None, ui=None) -> PersistedViewerStateV1: + """A real loaded file naming exactly the scene and UI keys given.""" + return PersistedViewerStateV1.model_validate({"ui": ui or {}, "scene": scene or {}}) + + +def test_absent_toggle_keeps_a_non_default_store_value(scene): + """Unnamed keeps the current value; named takes the file's.""" scene.set_cross_section_visibility(TOGGLE_ON) scene.set_edges_visible(TOGGLE_ON) scene.set_bounding_box_visibility(TOGGLE_ON) - spy = _ToggleSpyRenderer(scene) - scene._renderer = spy - _apply(scene, _toggle_runtime_state()) + scene.apply_state(_loaded_file(scene={"edges_enabled": False})) assert scene._cross_section_enabled is True - assert scene._edges_enabled is True + assert scene._edges_enabled is False assert scene._bounding_box_enabled is True - assert spy.calls == [] -def test_apply_state_ignores_orthographic_enabled(scene): - """The persisted projection toggle is emitted, never read back. +def test_null_unit_and_null_toggle_resolve_to_the_defaults(scene): + """Named as null takes the scene default: an empty unit, a toggle off.""" + scene._dataset_registry.set_unit("mm") + scene.set_edges_visible(TOGGLE_ON) - Two independent fields writing one camera property is the divergence this - story removes; projection arrives on the camera and nowhere else. Pinned - so that a later session does not restore the read as a bug fix: nothing on - the scene stores it, and a state carrying only that field changes nothing. - """ - spy = _ToggleSpyRenderer(scene) - scene._renderer = spy + scene.apply_state(_loaded_file(scene={"unit": None, "edges_enabled": None})) - _apply(scene, _toggle_runtime_state(orthographic_enabled=True)) + assert scene._dataset_registry.unit == "" + assert scene._edges_enabled is False - assert spy.calls == [] - assert not hasattr(scene, "_orthographic_enabled") + +def test_null_theme_resolves_to_the_constructor_theme(renderer, registry): + """A null UI field takes the value the scene was built with, not the model's.""" + s = _ConcreteScene(MagicMock(name="server"), dark_mode=True, renderer=renderer) + s._dataset_registry = registry + s._renderer.flush_wasm_state = MagicMock(name="flush_wasm_state") + s.dark_mode = False + s._ui_state.panel_top_right_tab_index = 2 + + s.apply_state(_loaded_file(ui={"dark_theme": None, "panel_top_right_tab_index": None})) + + assert s.dark_mode is True + assert s._ui_state.panel_top_right_tab_index == 0 + + +def test_null_camera_leaves_the_camera_record_unchanged(scene): + """A null camera or plane leaves the renderer's record as it is; neither has a default.""" + scene.sync_camera(_record_camera()) + scene.sync_cross_section_plane(RECORD_CROSS_SECTION_ORIGIN, RECORD_CROSS_SECTION_NORMAL) + + scene.apply_state(_loaded_file(scene={"camera": None, "cross_section": None})) + + assert scene._renderer.get_camera_state() == _record_camera() + plane = scene._renderer.get_cross_section_plane() + assert plane.origin == RECORD_CROSS_SECTION_ORIGIN + assert plane.normal == RECORD_CROSS_SECTION_NORMAL # --------------------------------------------------------------------------- @@ -2467,7 +2510,7 @@ def test_get_scene_details_delivers_the_four_widget_keywords(scene): double = _SceneDetailsRenderer(_delivered_camera()) scene._renderer = double - delivered = scene.get_scene_details().app_state.scene + delivered = scene.get_scene_details().scene assert delivered.cross_section_enabled is True assert delivered.edges_enabled is True @@ -2490,7 +2533,7 @@ def test_get_scene_details_delivers_no_orthographic_flag_when_the_camera_record_ scene._dataset_registry = VisorDatasetRegistry() scene._renderer = _SceneDetailsRenderer(None) - delivered = scene.get_scene_details().app_state.scene + delivered = scene.get_scene_details().scene assert delivered.orthographic_enabled is None assert delivered.edges_enabled is True @@ -2909,7 +2952,7 @@ def _sync(origin, normal): scene.apply_state( PersistedViewerStateV1.from_components( - ui_state=VisorUIState(dark_theme=False), + ui_state=PersistedUIState(dark_theme=False), unit="m", orthographic_enabled=None, cross_section_enabled=None, @@ -2991,13 +3034,8 @@ def _ui_save_scene(scene): def _panel_runtime_state(ui): - """A real RuntimeAppState carrying only the UI record named.""" - return RuntimeAppState.from_components( - ui=ui, - unit="m", - dataset_states={}, - variable_states={}, - ) + """A real, fully resolved RuntimeAppState carrying the UI record named.""" + return _app_state(ui=ui) # --------------------------------------------------------------------------- @@ -3104,8 +3142,7 @@ def test_apply_state_restores_the_ui_panel_state_to_the_store(scene): _apply( scene, _panel_runtime_state( - VisorUIState( - dark_theme=False, + _ui_record( panel_top_left_panel_collapsed=PANEL_COLLAPSED, panel_top_right_panel_collapsed=PANEL_COLLAPSED, panel_top_right_legend_collapsed=PANEL_COLLAPSED, @@ -3121,19 +3158,18 @@ def test_apply_state_restores_the_ui_panel_state_to_the_store(scene): def test_apply_state_leaves_an_absent_ui_panel_field_alone(scene): - """Absent says nothing: a state with no panel fields is not a state of defaults. + """Absent says nothing: a file with no panel fields is not a file of defaults. - The guard belongs to this path and not to get_state, and this is the test - that says so. The store is seeded through the coordinators first, so a - body that wrote the model's own ``None`` default through would be caught; - a file written before this record existed carries exactly this shape. + The store is seeded through the coordinators first, so a resolution that + based the overlay on the defaults rather than the current record would be + caught; a file written before this record existed carries exactly this shape. """ scene.set_panel_top_left_panel_collapsed(PANEL_COLLAPSED) scene.set_panel_top_right_panel_collapsed(PANEL_COLLAPSED) scene.set_panel_top_right_legend_collapsed(PANEL_COLLAPSED) scene.set_panel_top_right_tab_index(PANEL_TAB_INDEX) - _apply(scene, _panel_runtime_state(VisorUIState(dark_theme=False))) + scene.apply_state(_loaded_file(ui={"darkTheme": False})) assert scene._ui_state.panel_top_left_panel_collapsed is True assert scene._ui_state.panel_top_right_panel_collapsed is True @@ -3436,8 +3472,7 @@ def test_rebuild_at_update_clears_a_reference_whose_variable_is_gone(scene, regi _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 + assert state.color_variable 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 @@ -3609,3 +3644,109 @@ def test_get_state_takes_the_unit_from_the_registry(scene, registry): scene.get_state() assert captured["runtime_state"].scene.unit == "mm" + + +# --------------------------------------------------------------------------- +# add_dataset applies each part's record to its pipeline +# +# A real registry and scene graph, so the record is the one the metadata +# seeds at construction and the pipeline is the one register_node builds. +# The dataset is non-composite, so its single part takes the dataset name. +# --------------------------------------------------------------------------- + +ADDED_ACTOR_WASM_ID = 8150004 + + +@pytest.fixture +def add_scene(renderer): + """Scene with the real renderer, a real registry and a real scene graph, holding no dataset.""" + # A MagicMock plane would reach mapper.AddClippingPlane in register_node. + renderer._cross_section_widget.plane = None + return _ConcreteScene(MagicMock(name="server"), dark_mode=False, renderer=renderer) + + +def _add_sphere(scene, dataset, parts): + """add_dataset *dataset* named "sphere" with *parts* as its metadata part entries; return its pipeline.""" + metadata = ExtendedMetadata(name="sphere", unit="m", state={"parts": parts}) + dataset_id = scene.add_dataset(dataset, metadata) + part_id = scene.datasets[dataset_id].part_index.part_ids[0] + return scene._renderer._pipelines[part_id] + + +def _record_served_actor_visibility(renderer): + """Record the actor's visibility at each re-serialization of an actor, from here on.""" + other_ids = renderer._object_manager.GetId.side_effect + actors = {} + + def _get_id(obj): + if isinstance(obj, vtkActor): + actors[ADDED_ACTOR_WASM_ID] = obj + return ADDED_ACTOR_WASM_ID + return other_ids(obj) + + served = [] + renderer._object_manager.GetId.side_effect = _get_id + renderer._object_manager.UpdateStateFromObject.side_effect = lambda object_id: ( + served.append(actors[object_id].GetVisibility()) if object_id == ADDED_ACTOR_WASM_ID else None + ) + return served + + +def test_add_dataset_applies_metadata_visibility_to_the_actor(add_scene, array_dataset): + """Metadata hides the part: the actor is hidden, and the actor is served hidden.""" + served = _record_served_actor_visibility(add_scene._renderer) + + pipe = _add_sphere(add_scene, array_dataset, {"sphere": {"visible": False}}) + + assert pipe.actor.GetVisibility() == 0 + assert served == [0] + + +def test_add_dataset_applies_metadata_opacity_and_color_to_the_property(add_scene, array_dataset): + """Metadata opacity and diffuse colour reach the actor's property.""" + pipe = _add_sphere( + add_scene, array_dataset, {"sphere": {"opacity": 0.25, "diffuse_rgb": [0.75, 0.5, 0.25]}} + ) + + prop = pipe.actor.GetProperty() + assert prop.GetOpacity() == pytest.approx(0.25) + assert prop.GetDiffuseColor() == pytest.approx((0.75, 0.5, 0.25)) + + +def test_add_dataset_applies_selection_lighting_before_color(add_scene, array_dataset): + """A part selected with a custom colour: lighting terms, ambient colour and diffuse colour are all set.""" + pipe = _add_sphere( + add_scene, array_dataset, {"sphere": {"selected": True, "diffuse_rgb": [0.75, 0.5, 0.25]}} + ) + + prop = pipe.actor.GetProperty() + assert prop.GetDiffuse() == pytest.approx(0.5) + assert prop.GetAmbient() == pytest.approx(0.5) + assert prop.GetAmbientColor() == pytest.approx((0.0, 0.2431372549, 0.4352941176)) + assert prop.GetDiffuseColor() == pytest.approx((0.75, 0.5, 0.25)) + + +def test_add_dataset_without_metadata_leaves_the_record_applied(add_scene, array_dataset): + """No part entries: the default record is written over a pipeline built with other values.""" + real_from_dataset = VtkNodePipeline.from_dataset + + def _dirtied(dataset): + pipe = real_from_dataset(dataset) + pipe.actor.SetVisibility(0) + prop = pipe.actor.GetProperty() + prop.SetOpacity(0.3) + prop.SetDiffuse(0.4) + prop.SetAmbient(0.6) + prop.SetDiffuseColor(0.1, 0.2, 0.3) + return pipe + + with patch.object(VtkNodePipeline, "from_dataset", side_effect=_dirtied): + pipe = _add_sphere(add_scene, array_dataset, {}) + + prop = pipe.actor.GetProperty() + assert pipe.actor.GetVisibility() == 1 + assert prop.GetOpacity() == pytest.approx(1.0) + assert prop.GetDiffuse() == pytest.approx(1.0) + assert prop.GetAmbient() == pytest.approx(0.0) + assert prop.GetDiffuseColor() == pytest.approx((0.8, 0.8, 0.8)) + diff --git a/tests/unit/vtk/scene/test_local_scene.py b/tests/unit/vtk/scene/test_local_scene.py index 859f12ab..59441b73 100644 --- a/tests/unit/vtk/scene/test_local_scene.py +++ b/tests/unit/vtk/scene/test_local_scene.py @@ -513,9 +513,9 @@ def test_get_scene_details_returns_model(pipeline_instance): details = pipeline_instance.get_scene_details() - assert details.app_state.ui.dark_theme == pipeline_instance.dark_mode - assert details.app_state.scene.unit == "m" - assert details.app_state.scene.dataset_states == {} + assert details.ui.dark_theme == pipeline_instance.dark_mode + assert details.scene.unit == "m" + assert details.scene.dataset_states == {} assert details.vtk_info.renderer_annotation.widgets.orientation_widget_id == 10 assert details.vtk_info.renderer_annotation.widgets.cross_section_plane_id == 20 assert details.vtk_info.renderer_annotation.widgets.cross_section_plane_widget_id == 21 @@ -538,21 +538,20 @@ def test_get_scene_details_json_is_valid(pipeline_instance): payload = json.loads(pipeline_instance.get_scene_details_json()) # Top-level fields - assert payload["appState"]["scene"]["unit"] == "m" - assert payload["appState"]["ui"]["darkTheme"] == pipeline_instance.dark_mode + assert payload["scene"]["unit"] == "m" + assert payload["ui"]["darkTheme"] == pipeline_instance.dark_mode - # The four panel keys, at the client's own mount defaults. The gate for - # the push path: this dump is ``exclude_none=True``, so a store left at - # ``None`` would omit these keys entirely, the client would skip all four - # of its apply branches, and a refresh would show the panels' own + # The four panel keys, at the client's own mount defaults. A store left + # at ``None`` would push null for these keys, the client would skip all + # four of its apply branches, and a refresh would show the panels' own # defaults -- which is indistinguishable from working at a glance. The # expected values are hand-written literals, so a store initialised to - # ``None`` fails here on presence and a store initialised to the wrong - # values fails on value. - assert payload["appState"]["ui"]["panelTopLeftPanelCollapsed"] is False - assert payload["appState"]["ui"]["panelTopRightPanelCollapsed"] is False - assert payload["appState"]["ui"]["panelTopRightLegendCollapsed"] is False - assert payload["appState"]["ui"]["panelTopRightTabIndex"] == 0 + # ``None`` fails here on value, as does a store initialised to the wrong + # values. + assert payload["ui"]["panelTopLeftPanelCollapsed"] is False + assert payload["ui"]["panelTopRightPanelCollapsed"] is False + assert payload["ui"]["panelTopRightLegendCollapsed"] is False + assert payload["ui"]["panelTopRightTabIndex"] == 0 # Widget IDs are ints, now under rendererAnnotation.widgets widgets = payload["vtkInfo"]["rendererAnnotation"]["widgets"] @@ -566,7 +565,7 @@ def test_get_scene_details_json_is_valid(pipeline_instance): # Scene graph and state presence assert "sceneGraph" in payload["vtkInfo"] - dataset_dictionaries = payload["appState"]["scene"]["datasetStates"] + dataset_dictionaries = payload["scene"]["datasetStates"] assert dataset_dictionaries == {} diff --git a/tests/unit/vtk/scene/test_visor_frontend_bridge.py b/tests/unit/vtk/scene/test_visor_frontend_bridge.py index a48321de..2961bca2 100644 --- a/tests/unit/vtk/scene/test_visor_frontend_bridge.py +++ b/tests/unit/vtk/scene/test_visor_frontend_bridge.py @@ -1,3 +1,9 @@ +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.runtime.scene.runtime_app_state import RuntimeAppState +from ansys.visor.viewer.models.runtime.scene.runtime_scene_state import RuntimeSceneState +from ansys.visor.viewer.models.runtime.scene.runtime_ui_state import RuntimeUIState +from ansys.visor.viewer.models.runtime.scene.runtime_view_state import RuntimeViewState from ansys.visor.viewer.vtk.scene.visor_frontend_bridge import VisorFrontendBridge # ------------------------------------------------------------------ @@ -49,3 +55,52 @@ def model_dump(self, by_alias=True): assert server.calls[0][0] == "visor-frontend-ref" assert server.calls[0][1] == "setState" assert server.calls[0][2] == {"id": 123} + + +def test_load_push_carries_camera_and_plane_under_the_view(): + """The real request, dumped by the real bridge call: camera and plane sit in appState.view, not the scene.""" + state = RuntimeAppState( + ui=RuntimeUIState( + dark_theme=True, + panel_top_left_panel_collapsed=False, + panel_top_right_panel_collapsed=False, + panel_top_right_legend_collapsed=False, + panel_top_right_tab_index=0, + ), + view=RuntimeViewState( + camera=VisorCameraState( + position=[1.0, 2.0, 3.0], + focal_point=[4.0, 5.0, 6.0], + view_up=[0.0, 0.0, 1.0], + clipping_range=[7.0, 8.0], + parallel_projection=True, + view_angle=30.0, + parallel_scale=9.0, + ), + cross_section=VisorCrossSectionState(origin=[0.5, 0.5, 0.5], normal=[0.0, 1.0, 0.0]), + ), + scene=RuntimeSceneState( + unit="mm", + orthographic_enabled=True, + cross_section_enabled=False, + edges_enabled=True, + bounding_box_enabled=False, + ), + ) + server = FakeServer() + + VisorFrontendBridge(server, "visor-frontend-ref").set_state(state) + + app_state = server.calls[0][2]["appState"] + assert "camera" not in app_state["scene"] + assert "crossSection" not in app_state["scene"] + assert app_state["view"]["camera"] == { + "position": [1.0, 2.0, 3.0], + "focalPoint": [4.0, 5.0, 6.0], + "viewUp": [0.0, 0.0, 1.0], + "clippingRange": [7.0, 8.0], + "parallelProjection": True, + "viewAngle": 30.0, + "parallelScale": 9.0, + } + assert app_state["view"]["crossSection"] == {"origin": [0.5, 0.5, 0.5], "normal": [0.0, 1.0, 0.0]} diff --git a/tests/unit/vtk/scene/test_visor_state_mapper.py b/tests/unit/vtk/scene/test_visor_state_mapper.py index f84abbaa..60f1c0bd 100644 --- a/tests/unit/vtk/scene/test_visor_state_mapper.py +++ b/tests/unit/vtk/scene/test_visor_state_mapper.py @@ -1,10 +1,17 @@ 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.part_properties import PartProperties +from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState 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.persist.dataset.persisted_dataset_state import PersistedDatasetState +from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 +from ansys.visor.viewer.models.persist.visor_ui_state import PersistedUIState from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState +from ansys.visor.viewer.models.runtime.scene.runtime_scene_state import RuntimeSceneState +from ansys.visor.viewer.models.runtime.scene.runtime_ui_state import RuntimeUIState +from ansys.visor.viewer.models.runtime.scene.runtime_view_state import RuntimeViewState from ansys.visor.viewer.vtk.scene.visor_state_mapper import VisorStateMapper @@ -50,6 +57,51 @@ def get_by_name(self, name): return self._by_name.get(name) +def _ui(dark_theme=False) -> RuntimeUIState: + """A complete UI record from literals.""" + return RuntimeUIState( + dark_theme=dark_theme, + panel_top_left_panel_collapsed=False, + panel_top_right_panel_collapsed=False, + panel_top_right_legend_collapsed=False, + panel_top_right_tab_index=0, + ) + + +def _camera(parallel_projection=False) -> VisorCameraState: + """A camera record from literals.""" + return VisorCameraState( + position=[1.0, 2.0, 3.0], + focal_point=[4.0, 5.0, 6.0], + view_up=[0.0, 0.0, 1.0], + clipping_range=[7.0, 8.0], + parallel_projection=parallel_projection, + view_angle=30.0, + parallel_scale=9.0, + ) + + +def _app_state(camera=None, **scene_fields) -> RuntimeAppState: + """A runtime state with every scene-level field filled from literals unless given.""" + fields = { + "unit": "m", + "orthographic_enabled": None, + "cross_section_enabled": False, + "edges_enabled": False, + "bounding_box_enabled": False, + } + fields.update(scene_fields) + return RuntimeAppState( + ui=_ui(), + view=RuntimeViewState(camera=camera, cross_section=None), + scene=RuntimeSceneState(**fields), + ) + + +def _mapper(registry=None) -> VisorStateMapper: + return VisorStateMapper(registry or FakeRegistry(), _ui()) + + # ------------------------------------------------------------------ # Tests: init # ------------------------------------------------------------------ @@ -57,7 +109,7 @@ def get_by_name(self, name): def test_init_requires_registry(): """Constructor should require dataset registry.""" with pytest.raises(ValueError): - VisorStateMapper(None) + VisorStateMapper(None, _ui()) # ------------------------------------------------------------------ @@ -81,28 +133,26 @@ def fake_from_components(**kwargs): fake_from_components, ) - runtime_app_state = type("RuntimeAppState", (), { - "ui": type("UI", (), {})(), - "scene": type("Scene", (), { - "unit": "m", - "camera": "cam", - "variable_states": {"POINT::pressure::1": _literal_record()}, - "dataset_states": {1: {"state": 123}}, - "cross_section": "cs", - "orthographic_enabled": True, - "cross_section_enabled": False, - "edges_enabled": True, - "bounding_box_enabled": False, - })(), - })() - - mapper = VisorStateMapper(registry) - result = mapper.runtime_to_persisted(runtime_app_state) + runtime_app_state = _app_state(camera=_camera(), edges_enabled=True) + runtime_app_state.scene = runtime_app_state.scene.model_copy(update={ + "dataset_states": {1: "state-sentinel"}, + "variable_states": {"POINT::pressure::1": _literal_record()}, + }) + + result = _mapper(registry).runtime_to_persisted(runtime_app_state) assert result == {"result": "ok"} - assert called["datasets"] == {"dsA": {"converted": {"state": 123}}} + assert called["datasets"] == {"dsA": {"converted": "state-sentinel"}} assert called["unit"] == "m" - assert called["camera"] == "cam" + assert called["camera"] == _camera() + assert called["edges_enabled"] is True + assert called["ui_state"] == PersistedUIState( + dark_theme=False, + panel_top_left_panel_collapsed=False, + panel_top_right_panel_collapsed=False, + panel_top_right_legend_collapsed=False, + panel_top_right_tab_index=0, + ) # The record is projected, not passed through. assert called["variable_states"] == { "POINT::pressure::1": VisorVariableState( @@ -118,15 +168,9 @@ def fake_from_components(**kwargs): 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()}, - ) + runtime_app_state = _app_state(variable_states={"POINT::pressure::1": _literal_record()}) - persisted = VisorStateMapper(registry).runtime_to_persisted(runtime_app_state) + persisted = _mapper().runtime_to_persisted(runtime_app_state) entry = persisted.model_dump(by_alias=True)["scene"]["variable_states"]["POINT::pressure::1"] assert entry == { @@ -142,8 +186,6 @@ def test_runtime_to_persisted_projects_records_onto_the_persisted_entry(): def test_runtime_to_persisted_skips_missing_dataset(monkeypatch): """Datasets missing from registry should be skipped.""" - registry = FakeRegistry(datasets={}) - captured = {} def fake_from_components(**kwargs): @@ -155,23 +197,10 @@ def fake_from_components(**kwargs): fake_from_components, ) - runtime_app_state = type("RuntimeAppState", (), { - "ui": object(), - "scene": type("Scene", (), { - "unit": "m", - "camera": "cam", - "variable_states": {}, - "dataset_states": {123: {"data": 1}}, - "cross_section": None, - "orthographic_enabled": False, - "cross_section_enabled": False, - "edges_enabled": False, - "bounding_box_enabled": False, - })(), - })() - - mapper = VisorStateMapper(registry) - mapper.runtime_to_persisted(runtime_app_state) + runtime_app_state = _app_state() + runtime_app_state.scene = runtime_app_state.scene.model_copy(update={"dataset_states": {123: "data"}}) + + _mapper(FakeRegistry(datasets={})).runtime_to_persisted(runtime_app_state) assert captured["datasets"] == {} @@ -180,54 +209,30 @@ def fake_from_components(**kwargs): # persisted_to_runtime # ------------------------------------------------------------------ -def test_persisted_to_runtime_basic(monkeypatch): +def _file(datasets=None) -> PersistedViewerStateV1: + """A file whose scene names only its datasets.""" + return PersistedViewerStateV1.model_validate( + {"scene": {"dataset_states": datasets or {}}} + ) + + +def test_persisted_to_runtime_basic(): """persisted_to_runtime should convert dataset states via registry.""" dataset = FakeDataset(name="dsA", did=10) registry = FakeRegistry(by_name={"dsA": dataset}) + entry = PartProperties(opacity=0.5) - captured = {} - - def fake_from_components(**kwargs): - captured.update(kwargs) - return {"ok": True} - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_state_mapper.RuntimeAppState.from_components", - fake_from_components, + result = _mapper(registry).persisted_to_runtime( + _file({"dsA": PersistedDatasetState(parts={"p": entry})}), _app_state() ) - state = type("Persisted", (), { - "ui": type("UI", (), {"dark_theme": True})(), - "scene": type("Scene", (), { - "unit": "mm", - "camera": "cam", - "variable_states": {"v": 1}, - "dataset_states": { - "dsA": type("DS", (), {"parts": {"p": 9}})() - }, - "cross_section": "cs", - "orthographic_enabled": True, - "cross_section_enabled": False, - "edges_enabled": True, - "bounding_box_enabled": False, - })(), - })() - - mapper = VisorStateMapper(registry) - result = mapper.persisted_to_runtime(state) - - assert result == {"ok": True} - assert captured["dataset_states"] == {10: {"rt": {"p": 9}}} - assert captured["unit"] == "mm" - assert captured["camera"] == "cam" + assert result.scene.dataset_states == {10: {"rt": {"p": entry}}} def test_persisted_to_runtime_missing_dataset_logs_warning(monkeypatch): """Missing dataset in registry should log warning and skip.""" - registry = FakeRegistry(by_name={}) - warnings = [] monkeypatch.setattr( @@ -235,71 +240,40 @@ def test_persisted_to_runtime_missing_dataset_logs_warning(monkeypatch): lambda msg: warnings.append(msg), ) - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_state_mapper.RuntimeAppState.from_components", - lambda **kwargs: kwargs, + result = _mapper(FakeRegistry(by_name={})).persisted_to_runtime( + _file({"missing": PersistedDatasetState()}), _app_state() ) - state = type("Persisted", (), { - "ui": type("UI", (), {"dark_theme": False})(), - "scene": type("Scene", (), { - "unit": None, - "camera": None, - "variable_states": {}, - "dataset_states": { - "missing": type("DS", (), {"parts": {}})() - }, - "cross_section": None, - "orthographic_enabled": False, - "cross_section_enabled": False, - "edges_enabled": False, - "bounding_box_enabled": False, - })(), - })() - - mapper = VisorStateMapper(registry) - result = mapper.persisted_to_runtime(state) - - assert result["dataset_states"] == {} + assert result.scene.dataset_states == {} assert len(warnings) == 1 assert "not found in registry" in warnings[0] -def test_persisted_to_runtime_handles_none_variable_states(monkeypatch): - """None variable_states should default to empty dict.""" +def test_persisted_to_runtime_leaves_variable_states_empty(): + """The file's variable entries and the current records are both left out; the load path fills them.""" - dataset = FakeDataset(name="dsA", did=1) - registry = FakeRegistry(by_name={"dsA": dataset}) - - captured = {} + current = _app_state(variable_states={"POINT::pressure::1": _literal_record()}) + state = PersistedViewerStateV1.model_validate({ + "scene": { + "variable_states": { + "POINT::pressure::1": { + "id": "POINT::pressure::1", + "arrayName": "pressure", + "type": "POINT", + "numComponents": 1, + "magnitudeRange": [1.0, 2.0], + "ranges": [[1.0, 2.0]], + } + } + } + }) - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_state_mapper.RuntimeAppState.from_components", - lambda **kwargs: captured.update(kwargs) or kwargs, - ) + result = _mapper().persisted_to_runtime(state, current) - state = type("Persisted", (), { - "ui": type("UI", (), {"dark_theme": False})(), - "scene": type("Scene", (), { - "unit": "m", - "camera": "cam", - "variable_states": None, - "dataset_states": {}, - "cross_section": None, - "orthographic_enabled": False, - "cross_section_enabled": False, - "edges_enabled": False, - "bounding_box_enabled": False, - })(), - })() + assert result.scene.variable_states == {} - mapper = VisorStateMapper(registry) - mapper.persisted_to_runtime(state) - assert captured["variable_states"] == {} - - -def test_persisted_to_runtime_does_not_write_back_to_dataset_state(monkeypatch): +def test_persisted_to_runtime_does_not_write_back_to_dataset_state(): """The mapper is not, and never has been, the registry-population path. It builds runtime dataset states and returns them; it never assigns them @@ -311,33 +285,12 @@ def test_persisted_to_runtime_does_not_write_back_to_dataset_state(monkeypatch): dataset = FakeDataset(name="dsA", did=10) registry = FakeRegistry(by_name={"dsA": dataset}) - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_state_mapper.RuntimeAppState.from_components", - lambda **kwargs: kwargs, + result = _mapper(registry).persisted_to_runtime( + _file({"dsA": PersistedDatasetState()}), _app_state() ) - state = type("Persisted", (), { - "ui": type("UI", (), {"dark_theme": False})(), - "scene": type("Scene", (), { - "unit": "m", - "camera": None, - "variable_states": {}, - "dataset_states": { - "dsA": type("DS", (), {"parts": {"p": 9}})() - }, - "cross_section": None, - "orthographic_enabled": False, - "cross_section_enabled": False, - "edges_enabled": False, - "bounding_box_enabled": False, - })(), - })() - - mapper = VisorStateMapper(registry) - result = mapper.persisted_to_runtime(state) - # The converted state is returned ... - assert result["dataset_states"] == {10: {"rt": {"p": 9}}} + assert result.scene.dataset_states == {10: {"rt": {}}} # ... and is not written back onto the dataset the registry holds. assert dataset.state == "untouched-sentinel" diff --git a/tests/unit/vtk/test_wire_format_identity.py b/tests/unit/vtk/test_wire_format_identity.py index 6d853d2a..2e5c0499 100644 --- a/tests/unit/vtk/test_wire_format_identity.py +++ b/tests/unit/vtk/test_wire_format_identity.py @@ -307,8 +307,8 @@ def _find_wasm_id_keys(obj, path: str = "") -> list: def test_payload_splits_scene_graph_and_renderer_annotation_with_no_wasm_ids_under_scene_graph(): scene = _build_scene(_sphere_polydata()) # Call the real production payload path -- get_scene_details_json() itself - # does `json.dumps(self.get_scene_details().model_dump(exclude_none=True, - # by_alias=True))` (vtk/scene/base.py) -- so a later change to those dump + # does `json.dumps(self.get_scene_details().model_dump(by_alias=True))` + # (vtk/scene/base.py) -- so a later change to those dump # kwargs is caught here rather than in a re-derived copy. payload = json.loads(scene.get_scene_details_json()) @@ -397,13 +397,89 @@ def _scene_with_two_datasets_sharing_pressure(): return scene +def _null_paths(obj, path: str = "") -> list: + """Every path in a JSON-shaped structure whose value is null.""" + hits = [] + if isinstance(obj, dict): + for key, value in obj.items(): + key_path = f"{path}.{key}" if path else key + if value is None: + hits.append(key_path) + else: + hits.extend(_null_paths(value, key_path)) + elif isinstance(obj, list): + for i, item in enumerate(obj): + hits.extend(_null_paths(item, f"{path}[{i}]")) + return hits + + 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.""" + """Every record key is present at schema version 4, and the only null is a part's colorVariable. + + The scene has a camera record, so ``orthographicEnabled`` resolves; part 11 + is coloured and part 12 is not. + """ + from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState + from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( + RuntimePartProperties, + VariableReference, + ) scene = _scene_with_two_datasets_sharing_pressure() + camera = VisorCameraState( + position=[1.0, 2.0, 3.0], focal_point=[0.0, 0.0, 0.0], view_up=[0.0, 0.0, 1.0], + clipping_range=[0.1, 10.0], parallel_projection=False, view_angle=30.0, parallel_scale=1.0, + ) + scene._renderer.get_camera_state = lambda: camera + scene._dataset_registry.datasets[1].state.part_states = { + 11: RuntimePartProperties( + id=11, opacity=1.0, visible=True, selected=False, diffuse_rgb=[0.8, 0.8, 0.8], + color_variable=VariableReference(variable_id="POINT::pressure::1", variable_component=-1), + ), + 12: RuntimePartProperties( + id=12, opacity=0.5, visible=False, selected=True, diffuse_rgb=[0.1, 0.2, 0.3], color_variable=None, + ), + } payload = json.loads(scene.get_scene_details_json()) - assert payload["appState"]["scene"]["variableStates"] == { + assert set(payload) == {"schemaVersion", "ui", "scene", "vtkInfo"} + assert payload["schemaVersion"] == 4 + assert set(payload["ui"]) == { + "darkTheme", + "panelTopLeftPanelCollapsed", + "panelTopRightPanelCollapsed", + "panelTopRightLegendCollapsed", + "panelTopRightTabIndex", + } + assert set(payload["scene"]) == { + "unit", + "orthographicEnabled", + "crossSectionEnabled", + "edgesEnabled", + "boundingBoxEnabled", + "datasetStates", + "variableStates", + } + assert payload["scene"]["orthographicEnabled"] is False + parts = payload["scene"]["datasetStates"]["1"]["partStates"] + assert parts["11"] == { + "id": 11, + "opacity": 1.0, + "visible": True, + "selected": False, + "diffuseRgb": [0.8, 0.8, 0.8], + "colorVariable": {"variableId": "POINT::pressure::1", "variableComponent": -1}, + } + assert parts["12"] == { + "id": 12, + "opacity": 0.5, + "visible": False, + "selected": True, + "diffuseRgb": [0.1, 0.2, 0.3], + "colorVariable": None, + } + assert _null_paths(payload) == ["scene.datasetStates.1.partStates.12.colorVariable"] + assert payload["scene"]["variableStates"] == { "POINT::pressure::1": { "id": "POINT::pressure::1", "arrayName": "pressure", @@ -434,7 +510,7 @@ def test_mutating_the_handed_out_records_leaves_the_holder_unchanged(): 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 = details.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)