Source code for fieldview._base

"""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))