Skip to content
Draft
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/152.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Remote rendering 3.5a] move variable ownership to server
31 changes: 26 additions & 5 deletions src/ansys/visor/viewer/app/trame/local_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
from ansys.visor.viewer.models.runtime.requests.sync_camera_payload import SyncCameraPayload
from ansys.visor.viewer.models.runtime.requests.variable_range_payload import SetVariableRangePayload
from ansys.visor.viewer.models.runtime.requests.widget_state_payloads import (
SetBoundingBoxVisibilityPayload,
SetCrossSectionVisibilityPayload,
Expand Down Expand Up @@ -60,9 +61,11 @@ def set_part_color_variable(
association: VisorVtkVariableType,
array_name: str,
component: int,
min_val: float,
max_val: float,
) -> None: ...
) -> bool: ...

def set_variable_range(
self, variable_id: str, component: int, min_val: float, max_val: float
) -> bool: ...

def clear_part_color_variable(self, node_id: int) -> None: ...

Expand Down Expand Up @@ -215,6 +218,7 @@ class LocalApp:
set_part_selected: selects or deselects one part
set_part_color_variable: colours one part by a scalar variable
clear_part_color_variable: stops colouring one part by a scalar variable
set_variable_range: stores one variable slot's effective range
sync_camera: records a settled camera reported by the frontend
set_cross_section_visibility: shows or hides the cross-section plane
set_edges_visible: shows or hides edges on every part
Expand Down Expand Up @@ -444,6 +448,10 @@ def set_part_color_variable(self, payload) -> None:
no-op, matching the posture the pipeline takes on an unknown array
name. ``variableId`` is forwarded verbatim and is never parsed by
the server.

``min`` and ``max`` are still required on the wire and appear on the
arrival line, but are not forwarded: the server applies its own
record's effective range for the referenced slot.
"""
api = self._mutation_api("set_part_color_variable", payload)
if api is None:
Expand All @@ -454,8 +462,6 @@ def set_part_color_variable(self, payload) -> None:
payload.association,
payload.array_name,
payload.component,
payload.min_val,
payload.max_val,
)

@trigger("clear_part_color_variable")
Expand All @@ -467,6 +473,21 @@ def clear_part_color_variable(self, payload) -> None:
return
api.clear_part_color_variable(payload.node_id)

@trigger("set_variable_range")
@parse_payload(SetVariableRangePayload)
def set_variable_range(self, payload) -> None:
"""Frontend -> Backend: store one variable slot's effective range.

Scene-wide, not per-part: the coordinator applies the range to every
part whose reference names that slot.
"""
api = self._mutation_api("set_variable_range", payload)
if api is None:
return
api.set_variable_range(
payload.variable_id, payload.component, payload.min_val, payload.max_val
)

# ------------------------------------------------------------------
# Camera trigger
#
Expand Down
257 changes: 257 additions & 0 deletions src/ansys/visor/viewer/models/common/visor_variable_record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
"""Server-owned record for one color variable across every dataset in the scene."""

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Dict, List, Tuple

from pydantic import BaseModel, ConfigDict, Field, field_serializer

from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState
from ansys.visor.viewer.models.persist.scene.persisted_scene_state import _IDENTIFIER_SEPARATOR

if TYPE_CHECKING:
from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry
from ansys.visor.viewer.vtk.variables.visor_variables import VisorVariable

logger = VisorDefaultLogger(__name__)

Range = Tuple[float, float]


def compose_variable_identifier(type: VisorVtkVariableType, name: str, num_components: int) -> str:
"""Compose the stable variable identifier ``<type>::<name>::<num_components>``.

The inverse of :func:`derive_variable_fields_from_identifier`, and the same
composition the client uses, so identifiers in existing files key identically.
"""
return f"{type.value}{_IDENTIFIER_SEPARATOR}{name}{_IDENTIFIER_SEPARATOR}{num_components}"


class VisorVariableRecord(BaseModel):
"""
The server's record of one variable, keyed by its composed identifier.

Every field is required: a record never carries an unset range. ``magnitude_range``
and ``ranges`` are the custom (effective) ranges; ``default_magnitude_range`` and
``default_ranges`` are the ranges widened across every participating part.
``part_ids`` lists the parts that carry the variable.

