"""Internal graphic object base helpers."""
from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from typing import (
Protocol,
SupportsFloat,
SupportsIndex,
SupportsInt,
cast,
)
from collections.abc import Callable, Mapping
from . import _core, constant
from ._core_utils import _core_call
from .data import _integrate_surface
from .exceptions import InvalidArgumentError
from .utils import (
Colormap,
Legend,
LegendAnnotation,
LegendAnnotationText,
LegendContour,
LegendLabels,
LegendSpectrum,
Range,
ScalarAnnotation,
ScalarMinMax,
VectorOptions,
)
# ---------------------------------------------------------------------------
# Payload flatten helpers (moved here so base classes can use them without
# circular imports; coord.py re-exports for backward compatibility).
# ---------------------------------------------------------------------------
class _HasValue(Protocol):
value: object
_IntLike = SupportsInt | SupportsIndex | str | bytes
_FloatLike = SupportsFloat | SupportsIndex | str | bytes
_CoreFunc = Callable[..., object]
def _enum_payload_value(value: object) -> object:
if hasattr(value, "value"):
return cast(_HasValue, value).value
return value
def _payload_int(value: object) -> int:
return int(cast(_IntLike, value))
def _payload_float(value: object) -> float:
return float(cast(_FloatLike, value))
def _core_func(name: str) -> _CoreFunc:
return cast(_CoreFunc, getattr(_core, name))
def _surface_coloring_mode(
value: constant.Coloring | str,
*,
material_allowed: bool = False,
surface_name: str = "surface",
) -> str:
mode = (
value.value
if isinstance(value, constant.Coloring)
else str(value).strip().lower()
)
if mode == "vector":
raise InvalidArgumentError(
"vector is a display type; use display_type=DisplayType.VECTORS"
)
if mode == constant.Coloring.MATERIAL.value and not material_allowed:
raise InvalidArgumentError(
f"material coloring is not supported for {surface_name}"
)
valid = {constant.Coloring.GEOMETRIC.value, constant.Coloring.SCALAR.value}
if material_allowed:
valid.add(constant.Coloring.MATERIAL.value)
if mode not in valid:
allowed = (
"geometric, scalar, or material"
if material_allowed
else "geometric or scalar"
)
raise InvalidArgumentError(f"coloring must be {allowed}")
return mode
def _payload_dict(value: object) -> dict[str, object]:
if isinstance(value, dict):
return dict(cast(dict[str, object], value))
if isinstance(value, Mapping):
payload: dict[str, object] = {}
for key, item in cast(Mapping[object, object], value).items():
payload[str(key)] = item
return payload
return {}
def _payload_nested_bool(value: object, *keys: str) -> bool:
if isinstance(value, Mapping):
mapping = cast(Mapping[object, object], value)
for key in keys:
if key in mapping:
return bool(mapping[key])
return False
return bool(value)
def _flatten_colormap(options: dict[str, object]) -> dict[str, object]:
"""Flatten a Colormap-style dict into a core-ready payload."""
payload: dict[str, object] = dict(options)
range_value = payload.pop("range", None)
if range_value is not None:
if not isinstance(range_value, Range):
raise InvalidArgumentError("colormap range must be a Range")
if range_value.min is None or range_value.max is None:
raise InvalidArgumentError("colormap range must define both min and max")
payload["min"] = float(range_value.min)
payload["max"] = float(range_value.max)
name_value = payload.get("name")
if name_value is not None:
payload["name"] = str(_enum_payload_value(name_value))
scale_value = payload.pop("scale", None)
if scale_value is not None:
scale_value = str(_enum_payload_value(scale_value)).lower()
if scale_value == constant.Scale.LOG.value:
payload["log_scale"] = True
elif scale_value == constant.Scale.LINEAR.value:
payload["log_scale"] = False
else:
raise InvalidArgumentError("scale must be Scale.LINEAR or Scale.LOG")
return payload
def _flatten_vector_options(options: dict[str, object]) -> dict[str, object]:
payload: dict[str, object] = {}
for key, value in options.items():
if value is None:
continue
if isinstance(value, Enum):
payload[key] = cast(object, value.value)
else:
payload[key] = value
if "r_samples" in payload:
if "x_samples" in payload:
raise InvalidArgumentError("r_samples conflicts with x_samples")
payload["x_samples"] = payload.pop("r_samples")
if "t_samples" in payload:
if "y_samples" in payload:
raise InvalidArgumentError("t_samples conflicts with y_samples")
payload["y_samples"] = payload.pop("t_samples")
return payload
def _flatten_scalar_minmax(
options: dict[str, object],
) -> tuple[dict[str, object], bool | None]:
payload: dict[str, object] = dict(options)
show = cast(bool | None, payload.pop("show", None))
min_opts = payload.pop("min", None)
max_opts = payload.pop("max", None)
if isinstance(min_opts, ScalarAnnotation):
payload.update(min_opts.to_payload("min"))
elif isinstance(min_opts, dict):
for key, value in cast(dict[str, object], min_opts).items():
payload[f"min_{key}"] = value
if isinstance(max_opts, ScalarAnnotation):
payload.update(max_opts.to_payload("max"))
elif isinstance(max_opts, dict):
for key, value in cast(dict[str, object], max_opts).items():
payload[f"max_{key}"] = value
return payload, show
def _flatten_legend_annotation_text(prefix: str, options: object) -> dict[str, object]:
if isinstance(options, LegendAnnotationText):
return options.to_payload(prefix)
if isinstance(options, dict):
option_map = cast(dict[str, object], options)
payload: dict[str, object] = {}
text = option_map.get("text")
if text is not None:
payload[prefix] = str(text)
font = option_map.get("font")
if font is not None:
payload[f"{prefix}_font"] = str(_enum_payload_value(font))
size = option_map.get("size")
if size is not None:
payload[f"{prefix}_size"] = _payload_int(size)
color = option_map.get("color")
if color is not None:
payload[f"{prefix}_color"] = _payload_int(color)
return payload
return {}
def _flatten_legend_options(options: dict[str, object]) -> dict[str, object]:
"""Flatten a Legend-style dict into a core-ready payload."""
payload: dict[str, object] = {}
for key, value in options.items():
if value is None:
continue
if key == "spectrum":
if isinstance(value, LegendSpectrum):
payload.update(value.to_payload())
elif isinstance(value, dict):
spectrum_map = cast(dict[str, object], value)
if spectrum_map.get("colorbar") is not None:
payload["spectrum_colorbar"] = bool(spectrum_map["colorbar"])
if spectrum_map.get("border") is not None:
payload["spectrum_border"] = bool(spectrum_map["border"])
if spectrum_map.get("horizontal") is not None:
payload["spectrum_horizontal"] = bool(spectrum_map["horizontal"])
if spectrum_map.get("num_labels") is not None:
payload["spectrum_num_labels"] = _payload_int(
spectrum_map["num_labels"]
)
continue
if key == "contour":
if isinstance(value, LegendContour):
payload.update(value.to_payload())
elif isinstance(value, dict):
contour_map = cast(dict[str, object], value)
labels = contour_map.get("labels_per_line")
if labels is not None:
payload["contour_labels_per_line"] = str(
_enum_payload_value(labels)
)
continue
if key == "labels":
if isinstance(value, LegendLabels):
payload.update(value.to_payload())
elif isinstance(value, dict):
labels_map = cast(dict[str, object], value)
if labels_map.get("show") is not None:
payload["labels"] = bool(labels_map["show"])
color_mode = labels_map.get("color_mode")
if color_mode is not None:
payload["labels_color_mode"] = str(_enum_payload_value(color_mode))
geom_color = labels_map.get("geometric_color")
if geom_color is not None:
payload["labels_color"] = _payload_int(geom_color)
num_format = labels_map.get("numerical_format")
if num_format is not None:
payload["labels_numerical_format"] = str(
_enum_payload_value(num_format)
)
dec_places = labels_map.get("decimal_places")
if dec_places is not None:
payload["labels_decimal_places"] = _payload_int(dec_places)
font = labels_map.get("font")
if font is not None:
payload["labels_font"] = str(_enum_payload_value(font))
size = labels_map.get("size")
if size is not None:
payload["labels_size"] = _payload_int(size)
continue
if key == "annotation":
if isinstance(value, LegendAnnotation):
payload.update(value.to_payload())
elif isinstance(value, dict):
annotation_map = cast(dict[str, object], value)
if annotation_map.get("show") is not None:
payload["annotation"] = bool(annotation_map["show"])
position = annotation_map.get("position")
if position is not None:
payload["annotation_position"] = str(_enum_payload_value(position))
title = annotation_map.get("title")
if title is not None:
payload.update(_flatten_legend_annotation_text("title", title))
subtitle = annotation_map.get("subtitle")
if subtitle is not None:
payload.update(
_flatten_legend_annotation_text("subtitle", subtitle)
)
continue
if key == "relative_position":
if not isinstance(value, (list, tuple)):
raise InvalidArgumentError(
"legend relative_position must be a 2-value sequence"
)
relative_position = tuple(cast(tuple[object, ...], value))
if len(relative_position) != 2:
raise InvalidArgumentError(
"legend relative_position must be a 2-value sequence"
)
payload["relative_position"] = [
_payload_float(relative_position[0]),
_payload_float(relative_position[1]),
]
continue
if key in {"scale_width", "scale_height"}:
payload[key] = _payload_float(value)
continue
payload[key] = _enum_payload_value(value)
return payload
def _prefix_payload(prefix: str, payload: dict[str, object]) -> dict[str, object]:
"""Prefix all keys in *payload* with *prefix*."""
return {f"{prefix}{key}": value for key, value in payload.items()}
# ---------------------------------------------------------------------------
# Shared legend sub-object binding
# ---------------------------------------------------------------------------
def _bind_legend_sub_objects(
options: Legend, on_change: Callable[[str, object], None]
) -> Legend:
"""Wire live-update *on_change* callback into all legend sub-objects."""
object.__setattr__(options, "_on_change", on_change)
for attr in ("spectrum", "contour", "labels"):
sub = cast(object, getattr(options, attr, None))
if sub is not None:
object.__setattr__(sub, "_on_change", on_change)
if options.annotation is not None:
object.__setattr__(options.annotation, "_on_change", on_change)
for name in ("title", "subtitle"):
text = cast(object, getattr(options.annotation, name, None))
if text is not None:
object.__setattr__(text, "_on_change", on_change)
object.__setattr__(text, "_prefix", name)
return options
def _bind_vector_options(
options: VectorOptions, on_change: Callable[[str, object], None]
) -> VectorOptions:
object.__setattr__(options, "_on_change", on_change)
return options
def _bind_scalar_annotation(
annotation: ScalarAnnotation | None,
on_change: Callable[[str, object], None],
prefix: str,
) -> ScalarAnnotation:
if annotation is None:
annotation = ScalarAnnotation()
object.__setattr__(annotation, "_on_change", on_change)
object.__setattr__(annotation, "_prefix", prefix)
return annotation
def _bind_scalar_minmax(
options: ScalarMinMax,
on_change: Callable[[str, object], None],
get_data: Callable[[], dict[str, object]],
) -> ScalarMinMax:
object.__setattr__(options, "_on_change", on_change)
object.__setattr__(options, "_get_data", get_data)
min_annotation = _bind_scalar_annotation(options.min, on_change, "min")
max_annotation = _bind_scalar_annotation(options.max, on_change, "max")
object.__setattr__(options, "min", min_annotation)
object.__setattr__(options, "max", max_annotation)
return options
# ---------------------------------------------------------------------------
# Base class hierarchy
# ---------------------------------------------------------------------------
class GraphicObjectBase(ABC):
"""Root for FieldView graphics backed by a PHIGS object."""
__slots__ = ("_phigs_obj", "_is_deleted")
_phigs_obj: int
_is_deleted: bool
def __init__(self, phigs_obj: int) -> None:
self._phigs_obj = int(phigs_obj)
self._is_deleted = False
def __getattribute__(self, name: str) -> object:
if name.startswith("_") or name in {"delete", "__class__", "__repr__"}:
return cast(object, object.__getattribute__(self, name))
if object.__getattribute__(self, "_is_deleted"):
raise InvalidArgumentError(f"{type(self).__name__} surface was deleted")
return cast(object, object.__getattribute__(self, name))
def __setattr__(self, name: str, value: object) -> None:
if name.startswith("_"):
object.__setattr__(self, name, value)
return
if object.__getattribute__(self, "_is_deleted"):
raise InvalidArgumentError(f"{type(self).__name__} surface was deleted")
object.__setattr__(self, name, value)
def _delete_surface(self, delete_core_func: Callable[..., object]) -> None:
if self._is_deleted:
return
_core_call(delete_core_func, self._phigs_obj)
self._is_deleted = True
def __repr__(self) -> str:
return f"{type(self).__name__}(phigs_obj={self._phigs_obj})"
@property
def phigs_obj(self) -> int:
"""`int`: Public PHIGS object identifier for this graphic."""
return self._phigs_obj
class DatasetGraphicObject(GraphicObjectBase, ABC):
"""Base for dataset-backed graphics with common display properties.
Subclasses must implement ``_get_*`` / ``_set_*`` hooks for each
abstract property (or inherit defaults from ``SurfaceBase`` /
``PathBase``).
"""
__slots__ = ("_dataset_id",)
_dataset_id: int
def __init__(self, phigs_obj: int, dataset_id: int) -> None:
super().__init__(phigs_obj)
self._dataset_id = int(dataset_id)
def _ensure_dataset_graphic_valid(self) -> None:
try:
dataset = cast(object, object.__getattribute__(self, "_dataset"))
except AttributeError:
return
if dataset is None:
return
try:
require_dataset = cast(
Callable[[], None], object.__getattribute__(self, "_require_dataset")
)
except AttributeError:
return
require_dataset()
def __getattribute__(self, name: str) -> object:
if not name.startswith("_") and name != "delete":
cast(
Callable[[], None],
object.__getattribute__(self, "_ensure_dataset_graphic_valid"),
)()
return super().__getattribute__(name)
def __setattr__(self, name: str, value: object) -> None:
if not name.startswith("_"):
cast(
Callable[[], None],
object.__getattribute__(self, "_ensure_dataset_graphic_valid"),
)()
super().__setattr__(name, value)
# -- visibility --------------------------------------------------------
@abstractmethod
def _get_visibility(self) -> bool: ...
@abstractmethod
def _set_visibility(self, value: bool) -> None: ...
@property
def visibility(self) -> bool:
"""`bool`: Visibility state."""
return self._get_visibility()
@visibility.setter
def visibility(self, value: bool) -> None:
self._set_visibility(bool(value))
# -- geometric_color ---------------------------------------------------
@abstractmethod
def _get_geometric_color(self) -> constant.GeometricColor: ...
@abstractmethod
def _set_geometric_color(self, value: int) -> None: ...
@property
def geometric_color(self) -> constant.GeometricColor:
"""`GeometricColor`: Geometric color used when not scalar-colored."""
return self._get_geometric_color()
@geometric_color.setter
def geometric_color(self, value: constant.GeometricColor | int) -> None:
self._set_geometric_color(int(value))
# -- coloring ----------------------------------------------------------
@abstractmethod
def _get_coloring(self) -> constant.Coloring: ...
@abstractmethod
def _set_coloring(self, value: constant.Coloring | str) -> None: ...
@property
def coloring(self) -> constant.Coloring:
"""`Coloring`: Coloring mode (geometric, scalar, or material)."""
return self._get_coloring()
@coloring.setter
def coloring(self, value: constant.Coloring | str) -> None:
self._set_coloring(value)
# -- line_type ---------------------------------------------------------
@abstractmethod
def _get_line_type(self) -> constant.LineType: ...
@abstractmethod
def _set_line_type(self, value: constant.LineType | str) -> None: ...
@property
def line_type(self) -> constant.LineType:
"""`LineType`: Line type used to render the graphic."""
return self._get_line_type()
@line_type.setter
def line_type(self, value: constant.LineType | str) -> None:
self._set_line_type(value)
# -- colormap ----------------------------------------------------------
@abstractmethod
def _get_colormap(self) -> Colormap: ...
@abstractmethod
def _set_colormap(self, value: Colormap | dict[str, object]) -> None: ...
@property
def colormap(self) -> Colormap:
"""`Colormap`: Scalar colormap options."""
return self._get_colormap()
@colormap.setter
def colormap(self, value: Colormap | dict[str, object]) -> None:
self._set_colormap(value)
# -- legend ------------------------------------------------------------
@abstractmethod
def _get_legend(self) -> Legend: ...
@abstractmethod
def _set_legend(self, value: Legend | dict[str, object]) -> None: ...
@property
def legend(self) -> Legend:
"""`Legend`: Legend options for scalar coloring."""
return self._get_legend()
@legend.setter
def legend(self, value: Legend | dict[str, object]) -> None:
self._set_legend(value)
def __repr__(self) -> str:
return (
f"{type(self).__name__}("
f"phigs_obj={self._phigs_obj}, dataset_id={self._dataset_id})"
)
# ---------------------------------------------------------------------------
# SurfaceBase — for boundary, comp, coord, iso
# ---------------------------------------------------------------------------
class SurfaceBase(DatasetGraphicObject, ABC):
"""Base for surface-style graphics that use dedicated core getter/setter
functions resolved via ``_core_prefix`` (e.g. ``"boundary_surf"``).
Subclasses only need to set ``_core_prefix`` and optionally
``_material_coloring_allowed = True``.
"""
__slots__ = ()
_core_prefix: str
_material_coloring_allowed: bool = False
def _state(self) -> dict[str, object]:
fn = _core_func(f"{self._core_prefix}_get_state")
return _payload_dict(_core_call(fn, self._phigs_obj))
# -- visibility --------------------------------------------------------
def integrate(self) -> object:
"""Integrate the current scalar function over this surface.
Example usage:
.. code-block:: python
>>> import os
>>> import fieldview as fv
>>> data_dir = os.path.join(fv.home, "examples", "f18")
>>> ds = fv.data.load_plot3d(
... os.path.join(data_dir, "f18i9b_g_bin"),
... os.path.join(data_dir, "f18i9b_q_bin"),
... )
>>> surf = fv.vis.create_boundary(ds)
>>> surf.scalar_func = ds.scalar_functions[0]
>>> result = surf.integrate()
>>> result.surface
'boundary_surf'
"""
return _integrate_surface(self)
def _get_visibility(self) -> bool:
return bool(self._state().get("visibility", True))
def _set_visibility(self, value: bool) -> None:
fn = _core_func(f"{self._core_prefix}_set_visibility")
_core_call(fn, self._phigs_obj, value)
# -- geometric_color ---------------------------------------------------
def _get_geometric_color(self) -> constant.GeometricColor:
return constant.GeometricColor(
_payload_int(self._state().get("geometric_color", 0))
)
def _set_geometric_color(self, value: int) -> None:
fn = _core_func(f"{self._core_prefix}_set_geometric_color")
_core_call(fn, self._phigs_obj, value)
# -- coloring ----------------------------------------------------------
def _get_coloring(self) -> constant.Coloring:
mode = str(
self._state().get("coloring", constant.Coloring.GEOMETRIC.value)
).lower()
if mode == "vector":
mode = constant.Coloring.GEOMETRIC.value
return constant.Coloring(mode)
def _set_coloring(self, value: constant.Coloring | str) -> None:
mode = _surface_coloring_mode(
value,
material_allowed=self._material_coloring_allowed,
surface_name=f"{type(self).__name__.lower()} surfaces",
)
fn = _core_func(f"{self._core_prefix}_set_coloring")
_core_call(fn, self._phigs_obj, mode)
# -- line_type ---------------------------------------------------------
def _get_line_type(self) -> constant.LineType:
return constant.LineType(
str(self._state().get("line_type", constant.LineType.THIN.value)).lower()
)
def _set_line_type(self, value: constant.LineType | str) -> None:
lt = (
value.value
if isinstance(value, constant.LineType)
else str(value).strip().lower()
)
fn = _core_func(f"{self._core_prefix}_set_line_type")
_core_call(fn, self._phigs_obj, lt)
# -- display_type ------------------------------------------------------
@property
def display_type(self) -> constant.DisplayType:
"""`DisplayType`: Surface display type."""
return constant.DisplayType(str(self._state().get("display_type", "")).lower())
@display_type.setter
def display_type(self, value: constant.DisplayType | str) -> None:
dtype = (
value.value
if isinstance(value, constant.DisplayType)
else str(value).strip().lower()
)
fn = _core_func(f"{self._core_prefix}_set_display_type")
_core_call(fn, self._phigs_obj, dtype)
# -- colormap ----------------------------------------------------------
def _get_colormap(self) -> Colormap:
payload = _payload_dict(self._state().get("scalar_colormap", {}))
options = Colormap.from_payload(payload)
# Bind live-update callbacks.
set_fn = _core_func(f"{self._core_prefix}_set_scalar_colormap")
unify_fn = _core_func(f"{self._core_prefix}_scalar_colormap_unify")
phigs = self._phigs_obj
def on_change(key: str, value: object) -> None:
if value is None:
return
_core_call(set_fn, phigs, {key: value})
def on_unify() -> None:
_core_call(unify_fn, phigs)
object.__setattr__(options, "_on_change", on_change)
object.__setattr__(options, "_on_unify", on_unify)
return options
def _set_colormap(self, value: Colormap | dict[str, object]) -> None:
if isinstance(value, Colormap):
payload = _flatten_colormap(value.to_payload())
elif isinstance(value, dict):
payload = _flatten_colormap(value)
else:
raise InvalidArgumentError("colormap must be a Colormap or dict")
fn = _core_func(f"{self._core_prefix}_set_scalar_colormap")
_core_call(fn, self._phigs_obj, payload)
# -- vector_options ----------------------------------------------------
def _get_vector_options(self) -> VectorOptions:
payload = _payload_dict(self._state().get("vector_options", {}))
options = VectorOptions.from_payload(payload)
set_fn = _core_func(f"{self._core_prefix}_set_vector_options")
phigs = self._phigs_obj
def on_change(key: str, value: object) -> None:
if value is None:
return
_core_call(set_fn, phigs, {key: value})
return _bind_vector_options(options, on_change)
def _set_vector_options(self, value: VectorOptions | dict[str, object]) -> None:
if isinstance(value, VectorOptions):
payload = _flatten_vector_options(value.to_payload())
elif isinstance(value, dict):
payload = _flatten_vector_options(value)
else:
raise InvalidArgumentError("vector_options must be a VectorOptions or dict")
fn = _core_func(f"{self._core_prefix}_set_vector_options")
_core_call(fn, self._phigs_obj, payload)
# -- scalar_minmax -----------------------------------------------------
def _get_scalar_minmax(self) -> ScalarMinMax:
state = self._state()
payload = _payload_dict(state.get("scalar_minmax_options", {}))
show = _payload_nested_bool(
state.get("scalar_minmax", False), "enabled", "show"
)
options = ScalarMinMax.from_payload(payload, show=show)
set_show_fn = _core_func(f"{self._core_prefix}_set_scalar_minmax")
set_options_fn = _core_func(f"{self._core_prefix}_set_scalar_minmax_options")
phigs = self._phigs_obj
def on_change(key: str, value: object) -> None:
if value is None:
return
if key == "show":
_core_call(set_show_fn, phigs, bool(value))
else:
_core_call(set_options_fn, phigs, {key: value})
def get_data() -> dict[str, object]:
state_data = self._state().get("scalar_minmax_data")
if isinstance(state_data, dict):
return _payload_dict(cast(dict[str, object], state_data))
raise InvalidArgumentError(
"ScalarMinMax data is not available for this surface"
)
return _bind_scalar_minmax(options, on_change, get_data)
def _set_scalar_minmax(self, value: ScalarMinMax | dict[str, object]) -> None:
payload: dict[str, object]
show: bool | None
if isinstance(value, ScalarMinMax):
payload = value.to_payload()
show = value.show
elif isinstance(value, dict):
payload, show = _flatten_scalar_minmax(value)
else:
raise InvalidArgumentError("scalar_minmax must be a ScalarMinMax or dict")
if payload:
set_options_fn = _core_func(
f"{self._core_prefix}_set_scalar_minmax_options"
)
_core_call(set_options_fn, self._phigs_obj, payload)
if show is not None:
set_show_fn = _core_func(f"{self._core_prefix}_set_scalar_minmax")
_core_call(set_show_fn, self._phigs_obj, bool(show))
# -- legend ------------------------------------------------------------
def _get_legend(self) -> Legend:
payload = _payload_dict(self._state().get("legend", {}))
options = Legend.from_payload(payload)
set_fn = _core_func(f"{self._core_prefix}_set_legend")
phigs = self._phigs_obj
def on_change(key: str, value: object) -> None:
if value is None:
return
_core_call(set_fn, phigs, {key: value})
return _bind_legend_sub_objects(options, on_change)
def _set_legend(self, value: Legend | dict[str, object]) -> None:
if isinstance(value, Legend):
payload = value.to_payload()
elif isinstance(value, dict):
payload = _flatten_legend_options(value)
else:
raise InvalidArgumentError("legend must be a Legend or dict")
fn = _core_func(f"{self._core_prefix}_set_legend")
_core_call(fn, self._phigs_obj, payload)
# ---------------------------------------------------------------------------
# PathBase — for particle_paths, streamlines, vortex_cores
# ---------------------------------------------------------------------------
class PathBase(DatasetGraphicObject, ABC):
"""Base for path-style graphics that use a ``_state()`` dict for reads
and a single ``_core.<prefix>_modify`` function for writes.
Subclasses set ``_core_prefix`` (e.g. ``"particle_paths_surf"``) and
implement ``_state()``. ``display_type`` must be provided by the
subclass since normalization differs per type.
"""
__slots__ = ()
_core_prefix: str
_default_geometric_color: int = int(constant.GeometricColor.RED)
@abstractmethod
def _state(self) -> dict[str, object]:
"""Return current state dict from the core."""
def _modify(self, payload: dict[str, object]) -> None:
fn = _core_func(f"{self._core_prefix}_modify")
_core_call(fn, self._phigs_obj, payload)
# -- visibility --------------------------------------------------------
def _get_visibility(self) -> bool:
return bool(self._state().get("visibility", True))
def _set_visibility(self, value: bool) -> None:
self._modify({"visibility": value})
# -- geometric_color ---------------------------------------------------
def _get_geometric_color(self) -> constant.GeometricColor:
return constant.GeometricColor(
_payload_int(
self._state().get("geometric_color", self._default_geometric_color)
)
)
def _set_geometric_color(self, value: int) -> None:
self._modify({"geometric_color": value})
# -- coloring ----------------------------------------------------------
def _get_coloring(self) -> constant.Coloring:
return constant.Coloring(
str(
self._state().get("coloring", constant.Coloring.GEOMETRIC.value)
).lower()
)
def _set_coloring(self, value: constant.Coloring | str) -> None:
mode = (
value.value
if isinstance(value, constant.Coloring)
else str(value).strip().lower()
)
if mode == constant.Coloring.MATERIAL.value:
raise InvalidArgumentError(
f"material coloring is not supported for {type(self).__name__.lower()}"
)
if mode not in {
constant.Coloring.GEOMETRIC.value,
constant.Coloring.SCALAR.value,
}:
raise InvalidArgumentError("coloring must be geometric or scalar")
self._modify({"coloring": mode})
# -- line_type ---------------------------------------------------------
def _get_line_type(self) -> constant.LineType:
return constant.LineType(
str(self._state().get("line_type", constant.LineType.THIN.value)).lower()
)
def _set_line_type(self, value: constant.LineType | str) -> None:
lt = (
value.value
if isinstance(value, constant.LineType)
else str(value).strip().lower()
)
if lt not in {
constant.LineType.THIN.value,
constant.LineType.MEDIUM.value,
constant.LineType.THICK.value,
}:
raise InvalidArgumentError("line_type must be thin, medium, or thick")
self._modify({"line_type": lt})
# -- display_type (abstract — subclasses provide) ----------------------
@property
def display_type(self) -> constant.PathsDisplayType:
raise NotImplementedError
@display_type.setter
def display_type(self, _value: constant.PathsDisplayType | str) -> None:
raise NotImplementedError
# -- colormap ----------------------------------------------------------
def _get_colormap(self) -> Colormap:
raw_payload = self._state().get("scalar_colormap", {})
payload = _payload_dict(raw_payload)
options = Colormap.from_payload(payload)
phigs = self._phigs_obj
prefix = self._core_prefix
modify_fn = _core_func(f"{prefix}_modify")
def on_change(key: str, value: object) -> None:
if value is None:
return
_core_call(modify_fn, phigs, {f"scalar_colormap_{key}": value})
def on_unify() -> None:
_core_call(modify_fn, phigs, {"scalar_colormap_unify": True})
object.__setattr__(options, "_on_change", on_change)
object.__setattr__(options, "_on_unify", on_unify)
return options
def _set_colormap(self, value: Colormap | dict[str, object]) -> None:
if isinstance(value, Colormap):
payload = _flatten_colormap(value.to_payload())
elif isinstance(value, dict):
payload = _flatten_colormap(value)
else:
raise InvalidArgumentError("colormap must be a Colormap or dict")
self._modify(_prefix_payload("scalar_colormap_", payload))
# -- legend ------------------------------------------------------------
def _get_legend(self) -> Legend:
raw_payload = self._state().get("legend", {})
payload = _payload_dict(raw_payload)
options = Legend.from_payload(payload)
phigs = self._phigs_obj
prefix = self._core_prefix
modify_fn = _core_func(f"{prefix}_modify")
def on_change(key: str, value: object) -> None:
if value is None:
return
_core_call(modify_fn, phigs, {f"legend_{key}": value})
return _bind_legend_sub_objects(options, on_change)
def _set_legend(self, value: Legend | dict[str, object]) -> None:
if isinstance(value, Legend):
payload = value.to_payload()
elif isinstance(value, dict):
payload = _flatten_legend_options(value)
else:
raise InvalidArgumentError("legend must be a Legend or dict")
self._modify(_prefix_payload("legend_", payload))