277 lines
11 KiB
Python
277 lines
11 KiB
Python
"""
|
|
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)
|