The identity and range fields share their attribute names and aliases with
:class:`VisorVariableState`, which is the persisted projection (:meth:`to_variable_state`).
"""
model_config = ConfigDict(populate_by_name=True)

id: str = Field(...)
array_name: str = Field(..., alias="arrayName")
type: VisorVtkVariableType = Field(...)
num_components: int = Field(..., alias="numComponents")
part_ids: List[int] = Field(..., alias="partIds")
default_magnitude_range: Tuple[float, float] = Field(..., alias="defaultMagnitudeRange")
default_ranges: List[Tuple[float, float]] = Field(..., alias="defaultRanges")
magnitude_range: Tuple[float, float] = Field(..., alias="magnitudeRange")
ranges: List[Tuple[float, float]] = Field(...)

@field_serializer("type")
def _serialize_type(self, value: VisorVtkVariableType) -> str:
"""Emit the wire value (e.g. "POINT"/"CELL") for both dict-mode and JSON-mode dumps."""
return value.value

def to_variable_state(self) -> VisorVariableState:
"""Project the record onto the persisted entry: identity plus the effective ranges."""
return VisorVariableState(
id=self.id,
array_name=self.array_name,
type=self.type,
num_components=self.num_components,
magnitude_range=self.magnitude_range,
ranges=list(self.ranges),
)

def range_for(self, component: int) -> Range | None:
"""Return the effective range for a slot: ``-1`` is magnitude, ``0..n-1`` a component, else None."""
if component == -1:
return self.magnitude_range
if 0 <= component < self.num_components and component < len(self.ranges):
return self.ranges[component]
return None

def with_range(self, component: int, value_range: Range) -> "VisorVariableRecord":
"""Return a copy with one effective slot replaced: ``-1`` is magnitude, ``0..n-1`` a component.

The caller validates *component*; a slot outside ``[-1, num_components)`` raises ``IndexError``.
This record is never mutated.
"""
new_range = (float(value_range[0]), float(value_range[1]))
if component == -1:
return self.model_copy(update={"magnitude_range": new_range}, deep=True)
if not 0 <= component < len(self.ranges):
raise IndexError(f"component {component} is outside [-1, {len(self.ranges)})")
ranges = [tuple(r) for r in self.ranges]
ranges[component] = new_range
return self.model_copy(update={"ranges": ranges}, deep=True)


@dataclass
class _VariableAccumulator:
"""Participation and widened default ranges for one (association, name, width) key."""

variable_type: VisorVtkVariableType
name: str
num_components: int
part_ids: List[int] = field(default_factory=list, init=False)
magnitude: Range | None = field(default=None, init=False)
components: List[Range | None] = field(default_factory=list, init=False)

def __post_init__(self) -> None:
"""Start with one empty default slot per component."""
self.components = [None] * self.num_components

@staticmethod
def _widen(current: Range | None, other: Range | None) -> Range | None:
"""Return the union of two ranges; ``None`` contributes nothing."""
if other is None:
return current
other = (float(other[0]), float(other[1]))
if current is None:
return other
return (min(current[0], other[0]), max(current[1], other[1]))

@staticmethod
def _follow_or_keep(previous_custom: Range, previous_default: Range, new_default: Range) -> Range:
"""A custom slot equal to its previous default follows the new default; otherwise it is kept."""
if tuple(previous_custom) == tuple(previous_default):
return new_default
return tuple(previous_custom)

@property
def variable_id(self) -> str:
"""The composed identifier for this key: the one compose_variable_identifier call site."""
return compose_variable_identifier(self.variable_type, self.name, self.num_components)

def add_part(self, part_id: int, variable: "VisorVariable") -> None:
"""Record the part's participation and widen the default ranges with its variable's ranges."""
if part_id not in self.part_ids:
self.part_ids.append(part_id)

variable_magnitude = variable.magnitude_range
if variable_magnitude is None and self.num_components == 1 and variable.ranges:
variable_magnitude = variable.ranges[0]
self.magnitude = self._widen(self.magnitude, variable_magnitude)

for k in range(min(self.num_components, len(variable.ranges))):
self.components[k] = self._widen(self.components[k], variable.ranges[k])

def to_record(self, previous_record: VisorVariableRecord | None) -> VisorVariableRecord:
"""Build the record, carrying each custom slot from ``previous_record``."""
n = self.num_components
default_magnitude = self.magnitude or (0.0, 0.0)
default_ranges = [slot if slot is not None else (0.0, 0.0) for slot in self.components]

custom_magnitude = default_magnitude
custom_ranges = list(default_ranges)
old = previous_record
if old is not None:
custom_magnitude = self._follow_or_keep(old.magnitude_range, old.default_magnitude_range, default_magnitude)
custom_ranges = [
self._follow_or_keep(old.ranges[k], old.default_ranges[k], default_ranges[k])
if k < len(old.ranges) and k < len(old.default_ranges) else default_ranges[k]
for k in range(n)
]

return VisorVariableRecord(
id=self.variable_id,
array_name=self.name,
type=self.variable_type,
num_components=n,
part_ids=sorted(self.part_ids),
default_magnitude_range=default_magnitude,
default_ranges=default_ranges,
magnitude_range=custom_magnitude,
ranges=custom_ranges,
)


class VisorVariableRecords(BaseModel):
"""Holder for the scene's variable records, keyed by identifier.

Created once by the scene and never rebound. Writers assign a new dict to
``variables`` (copy-on-write); readers are handed ``model_copy(deep=True)``.
"""
variables: Dict[str, VisorVariableRecord] = Field(default_factory=dict)

@classmethod
def from_registry(
cls,
registry: "VisorDatasetRegistry",
previous: "VisorVariableRecords",
) -> "VisorVariableRecords":
"""Build a new holder from the registry's per-part variables, carrying custom ranges from ``previous``.

