""" Viewer3DWidget — visor 3D interactivo basado en PyVista / pyvistaqt. Integra un QtInteractor de pyvistaqt directamente en el viewport "Perspectiva 3D" del layout de 4 viewports. Si pyvistaqt no está disponible (entorno CI / offscreen) la clase degrada a un QLabel de reemplazo sin lanzar excepción, para que el smoke test y el resto de la UI sigan funcionando. Autor: Álvaro Romero Sprint 1 — AR-ShipDesign """ from __future__ import annotations import logging from typing import Optional import numpy as np from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget logger = logging.getLogger("ui.viewer_3d") # --------------------------------------------------------------------------- # Importación condicional de PyVista # --------------------------------------------------------------------------- try: import pyvista as pv from pyvistaqt import QtInteractor _PYVISTA_OK = True except Exception as _pv_err: # ImportError o cualquier otro pv = None # type: ignore[assignment] QtInteractor = None # type: ignore[assignment] _PYVISTA_OK = False logger.warning("PyVista/pyvistaqt no disponible: %s", _pv_err) class Viewer3DWidget(QWidget): """Widget embebible de visor 3D. Si PyVista está disponible → usa ``pyvistaqt.QtInteractor``. Si no → muestra un QLabel de aviso (modo degradado). Uso típico: ----------- >>> v = Viewer3DWidget(parent=viewport_frame) >>> v.load_hull(hull_object) # muestra casco NURBS >>> v.load_mesh(pv_mesh) # carga cualquier PolyData >>> v.reset_camera() """ ready: Signal = Signal() def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._plotter: Optional["QtInteractor"] = None self._ready = False self._pending_hull = None # hull recibido antes de que el plotter esté listo self._hull_actor = None # actor VTK del casco — para toggle de aristas self._show_edges = False # mallas apagadas por defecto (como Delftship) self._edge_btn: Optional[QPushButton] = None self._build_ui() # ------------------------------------------------------------------ # Construcción de la UI # ------------------------------------------------------------------ def _build_ui(self) -> None: lo = QVBoxLayout(self) lo.setContentsMargins(0, 0, 0, 0) lo.setSpacing(0) if _PYVISTA_OK: # El QtInteractor se crea diferido (después de que la ventana esté visible) # para evitar problemas con el contexto OpenGL en el arranque. self._placeholder = QLabel("Iniciando visor 3D…") self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) self._placeholder.setObjectName("viewportPlaceholder") lo.addWidget(self._placeholder) # Diferir inicialización hasta que Qt haya mostrado la ventana QTimer.singleShot(500, self._init_plotter) else: lbl = QLabel("PyVista no disponible\n(pip install pyvistaqt)") lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) lbl.setObjectName("viewportPlaceholder") lo.addWidget(lbl) def _init_plotter(self) -> None: """Crea el QtInteractor y carga la malla Wigley por defecto.""" if not _PYVISTA_OK: return try: lo = self.layout() # Eliminar placeholder if lo.count() > 0: old = lo.takeAt(0).widget() if old: old.hide() old.deleteLater() # ── Barra de herramientas 3D ─────────────────────────────── toolbar = QWidget() toolbar.setObjectName("viewer3dToolbar") tb_lo = QHBoxLayout(toolbar) tb_lo.setContentsMargins(4, 2, 4, 2) tb_lo.setSpacing(4) self._edge_btn = QPushButton("⬡ Mallas") self._edge_btn.setCheckable(True) self._edge_btn.setChecked(self._show_edges) self._edge_btn.setFixedHeight(22) self._edge_btn.setToolTip( "Mostrar / ocultar aristas de la malla (como Delftship)" ) self._edge_btn.toggled.connect(self._on_edge_toggled) tb_lo.addWidget(self._edge_btn) tb_lo.addStretch() lo.addWidget(toolbar) # ── Interactor PyVista embebido ──────────────────────────── self._plotter = QtInteractor( parent=self, auto_update=False, # sin polling continuo de GPU off_screen=False, ) self._plotter.setObjectName("pyvistaInteractor") lo.addWidget(self._plotter) # Configurar tema dark para que combine con la UI self._plotter.set_background("#1a1d30") # viewportCanvas color # Cargar casco pendiente (recibido antes del init) o Wigley por defecto if self._pending_hull is not None: mesh = self._pending_hull.to_mesh() self._render_hull_mesh(mesh) self._pending_hull = None else: self._load_default_wigley() self._ready = True self.ready.emit() logger.info("Viewer3DWidget: QtInteractor iniciado correctamente") except Exception as exc: logger.error("Error al iniciar PyVista: %s", exc) lbl = QLabel(f"Error visor 3D:\n{exc}") lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) lbl.setObjectName("viewportPlaceholder") self.layout().addWidget(lbl) def _load_default_wigley(self) -> None: """Renderiza el casco Wigley de demostración al arrancar.""" try: from arshipdesign.core.hull import Hull hull = Hull.from_wigley( lpp=10.0, beam=1.5, draft=0.75, n_stations=40, n_waterlines=20 ) mesh = hull.to_mesh(n_u=50, n_v=25) self._render_hull_mesh(mesh) except Exception as exc: logger.warning("No se pudo cargar Wigley por defecto: %s", exc) # ------------------------------------------------------------------ # API pública # ------------------------------------------------------------------ def load_hull(self, hull) -> None: """Carga un objeto Hull en el visor. Si el plotter aún no ha terminado de inicializarse (race condition de 500 ms), guarda el hull como pendiente — se cargará al final de _init_plotter(). Parámetros ---------- hull : arshipdesign.core.hull.Hull """ if not self._ready or self._plotter is None: self._pending_hull = hull # se cargará cuando _init_plotter termine return try: mesh = hull.to_mesh() self._render_hull_mesh(mesh) except Exception as exc: logger.error("Error al cargar Hull: %s", exc) def load_mesh(self, mesh: "pv.PolyData") -> None: """Carga directamente una malla PyVista.""" if not self._ready or self._plotter is None: return self._render_hull_mesh(mesh) def reset_camera(self) -> None: """Resetea la cámara a la vista isométrica.""" if self._plotter is not None: self._plotter.reset_camera() self._plotter.view_isometric() def clear(self) -> None: """Limpia todos los actores de la escena.""" if self._plotter is not None: self._plotter.clear() @property def is_ready(self) -> bool: return self._ready # ------------------------------------------------------------------ # Helpers internos # ------------------------------------------------------------------ def _render_hull_mesh(self, mesh: "pv.PolyData") -> None: """Renderiza una malla con el estilo marítimo del tema.""" if self._plotter is None: return self._plotter.clear() # Casco principal — color sólido estilo DelftShip # smooth_shading=False → facetas planas, aspecto sólido sin blur # ambient alto → menos sombras duras, color uniforme # specular bajo → sin brillos que difuminen el color self._hull_actor = self._plotter.add_mesh( mesh, color="#4a8ab0", # azul acero más vivo smooth_shading=False, # facetado / sólido (no blur) show_edges=self._show_edges, edge_color="#90c8f0", line_width=0.6, opacity=1.0, # totalmente opaco ambient=0.40, # luz ambiente alta: sombras suaves diffuse=0.60, # difuso moderado specular=0.05, # casi sin especular (anti-blur) specular_power=5, name="hull", ) # Plano de flotación semi-transparente try: hull_pts = mesh.points x_min, x_max = float(hull_pts[:, 0].min()), float(hull_pts[:, 0].max()) y_max = float(np.abs(hull_pts[:, 1]).max()) * 1.05 wl = pv.Plane( center=((x_min + x_max) / 2, 0, 0), direction=(0, 0, 1), i_size=(x_max - x_min), j_size=2 * y_max, i_resolution=1, j_resolution=1, ) self._plotter.add_mesh( wl, color="#4da8ff", opacity=0.15, name="waterplane" ) except Exception: pass # no crítico self._plotter.view_isometric() self._plotter.reset_camera() def _on_edge_toggled(self, checked: bool) -> None: """Activa / desactiva la malla de aristas sin re-renderizar el casco.""" self._show_edges = checked if self._hull_actor is not None and self._plotter is not None: prop = self._hull_actor.GetProperty() if checked: prop.EdgeVisibilityOn() else: prop.EdgeVisibilityOff() self._plotter.render() def closeEvent(self, event) -> None: # type: ignore[override] """Libera el contexto PyVista al cerrar.""" if self._plotter is not None: try: self._plotter.close() except Exception: pass super().closeEvent(event)