feat(sprint1): motor NURBS, modelos de casco, visor 3D PyVista
Geometría:
- BSplineCurve: interpolación scipy, arc_length, tangente, chord-length
- LoftedSurface: lofting de secciones → RectBivariateSpline bivariate
Core (casco Wigley como caso de prueba):
- Section: área, centroide_z, max_half_breadth, curva B-spline
- OffsetsTable: from_wigley(), to_sections(), interpolación xy
- Hull: volumen, Awp, LCB, VCB, Cb, Cm, Cp, desplazamiento, to_mesh()
UI:
- Viewer3DWidget (pyvistaqt.QtInteractor): casco Wigley por defecto
al arrancar, fondo navy, waterplane semi-transparente, fallback
graceful si PyVista no disponible
- MainWindow: Viewer3DWidget inyectado en viewport Perspectiva 3D
Tests: 39 nuevos tests, fórmulas analíticas Wigley verificadas (±1%)
V = 4BLT/9, Cb = 4/9, Awp = 2BL/3 (derivación correcta)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -822,6 +822,16 @@ class MainWindow(QMainWindow):
|
||||
self._module_area = ModuleArea()
|
||||
self.setCentralWidget(self._module_area)
|
||||
|
||||
# Inyectar visor 3D en el viewport Perspectiva (diferido)
|
||||
from arshipdesign.ui.widgets.viewer_3d import Viewer3DWidget, _PYVISTA_OK
|
||||
if _PYVISTA_OK:
|
||||
self._viewer_3d = Viewer3DWidget()
|
||||
vp = self._module_area.four_viewport.viewport("perspective")
|
||||
if vp is not None:
|
||||
vp.set_canvas(self._viewer_3d)
|
||||
else:
|
||||
self._viewer_3d = None
|
||||
|
||||
# Dock izquierdo — capas
|
||||
self._layers_panel = LayersPanel(self._strings)
|
||||
self._dock_layers = QDockWidget("Capas", self)
|
||||
|
||||
@@ -1,2 +1,224 @@
|
||||
"""Vista 3D PyVista. Stub — Sprint 1."""
|
||||
raise NotImplementedError("viewer_3d — Sprint 1")
|
||||
"""
|
||||
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._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 Wigley como geometría de bienvenida
|
||||
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.
|
||||
|
||||
Parámetros
|
||||
----------
|
||||
hull : arshipdesign.core.hull.Hull
|
||||
"""
|
||||
if not self._ready or self._plotter is None:
|
||||
logger.warning("Viewer3DWidget no listo — hull no cargado")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user