from typing import Optional

import numpy as np

from qtpy import QtCore, QtWidgets, QtGui

from .config import _DISPLAY_CONFIG
from .geometry import (
    _compute_center_radius,
    _estimate_ambient_occlusion,
    _build_sphere_mesh,
    _build_trace_ups,
    _extract_ca_trace,
    _build_bond_pairs,
    _generate_cartoon_tube_arrays,
    _generate_trace_arrays,
)
from .renderer import Renderer
from .renderer_qtgl import QtGLRenderer
from .scene import Scene, SceneObject, Geometry
from .colors import (
    _three_to_one_array,
    _build_residue_color_array,
    _build_ss_color_array,
)
from .picking import pick_residue_from_click, pick_residues_in_rect
class ProtView(QtWidgets.QWidget):

    # Emitted when residues are selected via picking in the 3D view. The
    # payload is a list of integer residue indices along the CA trace.
    residueSelectionChanged = QtCore.Signal(object)
    """Minimal 3D protein viewer widget (protview).

    This widget embeds a :class:`QtWidgets.QOpenGLWidget`-based renderer and
    draws a simple backbone trace (through CA atoms where possible). It is
    designed to be embedded in existing Qt layouts and does not manage its own
    QApplication.

    Public methods
    --------------
    - :meth:`set_structure(structure)`: accept a ChiSurf ``Structure``-like
      object with ``atoms``/``xyz`` attributes.
    - :meth:`set_coordinates(xyz)`: accept an ``(N, 3)`` coordinate array.
    """

    def __init__(
        self,
        parent: Optional[QtWidgets.QWidget] = None,
        background: Optional[str] = None,
        *,
        representation_mode: Optional[str] = None,
        show_cartoon: Optional[bool] = None,
        show_trace: Optional[bool] = None,
        show_atoms: Optional[bool] = None,
        show_sticks: Optional[bool] = None,
        sidechains_visible: Optional[bool] = None,
        grid_visible: Optional[bool] = None,
        surface_visible: Optional[bool] = None,
        scale_factor: Optional[float] = None,
    ) -> None:
        super().__init__(parent)

        layout = QtWidgets.QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        self.view: Optional[QtWidgets.QWidget] = None
        self._disabled_label: Optional[QtWidgets.QLabel] = None

        # Stored geometry
        self._coords: Optional[np.ndarray] = None
        self._center: Optional[np.ndarray] = None
        self._radius: float = 1.0

        self._atoms: Optional[np.ndarray] = None
        self._all_atom_coords: Optional[np.ndarray] = None
        self._all_atom_res_ids: Optional[np.ndarray] = None
        self._all_atom_radii: Optional[np.ndarray] = None

        # Residue / sequence metadata (valid when a Structure is set)
        self._residue_ids: Optional[np.ndarray] = None
        self._residue_names: Optional[np.ndarray] = None
        self._residue_oneletter: Optional[np.ndarray] = None
        self._selected_residues: list[int] = []

        display_defaults = _DISPLAY_CONFIG.get("defaults", {})
        scaling_cfg = _DISPLAY_CONFIG.get("scaling", {})
        camera_cfg = _DISPLAY_CONFIG.get("camera", {})

        # Coloring state
        # "single", "by_residue" (AA type) or "by_secondary_structure" (H/E/C)
        self._color_mode: str = str(
            display_defaults.get("color_mode", "single")
        )
        colors_cfg = _DISPLAY_CONFIG.get("colors", {})
        base_col = np.asarray(colors_cfg.get("base", [0.8, 0.8, 1.0, 1.0]), dtype=float)
        if base_col.shape[0] != 4:
            base_col = np.array([0.8, 0.8, 1.0, 1.0], dtype=float)
        self._base_color_single = tuple(float(x) for x in base_col)
        self._colors_per_ca: Optional[np.ndarray] = None
        self._secondary_structure: Optional[np.ndarray] = None

        rep_mode_default = str(display_defaults.get("representation_mode", "cartoon")).lower()
        self._representation_mode: str = str(
            (representation_mode or rep_mode_default)
        ).lower()
        self._trace_ups: Optional[np.ndarray] = None

        # Global representation flags to allow independent toggling from the
        # plugin UI (cartoon / trace / atoms / sticks).
        self._show_cartoon: bool = bool(
            show_cartoon if show_cartoon is not None else display_defaults.get("show_cartoon", True)
        )
        self._show_trace: bool = bool(
            show_trace if show_trace is not None else display_defaults.get("show_trace", False)
        )
        self._show_atoms: bool = bool(
            show_atoms if show_atoms is not None else display_defaults.get("show_atoms", False)
        )
        self._show_sticks: bool = bool(
            show_sticks if show_sticks is not None else display_defaults.get("show_sticks", False)
        )

        # Side-chain visibility flag for atom / ball view.
        self._sidechains_visible: bool = bool(
            sidechains_visible if sidechains_visible is not None else display_defaults.get("sidechains_visible", True)
        )

        # Per-residue representation masks (per CA index)
        self._cartoon_mask: Optional[np.ndarray] = None
        self._ball_mask: Optional[np.ndarray] = None

        # Cached bond list for sticks representation: array of shape (M, 2)
        # with integer indices into the all-atom coordinate array.
        self._bond_pairs: Optional[np.ndarray] = None

        self._info_text: str = ""
        self._info_visible: bool = False
        self._info_overlay: Optional[QtWidgets.QPlainTextEdit] = None

        # Render backend
        self._renderer: Optional[Renderer] = None
        self._point_overlays: dict[str, dict] = {}

        # Reference plane (grid) is hidden by default; the toolbar button
        # can toggle it on when needed.
        self._grid_visible: bool = bool(
            grid_visible if grid_visible is not None else display_defaults.get("grid_visible", False)
        )

        self._surface_visible: bool = bool(
            surface_visible if surface_visible is not None else display_defaults.get("surface_visible", False)
        )

        self._scale_factor = float(
            scale_factor if scale_factor is not None else scaling_cfg.get("structure", 10.0)
        )

        def _camera_val(key: str, default: float) -> float:
            try:
                return float(camera_cfg.get(key, default))
            except Exception:
                return float(default)

        self._camera_min_near_clip = max(_camera_val("min_near_clip", 0.005), 1e-4)
        self._camera_max_near_clip = max(
            _camera_val("max_near_clip", 5.0), self._camera_min_near_clip * 1.01
        )
        self._camera_near_clip = min(
            max(_camera_val("near_clip", 0.1), self._camera_min_near_clip), self._camera_max_near_clip
        )
        far_default = _camera_val("far_clip", 1000.0)
        self._camera_far_clip = far_default if far_default > self._camera_near_clip else self._camera_near_clip * 200.0
        clip_wheel_scale = _camera_val("clip_wheel_scale", 0.85)
        if not (0.0 < clip_wheel_scale < 1.0):
            clip_wheel_scale = 0.85
        self._camera_clip_wheel_scale = clip_wheel_scale

        # Camera defaults
        self._default_elevation = 20
        self._default_azimuth = 45
        self._scene: Optional[Scene] = None

        info_cfg = _DISPLAY_CONFIG.get("info_overlay", {})

        try:
            renderer = QtGLRenderer(controller=self, parent=self)
        except Exception:
            renderer = None

        if renderer is not None:
            container = QtWidgets.QWidget(self)
            container_layout = QtWidgets.QHBoxLayout(container)
            container_layout.setContentsMargins(0, 0, 0, 0)
            container_layout.setSpacing(0)

            self._info_overlay = QtWidgets.QPlainTextEdit(container)
            self._info_overlay.setReadOnly(True)
            max_width = int(info_cfg.get("max_width", 260))
            min_width = int(info_cfg.get("min_width", 180))
            self._info_overlay.setMaximumWidth(max_width)
            self._info_overlay.setMinimumWidth(min_width)
            self._info_overlay.setPlainText(self._info_text or "(no system loaded)")
            style = info_cfg.get(
                "stylesheet",
                "background-color: rgba(0, 0, 0, 180);"
                "color: white;"
                "border: 1px solid rgba(255, 255, 255, 80);",
            )
            self._info_overlay.setStyleSheet(style)
            self._info_overlay.setVisible(self._info_visible)
            container_layout.addWidget(self._info_overlay)

            self._renderer = renderer
            self.view = self._renderer.widget()
            bg = background if background is not None else _DISPLAY_CONFIG.get(
                "background", "k"
            )
            self._renderer.set_background_color(bg)
            grid_cfg = _DISPLAY_CONFIG.get("grid", {})
            g_size = float(grid_cfg.get("size", 20.0))
            g_spacing = float(grid_cfg.get("spacing", 1.0))
            self._renderer.configure_grid(g_size, g_spacing)
            self._renderer.set_grid_visible(self._grid_visible)
            configure_camera = getattr(self._renderer, "configure_camera", None)
            if callable(configure_camera):
                configure_camera(
                    near_clip=self._camera_near_clip,
                    far_clip=self._camera_far_clip,
                    min_near_clip=self._camera_min_near_clip,
                    max_near_clip=self._camera_max_near_clip,
                    clip_wheel_scale=self._camera_clip_wheel_scale,
                )
            container_layout.addWidget(self._renderer.widget(), 1)
            layout.addWidget(container)
        else:
            self._renderer = None

        if self._renderer is None:
            self._show_disabled_label()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------
    def set_structure(self, structure: object) -> None:
        """Set structure from a ChiSurf ``Structure``-like object.

        The object is expected to provide either:
        - ``atoms``: NumPy structured array with fields ``'xyz'`` and
          ``'atom_name'`` (as in :mod:`chisurf.structure`), or
        - ``xyz``: array-like of shape ``(N, 3)``.
        """

        self._atoms = None
        self._all_atom_coords = None
        self._all_atom_res_ids = None
        self._all_atom_radii = None
        self._bond_pairs = None
        self._bond_pairs = None
        self._secondary_structure = None

        # Case 1: explicit atoms array with xyz/atom_name fields (preferred)
        atoms = getattr(structure, "atoms", None)
        if isinstance(atoms, np.ndarray) and {"xyz", "atom_name"}.issubset(
            set(atoms.dtype.fields or {})
        ):
            self._atoms = atoms
            fields_atoms = set(atoms.dtype.fields or {})

            coords_all_raw: Optional[np.ndarray]
            try:
                coords_all_raw = np.asarray(atoms["xyz"], dtype=float)
            except Exception:
                coords_all_raw = None
            self._all_atom_coords = None if coords_all_raw is None else coords_all_raw.copy()
            if "res_id" in fields_atoms:
                try:
                    self._all_atom_res_ids = np.asarray(atoms["res_id"])
                except Exception:
                    self._all_atom_res_ids = None
            else:
                self._all_atom_res_ids = None
            if "radius" in fields_atoms:
                try:
                    self._all_atom_radii = np.asarray(atoms["radius"], dtype=float)
                except Exception:
                    self._all_atom_radii = None
            else:
                self._all_atom_radii = None

            # Pre-compute simple covalent bonds for sticks representation
            # using a distance cutoff in *raw* (unscaled) coordinates so the
            # list is independent of the global scaling we apply for viewing.
            sticks_cfg = _DISPLAY_CONFIG.get("sticks", {})
            max_bond_len = float(sticks_cfg.get("bond_max_length", 1.9))
            if coords_all_raw is not None and np.isfinite(max_bond_len) and max_bond_len > 0.0:
                self._bond_pairs = _build_bond_pairs(coords_all_raw, max_bond_len)
            else:
                self._bond_pairs = None

            coords, res_ids, res_names = _extract_ca_trace(atoms)
            if coords is None:
                coords = np.asarray(atoms["xyz"], dtype=float)
                res_ids = None
                res_names = None

            self._residue_ids = res_ids
            self._residue_names = res_names
            self._residue_oneletter = _three_to_one_array(res_names)

            coords_arr = np.asarray(coords, dtype=float)

            # Determine a common center/radius from all atoms if available,
            # otherwise from the CA trace, then center and scale geometry.
            if self._all_atom_coords is not None:
                center, radius = _compute_center_radius(self._all_atom_coords)
            else:
                center, radius = _compute_center_radius(coords_arr)

            scale = float(self._scale_factor)
            coords_arr = (coords_arr - center) * scale
            self._coords = coords_arr

            if self._all_atom_coords is not None:
                self._all_atom_coords = (self._all_atom_coords - center) * scale
            if self._all_atom_radii is not None:
                try:
                    self._all_atom_radii = (
                        np.asarray(self._all_atom_radii, dtype=float) * scale
                    )
                except Exception:
                    self._all_atom_radii = None

            self._center = np.zeros(3, dtype=float)
            self._radius = float(radius * scale)
            self._trace_ups = _build_trace_ups(atoms, self._residue_ids, self._coords)
            n = self._coords.shape[0]
            self._cartoon_mask = np.ones(n, dtype=bool)
            self._ball_mask = np.zeros(n, dtype=bool)
            self._update_view()
            return

        # Case 2: fallback to ``structure.xyz`` attribute
        xyz_attr = getattr(structure, "xyz", None)
        if xyz_attr is not None:
            self.set_coordinates(np.asarray(xyz_attr, dtype=float))
            return

        raise TypeError(
            "Unsupported structure type for ProtView: " f"{type(structure)!r}"
        )

    def set_coordinates(self, xyz: np.ndarray) -> None:
        """Set raw coordinates for visualization.

        Parameters
        ----------
        xyz:
            Array of shape ``(N, 3)`` with Cartesian coordinates.
        """

        arr = np.asarray(xyz, dtype=float)
        if arr.ndim != 2 or arr.shape[1] != 3:
            raise ValueError("xyz must have shape (N, 3)")

        self._atoms = None
        self._all_atom_coords = None
        self._all_atom_res_ids = None
        self._all_atom_radii = None

        center, radius = _compute_center_radius(arr)
        scale = float(self._scale_factor)
        arr = (arr - center) * scale

        self._coords = arr
        self._center = np.zeros(3, dtype=float)
        self._radius = float(radius * scale)
        self._update_view()

        # No sequence information when only raw coordinates are provided
        if self._residue_ids is None and self._residue_names is None:
            self._colors_per_ca = None
        self._cartoon_mask = None
        self._ball_mask = None

    # ------------------------------------------------------------------
    # Representation / interaction helpers
    # ------------------------------------------------------------------

    def set_residue_representation(
        self,
        indices,
        cartoon: Optional[bool] = None,
        ball: Optional[bool] = None,
    ) -> None:
        """Enable/disable cartoon and ball view for selected residues.

        Parameters
        ----------
        indices:
            Iterable of integer residue indices (0-based along the CA trace).
        cartoon:
            If True, enable cartoon for these residues; if False, disable;
            if None, leave unchanged.
        ball:
            If True, enable ball view for these residues; if False, disable;
            if None, leave unchanged.
        """

        if self._coords is None:
            return
        n = self._coords.shape[0]
        if n == 0:
            return

        # Ensure masks exist
        if self._cartoon_mask is None or len(self._cartoon_mask) != n:
            self._cartoon_mask = np.ones(n, dtype=bool)
        if self._ball_mask is None or len(self._ball_mask) != n:
            self._ball_mask = np.zeros(n, dtype=bool)

        if indices is None:
            idx_list = np.arange(n, dtype=int)
        else:
            try:
                idx_arr = np.asarray(list(indices), dtype=int)
            except Exception:
                return
            if idx_arr.size == 0:
                idx_list = np.arange(n, dtype=int)
            else:
                idx_arr = idx_arr[(idx_arr >= 0) & (idx_arr < n)]
                if idx_arr.size == 0:
                    idx_list = np.arange(n, dtype=int)
                else:
                    idx_list = idx_arr

        if cartoon is not None:
            self._cartoon_mask[idx_list] = bool(cartoon)
            # Keep the global cartoon flag in sync with the mask so that
            # turning cartoon off for all residues hides the tube entirely.
            try:
                self._show_cartoon = bool(self._cartoon_mask.any())
            except Exception:
                self._show_cartoon = True
        if ball is not None:
            self._ball_mask[idx_list] = bool(ball)

            # Ensure the global atoms toggle follows the per-residue mask so
            # that enabling balls for any residue automatically turns atoms
            # rendering on, and disabling all balls turns it off.
            try:
                self._show_atoms = bool(self._ball_mask.any())
            except Exception:
                self._show_atoms = True

        self._update_view()

    def set_secondary_structure_codes(self, codes) -> None:
        """Set per-residue secondary-structure codes for coloring.

        Parameters
        ----------
        codes:
            Iterable of single-character secondary-structure labels (e.g.
            'H', 'E', 'C') aligned to the CA trace.
        """

        try:
            arr = np.asarray(list(codes), dtype="U1")
        except Exception:
            return
        if arr.size == 0:
            self._secondary_structure = None
            return
        self._secondary_structure = arr
        if self._coords is not None and self._coords.shape[0] > 0:
            self._update_view()

    def set_plane_visible(self, visible: bool) -> None:
        """Show or hide the reference plane (grid)."""

        self._grid_visible = bool(visible)
        if self._renderer is not None:
            try:
                self._renderer.set_grid_visible(self._grid_visible)
            except Exception:
                pass

    def toggle_plane(self) -> None:
        self.set_plane_visible(not self._grid_visible)

    def set_surface_visible(self, visible: bool) -> None:
        self._surface_visible = bool(visible)
        if self._renderer is not None and self._coords is not None:
            self._update_view()

    def set_color_mode(self, mode: str) -> None:
        """Set coloring mode.

        Parameters
        ----------
        mode:
            "single" for uniform coloring, "by_residue" to color amino
            acids differently, or "by_secondary_structure" to color by
            secondary-structure state.
        """

        if mode not in ("single", "by_residue", "by_secondary_structure"):
            return
        self._color_mode = mode
        if self._coords is not None:
            self._update_view()

    def set_representation(self, mode: str) -> None:
        """Legacy mode-style API (cartoon / ca_trace / atoms).

        This is primarily used by keyboard shortcuts and :class:`ProtViewPlot`.
        Internally it configures the independent representation toggles
        (``_show_cartoon``, ``_show_trace``, ``_show_atoms``) and the
        per-residue ball mask, then refreshes the view.
        """

        mode_l = str(mode).lower()
        if mode_l not in ("cartoon", "ca_trace", "atoms"):
            return
        self._representation_mode = mode_l

        if self._coords is None:
            return
        n = self._coords.shape[0]
        if n <= 0:
            return

        if mode_l == "cartoon":
            self._show_cartoon = True
            self._show_trace = False
            self._show_atoms = False
        elif mode_l == "ca_trace":
            self._show_cartoon = False
            self._show_trace = True
            self._show_atoms = False
        else:  # "atoms"
            self._show_cartoon = False
            self._show_trace = False
            self._show_atoms = True

        # For atoms mode, default to showing balls on all residues.
        if mode_l == "atoms":
            self._cartoon_mask = np.ones(n, dtype=bool)
            self._ball_mask = np.ones(n, dtype=bool)
        else:
            if self._cartoon_mask is None or len(self._cartoon_mask) != n:
                self._cartoon_mask = np.ones(n, dtype=bool)
            else:
                self._cartoon_mask[:] = True
            if self._ball_mask is None or len(self._ball_mask) != n:
                self._ball_mask = np.zeros(n, dtype=bool)
            else:
                self._ball_mask[:] = False

        self._update_view()

    def set_cartoon_visible(self, visible: bool) -> None:
        """Enable or disable the cartoon tube globally."""

        self._show_cartoon = bool(visible)
        if self._coords is not None:
            self._update_view()

    def set_trace_visible(self, visible: bool) -> None:
        """Enable or disable the CA trace line globally."""

        self._show_trace = bool(visible)
        if self._coords is not None:
            self._update_view()

    def set_atoms_visible(self, visible: bool) -> None:
        """Enable or disable the atom/ball representation globally."""

        self._show_atoms = bool(visible)
        if self._coords is not None:
            self._update_view()

    def set_sticks_visible(self, visible: bool) -> None:
        """Enable or disable the sticks (bond) representation globally."""

        self._show_sticks = bool(visible)
        if self._coords is not None:
            self._update_view()

    def handle_key_event(self, ev: QtGui.QKeyEvent) -> bool:  # type: ignore[name-defined]
        """Handle keyboard shortcuts for basic viewer controls.

        r - cartoon/ribbon mode
        c - CA trace mode
        b - atoms/ball mode
        s - toggle sidechains on/off (atoms view)
        q - close the containing window
        """

        try:
            ch = ev.text().lower()
        except Exception:
            return False

        if ch == "r":
            self.set_representation("cartoon")
            return True
        if ch == "c":
            self.set_representation("ca_trace")
            return True
        if ch == "b":
            self.set_representation("atoms")
            return True
        if ch == "s":
            self.toggle_sidechains()
            return True
        if ch == "q":
            w = self.window()
            if w is not None:
                try:
                    w.close()
                except Exception:
                    pass
            return True
        return False

    # ------------------------------------------------------------------
    # Picking / mouse interaction
    # ------------------------------------------------------------------

    def handle_mouse_click(self, ev: QtGui.QMouseEvent) -> None:  # type: ignore[name-defined]
        """Handle a mouse-click in the GL view for residue picking.

        A left-click near the backbone/CA trace selects the nearest residue;
        clicking in empty space clears the selection.
        """

        indices = []
        mods = None
        if self._coords is not None and getattr(self, "_gl_enabled", False) and self.view is not None:
            try:
                sel_cfg = _DISPLAY_CONFIG.get("selection", {})
            except Exception:
                sel_cfg = {}
            try:
                radius_px = float(sel_cfg.get("click_radius_px", 8.0))
            except Exception:
                radius_px = 8.0
            if not np.isfinite(radius_px) or radius_px <= 0.0:
                radius_px = 8.0

            try:
                mods = ev.modifiers()
            except Exception:
                mods = None

            picked_idx = None
            try:
                picked_idx = pick_residue_from_click(
                    self._coords,
                    self.view,
                    ev,
                    radius_px,
                )
            except Exception:
                picked_idx = None

            try:
                pos = ev.pos()
                x = int(pos.x())
                y = int(pos.y())
            except Exception:
                x = int(ev.x())
                y = int(ev.y())

            if picked_idx is not None:
                indices = [picked_idx]
            else:
                half = int(max(1, round(radius_px)))
                rect = QtCore.QRect(x - half, y - half, 2 * half, 2 * half)

                try:
                    idx_arr = pick_residues_in_rect(self._coords, self.view, rect)
                except Exception:
                    idx_arr = np.zeros(0, dtype=int)

                try:
                    indices = [int(i) for i in np.asarray(idx_arr, dtype=int) if int(i) >= 0]
                except Exception:
                    indices = []

        try:
            self._apply_selection_indices(indices, mods)
        except Exception:
            # Fallback: ignore selection errors for robustness.
            pass

        if self._coords is not None and getattr(self, "_gl_enabled", False):
            try:
                self._update_view()
            except Exception:
                pass

    def _apply_selection_indices(self, indices, modifiers=None) -> None:
        try:
            mods = modifiers
            ctrl = bool(mods & QtCore.Qt.ControlModifier) if mods is not None else False
        except Exception:
            ctrl = False

        try:
            idx_list = [int(i) for i in list(indices)]
        except Exception:
            idx_list = []

        if idx_list:
            if ctrl:
                try:
                    current = set(
                        int(i)
                        for i in getattr(self, "_selected_residues", [])
                        if int(i) >= 0
                    )
                except Exception:
                    current = set()
                region = set(i for i in idx_list if i >= 0)
                new_sel = sorted(current.symmetric_difference(region))
                self._selected_residues = new_sel
                try:
                    self.residueSelectionChanged.emit(new_sel)
                except Exception:
                    pass
            else:
                self._selected_residues = idx_list
                try:
                    self.residueSelectionChanged.emit(idx_list)
                except Exception:
                    pass
        else:
            self._selected_residues = []
            try:
                self.residueSelectionChanged.emit([])
            except Exception:
                pass


    def handle_rect_selection(self, rect, modifiers=None) -> None:
        if self._coords is None or not getattr(self, "_gl_enabled", False) or self.view is None:
            return

        try:
            idx_arr = pick_residues_in_rect(self._coords, self.view, rect)
        except Exception:
            idx_arr = np.zeros(0, dtype=int)

        try:
            indices = [int(i) for i in np.asarray(idx_arr, dtype=int) if int(i) >= 0]
        except Exception:
            indices = []
        try:
            self._apply_selection_indices(indices, modifiers)
        except Exception:
            pass

    def _show_disabled_label(self, reason: Optional[str] = None) -> None:
        message = (
            "ProtView OpenGL viewer is disabled.\n"
            "Enable OpenGL support to use this tool."
        )
        if reason:
            message = f"{message}\n\n{reason}"

        if self._disabled_label is None:
            label = QtWidgets.QLabel(self)
            label.setAlignment(QtCore.Qt.AlignCenter)
            label.setWordWrap(True)
            self._disabled_label = label
            self.layout().addWidget(label)

        self._disabled_label.setText(message)
        self._disabled_label.show()

    def on_renderer_error(self, message: str) -> None:
        self._renderer = None
        self._show_disabled_label(message)

        try:
            self._update_view()
        except Exception:
            pass

    def set_selected_residues(self, indices) -> None:
        """Update selection from external widgets (e.g. sequence view)."""

        if self._coords is None:
            self._selected_residues = []
            return

        try:
            idx_list = [int(i) for i in list(indices)]
        except Exception:
            idx_list = []

        try:
            self._apply_selection_indices(idx_list, modifiers=None)
        except Exception:
            self._selected_residues = []

        try:
            self._update_view()
        except Exception:
            pass


    def set_system_info_text(self, text: str) -> None:
        self._info_text = str(text)
        overlay = self._info_overlay
        if overlay is not None:
            try:
                overlay.setPlainText(self._info_text)
            except Exception:
                pass

    def set_system_info_visible(self, visible: bool) -> None:
        self._info_visible = bool(visible)
        overlay = self._info_overlay
        if overlay is not None:
            try:
                overlay.setVisible(self._info_visible)
                if self._info_visible:
                    # Ensure the overlay is stacked above the GL view.
                    overlay.raise_()
            except Exception:
                pass

    def reset_view(self) -> None:
        """Reset camera orientation and distance to a sensible default."""

        if self._renderer is None:
            return
        distance = max(3.0 * float(self._radius or 1.0), 1.0)
        try:
            self._renderer.reset_view(
                distance=distance,
                elevation=self._default_elevation,
                azimuth=self._default_azimuth,
            )
        except Exception:
            pass

    @property
    def residue_oneletter(self) -> Optional[np.ndarray]:
        """Return array of one-letter amino-acid codes for the CA trace."""

        return self._residue_oneletter

    @property
    def residue_names(self) -> Optional[np.ndarray]:
        """Return array of three-letter residue names for the CA trace."""

        return self._residue_names

    def get_residue_colors(self) -> Optional[np.ndarray]:
        """Return (N, 4) RGBA colors used for the CA trace, if available."""

        return self._colors_per_ca

    def add_point_cloud(
        self,
        name: str,
        coords,
        color=None,
        size_scale: Optional[float] = None,
        min_size: Optional[float] = None,
        alpha: float = 1.0,
        px_mode: Optional[bool] = None,
    ) -> None:
        """Register a named point-cloud overlay to be drawn on top of the structure.

        This is intended for external tools (e.g. QUEST) that want to display
        additional 3D data such as diffusion trajectories or accessible-volume
        point clouds. The actual GL items are created inside :meth:`_update_view`.
        """

        try:
            key = str(name)
        except Exception:
            key = repr(name)

        try:
            arr = np.asarray(coords, dtype=float)
        except Exception:
            return
        if arr.ndim != 2 or arr.shape[1] != 3 or arr.shape[0] == 0:
            return

        overlay: dict = {"coords": arr}
        if color is not None:
            overlay["color"] = color
        if size_scale is not None:
            overlay["size_scale"] = float(size_scale)
        if min_size is not None:
            overlay["min_size"] = float(min_size)
        overlay["alpha"] = float(alpha)
        if px_mode is not None:
            overlay["px_mode"] = bool(px_mode)

        try:
            self._point_overlays[key] = overlay
        except Exception:
            return

        if self._renderer is not None and self._coords is not None:
            try:
                self._update_view()
            except Exception:
                pass

    def clear_point_cloud(self, name: str) -> None:
        """Remove a single named point-cloud overlay, if present."""

        try:
            key = str(name)
        except Exception:
            key = repr(name)

        try:
            self._point_overlays.pop(key, None)
        except Exception:
            return

        if self._renderer is not None and self._coords is not None:
            try:
                self._update_view()
            except Exception:
                pass

    def clear_all_point_clouds(self) -> None:
        """Remove all registered point-cloud overlays."""

        try:
            self._point_overlays.clear()
        except Exception:
            return

        if getattr(self, "_gl_enabled", False) and self._coords is not None:
            try:
                self._update_view()
            except Exception:
                pass

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------
    def _clear_items(self) -> None:
        if self._renderer is not None:
            try:
                self._renderer.clear()
            except Exception:
                pass

    def _update_cartoon(
        self, coords: np.ndarray, n_points: int, config: dict, colors: Optional[np.ndarray]
    ) -> list[SceneObject]:
        scene_objects: list[SceneObject] = []
        if not self._show_cartoon:
            return scene_objects

        radius_scale = float(config.get("radius_scale", 0.05))
        min_radius = float(config.get("min_radius", 0.4))
        ao_radius = float(config.get("ao_radius", 4.0))
        ao_max = int(config.get("ao_max_neighbors", 16))
        ao_strength = float(config.get("ao_strength", 0.45))

        coords_cartoon = coords
        colors_for_tube = colors

        # Apply per-residue cartoon mask by subselecting the CA points
        # used to build the tube.
        if self._cartoon_mask is not None and len(self._cartoon_mask) == n_points:
            mask = self._cartoon_mask.astype(bool)
            if mask.any():
                coords_cartoon = coords[mask]
                if colors_for_tube is not None:
                    colors_for_tube = colors_for_tube[mask]
            else:
                coords_cartoon = coords[:0]

        try:
            occ_ca = _estimate_ambient_occlusion(
                coords_cartoon,
                radius=ao_radius,
                max_neighbors=ao_max,
            )
        except Exception:
            occ_ca = None

        if (
            occ_ca is not None
            and np.asarray(occ_ca).shape[0] == coords_cartoon.shape[0]
            and colors_for_tube is not None
        ):
            colors_for_tube = colors_for_tube.copy()
            occ_ca_arr = np.asarray(occ_ca, dtype=float)
            shade = (1.0 - ao_strength) + ao_strength * (1.0 - occ_ca_arr)
            colors_for_tube[:, :3] *= shade.reshape(-1, 1)
            colors_for_tube = np.clip(colors_for_tube, 0.0, 1.0)

        arrays = None
        if coords_cartoon is not None and coords_cartoon.shape[0] >= 2:
            arrays = _generate_cartoon_tube_arrays(
                coords_cartoon,
                colors_for_tube,
                getattr(self, "_trace_ups", None),
                base_radius=max(self._radius * radius_scale, min_radius),
                style=str(config.get("style", "tube")),
                ss_codes=self._secondary_structure,
                config=config,
            )

        if arrays is not None:
            verts, norms, faces_arr, cols = arrays
            geom = Geometry(
                kind="mesh",
                positions=verts,
                indices=faces_arr,
                normals=norms,
                colors=cols,
            )
            scene_objects.append(SceneObject(id="cartoon", geometry=geom, render_mode="opaque"))

        return scene_objects

    def _update_trace(self, coords: np.ndarray, colors: Optional[np.ndarray]) -> list[SceneObject]:
        scene_objects: list[SceneObject] = []
        if not self._show_trace:
            return scene_objects

        coords_line, colors_line = _generate_trace_arrays(coords, colors)
        if colors_line is None:
            colors_line = colors

        geom = Geometry(kind="line", positions=coords_line, colors=colors_line)
        scene_objects.append(SceneObject(id="trace", geometry=geom, render_mode="opaque"))

        return scene_objects

    def _update_atoms(
        self, 
        coords: np.ndarray, 
        n_points: int, 
        balls_cfg: dict,
        colors_per_ca: Optional[np.ndarray]
    ) -> Optional[list[SceneObject]]:
        scene_objects: list[SceneObject] = []
        if not self._show_atoms:
            return scene_objects

        balls_size_scale = float(balls_cfg.get("size_scale", 0.04))
        balls_min_size = float(balls_cfg.get("min_size", 3.0))
        balls_radius_multiplier = float(balls_cfg.get("radius_multiplier", 1.0))
        balls_ao_radius = float(balls_cfg.get("ao_radius", 4.0))
        balls_ao_max = int(balls_cfg.get("max_neighbors", 24))
        balls_ao_strength = float(balls_cfg.get("ao_strength", 0.5))
        balls_max_atoms = int(balls_cfg.get("max_atoms", 8000))
        base_global_radius = max(self._radius * balls_size_scale, balls_min_size)

        used_all_atoms_for_balls = False
        if (
            self._ball_mask is not None
            and len(self._ball_mask) == n_points
            and self._ball_mask.any()
            and self._atoms is not None
            and self._residue_ids is not None
        ):
            atoms = self._atoms
            fields_atoms = set(atoms.dtype.fields or {})
            atom_names = None
            if "res_id" in fields_atoms:
                try:
                    atom_res_id = np.asarray(atoms["res_id"])
                except Exception:
                    atom_res_id = None
            else:
                atom_res_id = None

            # Prefer the already-centered and scaled all-atom coordinates so
            # atoms and cartoon/surface share the exact same frame.
            if self._all_atom_coords is not None:
                atom_xyz = np.asarray(self._all_atom_coords, dtype=float)
            elif "xyz" in fields_atoms:
                try:
                    atom_xyz = np.asarray(atoms["xyz"], dtype=float)
                except Exception:
                    atom_xyz = None
            else:
                atom_xyz = None

            if "atom_name" in fields_atoms:
                try:
                    atom_names = np.char.strip(atoms["atom_name"].astype(str))
                except Exception:
                    atom_names = None

            if atom_xyz is not None and atom_res_id is not None:
                sel_idx = np.nonzero(self._ball_mask)[0]
                if sel_idx.size:
                    sel_res_ids = np.unique(self._residue_ids[sel_idx])
                    atom_mask = np.isin(atom_res_id, sel_res_ids)

                    # Optionally hide sidechains and keep only backbone atoms
                    # in the atoms/ball representation.
                    if not getattr(self, "_sidechains_visible", True) and atom_names is not None:
                        backbone_names = np.array(["N", "CA", "C", "O", "CB"], dtype=atom_names.dtype)
                        backbone_mask = np.isin(atom_names, backbone_names)
                        atom_mask = atom_mask & backbone_mask
                    pts = atom_xyz[atom_mask]
                    if pts.size:
                        pts = np.asarray(pts, dtype=float)
                        radii_sel: Optional[np.ndarray]
                        if (
                            self._all_atom_radii is not None
                            and len(self._all_atom_radii) == atom_xyz.shape[0]
                        ):
                            try:
                                radii_sel = np.asarray(self._all_atom_radii, dtype=float)[atom_mask]
                            except Exception:
                                radii_sel = None
                        else:
                            radii_sel = None

                        if (
                            colors_per_ca is not None
                            and len(colors_per_ca) == len(self._residue_ids)
                        ):
                            color_map = {}
                            for i_res, rid in enumerate(self._residue_ids):
                                color_map[rid] = colors_per_ca[i_res]
                            sel_atom_res = atom_res_id[atom_mask]
                            colors = np.zeros((pts.shape[0], 4), dtype=float)
                            for i_atom, rid in enumerate(sel_atom_res):
                                colors[i_atom, :] = color_map.get(
                                    rid, self._base_color_single
                                )
                        else:
                            colors = np.tile(
                                np.asarray(self._base_color_single, dtype=float),
                                (pts.shape[0], 1),
                            )

                        # Make balls fully opaque
                        colors[:, 3] = 1.0

                        # Lightweight ambient-occlusion style darkening to
                        # improve depth perception in ball view.
                        try:
                            occ_balls = _estimate_ambient_occlusion(
                                pts,
                                radius=balls_ao_radius,
                                max_neighbors=balls_ao_max,
                            )
                        except Exception:
                            occ_balls = None
                        if (
                            occ_balls is not None
                            and np.asarray(occ_balls).shape[0] == pts.shape[0]
                        ):
                            occ_b = np.asarray(occ_balls, dtype=float)
                            shade_balls = (1.0 - balls_ao_strength) + (
                                balls_ao_strength * (1.0 - occ_b)
                            )
                            colors[:, :3] *= shade_balls.reshape(-1, 1)
                            colors = np.clip(colors, 0.0, 1.0)

                        max_atoms = max(1, balls_max_atoms)
                        if pts.shape[0] > max_atoms:
                            step = max(1, pts.shape[0] // max_atoms)
                            pts = pts[::step]
                            colors = colors[::step]
                            if radii_sel is not None and radii_sel.shape[0] >= pts.shape[0]:
                                radii_sel = radii_sel[::step]

                        if radii_sel is not None and radii_sel.shape[0] == pts.shape[0]:
                            radii_for_mesh = radii_sel * balls_radius_multiplier
                        else:
                            radii_for_mesh = np.full(
                                pts.shape[0], base_global_radius, dtype=float
                            )

                        invalid_r = (~np.isfinite(radii_for_mesh)) | (radii_for_mesh <= 0.0)
                        if invalid_r.any():
                            radii_for_mesh[invalid_r] = base_global_radius
                        radii_for_mesh = np.maximum(radii_for_mesh, balls_min_size)

                        # Render all balls for the current selection as a
                        # single merged mesh. This is much faster than
                        # creating one GLMeshItem per atom while still
                        # providing proper shaded spheres, similar to pyball.
                        sphere_mesh = _build_sphere_mesh(radius=1.0)
                        if sphere_mesh is not None and pts.shape[0] > 0:
                            base_verts = sphere_mesh.get("vertices")
                            base_norms = sphere_mesh.get("normals")
                            base_faces = sphere_mesh.get("faces")
                            if (
                                base_verts is not None
                                and base_faces is not None
                                and base_norms is not None
                                and base_verts.size
                                and base_faces.size
                                and base_norms.size
                            ):
                                n_atoms = pts.shape[0]
                                n_verts = base_verts.shape[0]

                                # Duplicate and translate sphere vertices for
                                # each atom center: (N_atoms, N_verts, 3)
                                verts = base_verts[np.newaxis, :, :] * (
                                    radii_for_mesh[:, np.newaxis, np.newaxis]
                                )
                                verts += pts[:, np.newaxis, :]
                                verts = verts.reshape(-1, 3)

                                # Duplicate faces with index offsets
                                faces = np.repeat(
                                    base_faces[np.newaxis, :, :], n_atoms, axis=0
                                )
                                offsets = (
                                    np.arange(n_atoms, dtype=base_faces.dtype)
                                    * n_verts
                                )
                                faces += offsets[:, np.newaxis, np.newaxis]
                                faces = faces.reshape(-1, 3)

                                norms = np.repeat(
                                    base_norms[np.newaxis, :, :], n_atoms, axis=0
                                ).reshape(-1, 3)

                                vcols = None
                                try:
                                    col_arr = np.asarray(colors, dtype=float)
                                    if col_arr.shape[0] == n_atoms:
                                        vcols = np.repeat(
                                            col_arr[:, np.newaxis, :],
                                            n_verts,
                                            axis=1,
                                        ).reshape(-1, 4)
                                except Exception:
                                    vcols = None

                                geom = Geometry(
                                    kind="mesh",
                                    positions=verts,
                                    indices=faces,
                                    normals=norms,
                                    colors=vcols,
                                )
                                scene_objects.append(
                                    SceneObject(id="atoms_mesh", geometry=geom, render_mode="opaque")
                                )

                                used_all_atoms_for_balls = True

        if self._show_atoms and not used_all_atoms_for_balls:
            sphere_radius = max(self._radius * balls_size_scale * 0.5, balls_min_size * 0.1)
            sphere = _build_sphere_mesh(radius=sphere_radius)
            if (
                self._ball_mask is not None
                and len(self._ball_mask) == n_points
                and self._ball_mask.any()
            ):
                indices = np.nonzero(self._ball_mask)[0]
            else:
                # Default: sparse sampling along the chain
                step = max(1, n_points // 50)
                indices = np.arange(0, n_points, step, dtype=int)

            point_positions: list[np.ndarray] = []
            point_colors: list[np.ndarray] = []

            for i in indices:
                center = coords[i]
                color = (
                    colors_per_ca[i]
                    if colors_per_ca is not None
                    else self._base_color_single
                )
                color_local = np.array(color, dtype=float)
                color_local[3] = 1.0
                point_positions.append(center)
                point_colors.append(color_local)

            if point_positions:
                geom = Geometry(
                    kind="points",
                    positions=np.asarray(point_positions, dtype=float),
                    colors=np.asarray(point_colors, dtype=float),
                    meta={"glyph": "sphere", "radius": sphere_radius},
                )
                scene_objects.append(
                    SceneObject(id="atoms_points", geometry=geom, render_mode="opaque")
                )

        return scene_objects if scene_objects else None

    def _update_sticks(self, sticks_cfg: dict, colors_per_ca: Optional[np.ndarray]) -> Optional[list[SceneObject]]:
        if not self._show_sticks:
            return None
        if self._bond_pairs is None or self._all_atom_coords is None:
            return None

        sticks_width = float(sticks_cfg.get("width", 2.0))
        sticks_max_bonds = int(sticks_cfg.get("max_bonds", 20000))

        bonds = np.asarray(self._bond_pairs, dtype=int)
        if bonds.ndim == 2 and bonds.shape[1] == 2 and bonds.size:
            n_atoms_all = self._all_atom_coords.shape[0]
            # Clamp indices to valid range just in case.
            mask_valid = (
                (bonds[:, 0] >= 0)
                & (bonds[:, 0] < n_atoms_all)
                & (bonds[:, 1] >= 0)
                & (bonds[:, 1] < n_atoms_all)
                & (bonds[:, 0] != bonds[:, 1])
            )
            bonds = bonds[mask_valid]

            if bonds.size:
                # Optional bond downsampling for performance.
                if sticks_max_bonds > 0 and bonds.shape[0] > sticks_max_bonds:
                    step = max(1, bonds.shape[0] // sticks_max_bonds)
                    bonds = bonds[::step]

                pts_all = np.asarray(self._all_atom_coords, dtype=float)

                # Build per-atom colors from residue colors when possible.
                if (
                    self._all_atom_res_ids is not None
                    and self._residue_ids is not None
                    and colors_per_ca is not None
                    and len(colors_per_ca) == len(self._residue_ids)
                ):
                    color_map = {}
                    for i_res, rid in enumerate(self._residue_ids):
                        color_map[rid] = colors_per_ca[i_res]
                    atom_res = np.asarray(self._all_atom_res_ids)
                    atom_colors = np.zeros(
                        (atom_res.shape[0], 4), dtype=float
                    )
                    for i_atom, rid in enumerate(atom_res):
                        atom_colors[i_atom, :] = color_map.get(
                            rid, self._base_color_single
                        )
                else:
                    atom_colors = np.tile(
                        np.asarray(self._base_color_single, dtype=float),
                        (pts_all.shape[0], 1),
                    )

                n_bonds = bonds.shape[0]
                seg_pos = np.empty((n_bonds * 2, 3), dtype=float)
                seg_col = np.empty((n_bonds * 2, 4), dtype=float)

                seg_pos[0::2, :] = pts_all[bonds[:, 0]]
                seg_pos[1::2, :] = pts_all[bonds[:, 1]]
                seg_col[0::2, :] = atom_colors[bonds[:, 0]]
                seg_col[1::2, :] = atom_colors[bonds[:, 1]]
                seg_col[:, 3] = 1.0

                geom = Geometry(kind="line", positions=seg_pos, colors=seg_col)
                scene_obj = SceneObject(id="sticks", geometry=geom, render_mode="opaque")

                return [scene_obj]

        return None

    def _update_surface(
        self, 
        coords: np.ndarray, 
        surface_cfg: dict, 
        colors_per_ca: Optional[np.ndarray]
    ) -> Optional[list[SceneObject]]:
        if not self._surface_visible:
            return None

        surface_size_scale = float(surface_cfg.get("size_scale", 0.03))
        surface_min_size = float(surface_cfg.get("min_size", 2.5))
        surface_ao_radius = float(surface_cfg.get("ao_radius", 4.5))
        surface_ao_max = int(surface_cfg.get("max_neighbors", 24))
        surface_ao_strength = float(surface_cfg.get("ao_strength", 0.6))
        surface_alpha = float(surface_cfg.get("alpha", 0.8))
        surface_max_points = int(surface_cfg.get("max_points", 10000))
        surface_color_mode = str(surface_cfg.get("color_mode", "ao_gray")).lower()
        surface_base_color = np.asarray(
            surface_cfg.get("base_color", self._base_color_single), dtype=float
        )
        if surface_base_color.shape[0] != 4:
            surface_base_color = np.asarray(self._base_color_single, dtype=float)

        pts_surface = None
        colors_surface = None

        if self._all_atom_coords is not None:
            pts_surface = np.asarray(self._all_atom_coords, dtype=float)
        else:
            pts_surface = coords.copy()

        if pts_surface.size:
            if (
                surface_color_mode == "by_residue"
                and self._all_atom_res_ids is not None
                and self._residue_ids is not None
                and colors_per_ca is not None
                and len(colors_per_ca) == len(self._residue_ids)
            ):
                color_map = {}
                for i_res, rid in enumerate(self._residue_ids):
                    color_map[rid] = colors_per_ca[i_res]
                colors_surface = np.zeros(
                    (pts_surface.shape[0], 4), dtype=float
                )
                for i_atom, rid in enumerate(self._all_atom_res_ids):
                    colors_surface[i_atom, :] = color_map.get(
                        rid, self._base_color_single
                    )
            else:
                colors_surface = np.tile(
                    surface_base_color,
                    (pts_surface.shape[0], 1),
                )

            max_pts = max(1, surface_max_points)
            if pts_surface.shape[0] > max_pts:
                step = max(1, pts_surface.shape[0] // max_pts)
                pts_surface = pts_surface[::step]
                colors_surface = colors_surface[::step]

            colors_surface = colors_surface.copy()

            # Apply a simple ambient-occlusion style darkening based on
            # local point density so that pockets and grooves appear
            # darker, similar to pyball-style surface shading.
            try:
                occ_surf = _estimate_ambient_occlusion(
                    pts_surface,
                    radius=surface_ao_radius,
                    max_neighbors=surface_ao_max,
                )
            except Exception:
                occ_surf = None
            if (
                occ_surf is not None
                and np.asarray(occ_surf).shape[0] == pts_surface.shape[0]
            ):
                occ_s = np.asarray(occ_surf, dtype=float)
                shade_surf = (1.0 - surface_ao_strength) + (
                    surface_ao_strength * (1.0 - occ_s)
                )
                colors_surface[:, :3] *= shade_surf.reshape(-1, 1)

            colors_surface = np.clip(colors_surface, 0.0, 1.0)
            colors_surface[:, 3] *= surface_alpha

            geom = Geometry(kind="points", positions=pts_surface, colors=colors_surface)
            scene_objects = [SceneObject(id="surface", geometry=geom, render_mode="transparent")]

            return scene_objects

        return None

    def _update_custom_overlays(self, surface_cfg: dict) -> Optional[list[SceneObject]]:
        overlays = getattr(self, "_point_overlays", None)
        if not overlays:
            return None

        surface_size_scale = float(surface_cfg.get("size_scale", 0.03))
        surface_min_size = float(surface_cfg.get("min_size", 2.5))

        scene_objects = []
        for overlay in list(overlays.values()):
            try:
                pts_ov = np.asarray(overlay.get("coords"), dtype=float)
            except Exception:
                continue
            if pts_ov.ndim != 2 or pts_ov.shape[0] == 0 or pts_ov.shape[1] != 3:
                continue

            try:
                col = overlay.get("color", None)
                if col is None:
                    base_col = np.asarray(self._base_color_single, dtype=float)
                    colors_ov = np.tile(base_col, (pts_ov.shape[0], 1))
                else:
                    arr_col = np.asarray(col, dtype=float)
                    if arr_col.ndim == 1 and arr_col.shape[0] == 4:
                        colors_ov = np.tile(arr_col, (pts_ov.shape[0], 1))
                    elif (
                        arr_col.ndim == 2
                        and arr_col.shape[1] == 4
                        and arr_col.shape[0] == pts_ov.shape[0]
                    ):
                        colors_ov = arr_col
                    else:
                        base_col = np.asarray(self._base_color_single, dtype=float)
                        colors_ov = np.tile(base_col, (pts_ov.shape[0], 1))
            except Exception:
                base_col = np.asarray(self._base_color_single, dtype=float)
                colors_ov = np.tile(base_col, (pts_ov.shape[0], 1))

            try:
                size_scale_ov = float(overlay.get("size_scale", surface_size_scale))
            except Exception:
                size_scale_ov = surface_size_scale
            try:
                min_size_ov = float(overlay.get("min_size", surface_min_size))
            except Exception:
                min_size_ov = surface_min_size
            try:
                alpha_ov = float(overlay.get("alpha", 1.0))
            except Exception:
                alpha_ov = 1.0
            try:
                px_mode_ov = bool(overlay.get("px_mode", False))
            except Exception:
                px_mode_ov = False

            size_ov = max(self._radius * size_scale_ov, min_size_ov)
            colors_ov = np.asarray(colors_ov, dtype=float).copy()
            if colors_ov.shape[1] >= 4:
                colors_ov[:, 3] *= alpha_ov

            geom = Geometry(kind="points", positions=pts_ov, colors=colors_ov)
            scene_objects.append(SceneObject(id="overlay", geometry=geom, render_mode="transparent"))

        return scene_objects

    def _update_selection_highlight(self, coords: np.ndarray) -> Optional[list[SceneObject]]:
        sel = getattr(self, "_selected_residues", None)
        if not sel or self._coords is None:
            return None

        try:
            idx_sel = np.asarray(list(sel), dtype=int)
        except Exception:
            idx_sel = np.zeros(0, dtype=int)
        n = self._coords.shape[0]
        if idx_sel.size and n > 0:
            idx_sel = idx_sel[(idx_sel >= 0) & (idx_sel < n)]
        if idx_sel.size:
            try:
                centers = coords[idx_sel]
            except Exception:
                centers = None
            if centers is not None and centers.size:
                try:
                    sel_cfg = _DISPLAY_CONFIG.get("selection", {})
                except Exception:
                    sel_cfg = {}
                try:
                    col = np.asarray(
                        sel_cfg.get("color", [1.0, 1.0, 0.0, 1.0]),
                        dtype=float,
                    )
                except Exception:
                    col = np.array([1.0, 1.0, 0.0, 1.0], dtype=float)
                if col.shape[0] != 4:
                    col = np.array([1.0, 1.0, 0.0, 1.0], dtype=float)
                size_scale = float(sel_cfg.get("size_scale", 0.08))
                min_size = float(sel_cfg.get("min_size", 6.0))
                px_mode = bool(sel_cfg.get("px_mode", False))
                size = max(self._radius * size_scale, min_size)
                color_arr = np.tile(col, (centers.shape[0], 1))
                geom = Geometry(
                    kind="points",
                    positions=centers,
                    colors=color_arr,
                    meta={"glyph": "sphere", "radius": size * 0.1, "size": size, "px_mode": px_mode},
                )
                scene_objects = [SceneObject(id="selection", geometry=geom, render_mode="overlay")]

                return scene_objects

        return None

    def _update_view(self) -> None:
        if self._renderer is None:
            return
        if self._coords is None or self._coords.size == 0:
            self._clear_items()
            return

        self._clear_items()

        # Use raw structure coordinates for all representations so that
        # cartoon, atoms and surface always share the exact same frame.
        coords = self._coords.copy()
        n_points = coords.shape[0]

        # Get display configurations
        cartoon_cfg = _DISPLAY_CONFIG.get("cartoon", {})
        balls_cfg = _DISPLAY_CONFIG.get("balls", {})
        sticks_cfg = _DISPLAY_CONFIG.get("sticks", {})
        surface_cfg = _DISPLAY_CONFIG.get("surface", {})

        # Determine per-vertex base colors
        if (
            self._color_mode == "by_secondary_structure"
            and self._secondary_structure is not None
        ):
            self._colors_per_ca = _build_ss_color_array(
                self._secondary_structure, n_points
            )
        elif self._color_mode == "by_residue" and self._residue_names is not None:
            self._colors_per_ca = _build_residue_color_array(
                self._residue_names, n_points
            )
        else:
            base = np.asarray(self._base_color_single, dtype=float)
            self._colors_per_ca = np.tile(base, (n_points, 1))

        # Render all molecular representations
        scene_objects: list[SceneObject] = []
        scene_objects += self._update_cartoon(coords, n_points, cartoon_cfg, self._colors_per_ca)
        scene_objects += self._update_trace(coords, self._colors_per_ca)
        scene_objects += self._update_atoms(coords, n_points, balls_cfg, self._colors_per_ca) or []
        scene_objects += self._update_sticks(sticks_cfg, self._colors_per_ca) or []
        scene_objects += self._update_surface(coords, surface_cfg, self._colors_per_ca) or []
        scene_objects += self._update_custom_overlays(surface_cfg) or []
        scene_objects += self._update_selection_highlight(coords) or []

        # Adjust camera distance to fit structure nicely
        self._fit_camera_to_radius(self._radius)

        center = (
            np.asarray(self._center, dtype=float)
            if isinstance(self._center, np.ndarray)
            else np.zeros(3, dtype=float)
        )
        self._scene = Scene(objects=scene_objects, center=center, radius=float(self._radius))
        if self._renderer is not None:
            try:
                self._renderer.set_scene(self._scene)
            except Exception:
                pass

    def _fit_camera_to_radius(self, radius: float) -> None:
        if self._renderer is None:
            return
        self._renderer.fit_to_radius(radius)