1. Union: each part carrying a variable joins that identifier's ``part_ids``.
2. Widen: default ranges are the min of the mins and the max of the maxes.
3. Custom: an existing id keeps an edited custom slot; an unedited slot (equal to the
previous default) follows the new default. A new id starts with custom equal to default.

``previous`` is never mutated.
"""
accumulators: Dict[tuple, _VariableAccumulator] = {}
for dataset in list(registry.datasets.values()):
for part_variables in dataset.list_variables():
for variable in part_variables.variables:
key = (variable.type, variable.name, int(variable.num_components))
if key not in accumulators:
accumulators[key] = _VariableAccumulator(*key)
accumulators[key].add_part(part_variables.part_id, variable)

variables: Dict[str, VisorVariableRecord] = {}
for accumulator in accumulators.values():
variable_id = accumulator.variable_id
variables[variable_id] = accumulator.to_record(previous.variables.get(variable_id))
return cls(variables=variables)

@classmethod
def from_file(
cls,
registry: "VisorDatasetRegistry",
file_states: Dict[str, VisorVariableState],
) -> "VisorVariableRecords":
"""Build a fresh holder from the registry and apply the file's ranges."""
return cls.from_registry(registry, VisorVariableRecords())._overlay(file_states)

def _overlay(self, file_states: Dict[str, VisorVariableState]) -> "VisorVariableRecords":
"""Apply a file's ranges to this fresh build and return this holder; called only by from_file.

- A null or absent ``magnitudeRange`` falls back to the default (DEBUG).
- ``ranges`` whose length differs from ``num_components`` fall back to the default (DEBUG).
- A file id with no record is dropped (WARNING).

Assigns a new dict to ``variables``; the previous dict and its entries are never mutated.
"""
result = dict(self.variables)
for variable_id, entry in (file_states or {}).items():
record = result.get(variable_id)
if record is None:
logger.warning("persisted variable %s has no record in the scene; dropped", variable_id)
continue

magnitude = entry.magnitude_range
if magnitude is None:
logger.debug("persisted variable %s has no magnitudeRange; default used", variable_id)
magnitude = record.default_magnitude_range

ranges = entry.ranges
if ranges is None or len(ranges) != record.num_components:
logger.debug(
"persisted variable %s has %s ranges for %d components; default used",
variable_id, None if ranges is None else len(ranges), record.num_components,
)
ranges = record.default_ranges

result[variable_id] = record.model_copy(
update={"magnitude_range": tuple(magnitude), "ranges": [tuple(r) for r in ranges]},
deep=True,
)
self.variables = result
return self

6 changes: 4 additions & 2 deletions src/ansys/visor/viewer/models/common/visor_variable_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ class VisorVariableState(BaseModel):
explicitly (rather than only opaquely inside ``id``) so a consumer can act on them without parsing the
client-built identifier.

In the future, the backend can own this, but for now we can treat this as passthrough data,
as the id value is stable across sessions.
This is the persisted entry: the identity plus the effective ranges at save time. The backend owns
the variables as :class:`VisorVariableRecord` (``visor_variable_record.py``), and this model is that
record's persisted projection (``VisorVariableRecord.to_variable_state``). The id value is stable
across sessions.
"""
model_config = ConfigDict(populate_by_name=True)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ def from_components(cls,
datasets: Dict[str, PersistedDatasetState],
camera: VisorCameraState | None = None,
cross_section: VisorCrossSectionState | None = None,
variable_states: Dict[str, "VisorVariableState"] | None = None,
*,
variable_states: Dict[str, "VisorVariableState"],
) -> "PersistedViewerStateV1":
"""
Create a PersistedViewerStateV1 instance from UI settings and dataset states.
Expand All @@ -51,7 +52,8 @@ def from_components(cls,
unit (str | None): Scene unit (or None).
datasets (Dict[str, PersistedDatasetState]): Dataset states keyed by dataset name.
camera (VisorCameraState | None): Camera state (or None).
variable_states (Dict[str, VisorVariableState] | None): Variable states keyed by variable identifier (or None).
variable_states (Dict[str, VisorVariableState]): Variable states keyed by variable identifier.
Keyword-only and required; pass ``{}`` for none.
Returns:
PersistedViewerStateV1: The constructed viewer state.
"""
Expand All @@ -64,6 +66,6 @@ def from_components(cls,
edges_enabled=edges_enabled,
bounding_box_enabled=bounding_box_enabled,
dataset_states=datasets,
variable_states=variable_states or {},
variable_states=variable_states,
)
return cls(ui=ui_state, scene=scene_state)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Model for the ``set_variable_range`` trigger payload."""

from pydantic import BaseModel, ConfigDict, Field


class SetVariableRangePayload(BaseModel):
"""
Payload of the ``set_variable_range`` trigger.

Scene-wide: it names a variable and one of its slots, not a part.
``component`` uses the server convention, ``-1`` for magnitude and
``0..n-1`` for a component. Finiteness and ``min <= max`` are checked by
the coordinator, which refuses with a WARNING, not here.
"""

model_config = ConfigDict(populate_by_name=True)

variable_id: str = Field(alias="variableId")
component: int
min_val: float = Field(alias="min")
max_val: float = Field(alias="max")

Loading
Loading