Files
AR-Shipdesign/arshipdesign/ui/widgets/viewer_3d.py
T
alro65 98ff57ed08 Módulo 1 fixes + Módulo 2 motor hidrostático (Tasks 13–13b)
Fixes Module 1 UI:
- wizard_cruiser/sailing/planing: perfiles sin^n calibrados por Cm, V-bottom
  con ángulo de astilla, corrección zona sobre chine planeador
- viewer_3d: buffer hull pendiente para eliminar race condition 500ms
- viewer_lines: reescritura completa — waterlines visibles, control points
  interactivos (drag DelftShip-style), señal offsets_edited
- main_window: conecta offsets_edited → slot _on_offsets_edited_from_viewer
  que propaga cambios a todos los visores, editor, 3D y barra hidrostática

Módulo 2 — motor HydrostaticCurves (Task 13):
- integrator.py: integrate() (Simpson+trapz), waterplane_strips(), section_areas()
- upright.py: UprightHydrostatics (19 campos), compute_upright() single-pass
- curves_of_form.py: HydrostaticCurves.compute(), at_draft(), to_csv_lines(), to_dict()
- tests/test_module2_hydrostatics.py: 83 tests — Wigley V&V, monotonicidad,
  CSV export, IACS Rec.34 §4.3–4.5; todos los 224 tests pasan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 09:11:58 -04:00

234 lines
8.3 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
from PySide6.QtWidgets import QLabel, 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()
"""
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._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()
# Crear el 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
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 acero naval
self._plotter.add_mesh(
mesh,
color="#3a6080",
smooth_shading=True,
show_edges=True,
edge_color="#4da8ff",
line_width=0.3,
opacity=0.92,
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 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)