Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changelog.d/166.maintenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Remote rendering 3.6b] Resolve state on server and apply as given on the client
8 changes: 8 additions & 0 deletions src/ansys/visor/viewer/core/visor_colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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,
Expand All @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
182 changes: 115 additions & 67 deletions src/ansys/visor/viewer/models/runtime/dataset/runtime_dataset_state.py
Original file line number Diff line number Diff line change
@@ -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):
"""
Expand Down
63 changes: 9 additions & 54 deletions src/ansys/visor/viewer/models/runtime/scene/runtime_app_state.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading