fix(ui): smooth B-spline section/waterline curves + 3D ready-signal sync

- viewer_3d.py: add `ready` Signal emitted once QtInteractor finishes init
- main_window.py: connect ready signal to sync active hull into 3D viewer on startup
- viewer_lines.py: add _smooth_pts helper; replace straight polylines in BodyPlanViewer
  and PlanViewer CAPA 3 with B-spline interpolated curves (80 sample points)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 17:54:08 -04:00
parent eac3a3c965
commit 58228080e8
3 changed files with 53 additions and 10 deletions
+10
View File
@@ -841,6 +841,7 @@ class MainWindow(QMainWindow):
from arshipdesign.ui.widgets.viewer_3d import Viewer3DWidget, _PYVISTA_OK from arshipdesign.ui.widgets.viewer_3d import Viewer3DWidget, _PYVISTA_OK
if _PYVISTA_OK: if _PYVISTA_OK:
self._viewer_3d = Viewer3DWidget() self._viewer_3d = Viewer3DWidget()
self._viewer_3d.ready.connect(self._on_3d_viewer_ready)
vp = self._module_area.four_viewport.viewport("perspective") vp = self._module_area.four_viewport.viewport("perspective")
if vp is not None: if vp is not None:
vp.set_canvas(self._viewer_3d) vp.set_canvas(self._viewer_3d)
@@ -1357,6 +1358,15 @@ class MainWindow(QMainWindow):
self._load_hull_viewers(hull, _skip_offsets_editor=True) self._load_hull_viewers(hull, _skip_offsets_editor=True)
self.statusBar().showMessage(f"Offsets actualizados — {hull.name}") self.statusBar().showMessage(f"Offsets actualizados — {hull.name}")
def _on_3d_viewer_ready(self) -> None:
"""El plotter 3D terminó de inicializarse — sincronizar con el casco activo."""
if self._current_hull is not None and self._viewer_3d is not None:
try:
self._current_hull.invalidate()
self._viewer_3d.load_hull(self._current_hull)
except Exception as exc:
logger.warning("Error al sincronizar 3D tras init: %s", exc)
def _on_offsets_edited_from_viewer(self, offsets_table) -> None: def _on_offsets_edited_from_viewer(self, offsets_table) -> None:
"""Slot: fin del drag — persistir + actualizar 3D + hidrostáticos. """Slot: fin del drag — persistir + actualizar 3D + hidrostáticos.
+4 -1
View File
@@ -17,7 +17,7 @@ import logging
from typing import Optional from typing import Optional
import numpy as np import numpy as np
from PySide6.QtCore import Qt, QTimer from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
logger = logging.getLogger("ui.viewer_3d") logger = logging.getLogger("ui.viewer_3d")
@@ -50,6 +50,8 @@ class Viewer3DWidget(QWidget):
>>> v.reset_camera() >>> v.reset_camera()
""" """
ready: Signal = Signal()
def __init__(self, parent: Optional[QWidget] = None) -> None: def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent) super().__init__(parent)
self._plotter: Optional["QtInteractor"] = None self._plotter: Optional["QtInteractor"] = None
@@ -114,6 +116,7 @@ class Viewer3DWidget(QWidget):
else: else:
self._load_default_wigley() self._load_default_wigley()
self._ready = True self._ready = True
self.ready.emit()
logger.info("Viewer3DWidget: QtInteractor iniciado correctamente") logger.info("Viewer3DWidget: QtInteractor iniciado correctamente")
except Exception as exc: except Exception as exc:
+39 -9
View File
@@ -365,6 +365,25 @@ def _draw_cnet_planview(p: QPainter, ot, w2s_fn) -> None:
p.drawPath(path) p.drawPath(path)
def _smooth_pts(pts_2d: np.ndarray, n: int = 60) -> np.ndarray:
"""Muestrea n puntos de una B-spline interpolada a través de pts_2d.
pts_2d : shape (m, 2)
Returns shape (n, 2). Si hay < 4 puntos o falla el spline,
devuelve los puntos originales sin modificar.
"""
from arshipdesign.geometry.nurbs_curve import BSplineCurve
m = len(pts_2d)
if m < 4:
return pts_2d
try:
k = min(3, m - 1)
curve = BSplineCurve(pts_2d, degree=k)
return curve.sample(n) # shape (n, 2)
except Exception:
return pts_2d
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 1. Body Plan — secciones transversales # 1. Body Plan — secciones transversales
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -475,14 +494,20 @@ class BodyPlanViewer(_BaseViewer):
z_arr = ot.z_waterlines z_arr = ot.z_waterlines
sign = 1.0 if is_fwd else -1.0 sign = 1.0 if is_fwd else -1.0
# Smooth B-spline through the section data points
raw = np.column_stack([y_arr * sign, z_arr])
# Close to keel: append (0, 0) as the last interpolation point
keel_row = np.array([[0.0, 0.0]])
raw_closed = np.vstack([raw, keel_row])
smooth = _smooth_pts(raw_closed, n=80)
path = QPainterPath() path = QPainterPath()
for k, (y, z) in enumerate(zip(y_arr, z_arr)): for k_pt in range(len(smooth)):
pt = self._w2s(sign * y, z) pt = self._w2s(smooth[k_pt, 0], smooth[k_pt, 1])
if k == 0: if k_pt == 0:
path.moveTo(pt) path.moveTo(pt)
else: else:
path.lineTo(pt) path.lineTo(pt)
path.lineTo(self._w2s(0.0, 0.0))
p.drawPath(path) p.drawPath(path)
# Flotación de diseño (encima de todo lo anterior) # Flotación de diseño (encima de todo lo anterior)
@@ -691,15 +716,20 @@ class PlanViewer(_BaseViewer):
width = 2.2 width = 2.2
else: else:
color = QColor(_WATERLINE) color = QColor(_WATERLINE)
color.setAlphaF(0.40 + 0.50 * frac) color.setAlphaF(0.45 + 0.45 * frac)
width = 1.1 width = 1.2
p.setPen(QPen(color, width)) p.setPen(QPen(color, width))
p.setBrush(Qt.BrushStyle.NoBrush) p.setBrush(Qt.BrushStyle.NoBrush)
# Smooth B-spline through the waterline data points
raw = np.column_stack([ot.x_stations, ot.data[:, j]])
smooth = _smooth_pts(raw, n=80)
path = QPainterPath() path = QPainterPath()
for i, (x, y) in enumerate(zip(ot.x_stations, ot.data[:, j])): for k_pt in range(len(smooth)):
pt = self._w2s(x, y) pt = self._w2s(smooth[k_pt, 0], smooth[k_pt, 1])
if i == 0: if k_pt == 0:
path.moveTo(pt) path.moveTo(pt)
else: else:
path.lineTo(pt) path.lineTo(pt)