Módulo 1: peine de curvatura + live cross-view durante drag (Tasks 17-18)
- viewer_lines.py: añade toggle curvatura (tecla C), señal offsets_dragging, método update_offsets (sin resetear zoom/pan), keyPressEvent, hint overlay, funciones _curvature_comb_data y _draw_curvature_comb para BodyPlan y Plan - main_window.py: conecta offsets_dragging → slot ligero _on_offsets_dragging que actualiza vistas 2D en vivo; _on_offsets_edited_from_viewer usa update_offsets en lugar de set_hull para preservar zoom/pan al soltar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -860,7 +860,10 @@ class MainWindow(QMainWindow):
|
||||
if _vp is not None:
|
||||
_vp.set_canvas(_widget)
|
||||
|
||||
# Conectar edición interactiva de control points → propagar a todos los visores
|
||||
# Edición live durante drag → actualizar vistas cruzadas sin resetear zoom
|
||||
self._viewer_bodyplan.offsets_dragging.connect(self._on_offsets_dragging)
|
||||
self._viewer_plan.offsets_dragging.connect(self._on_offsets_dragging)
|
||||
# Fin del drag → persistir + actualizar 3D + hidrostáticos
|
||||
self._viewer_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
||||
self._viewer_plan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
||||
|
||||
@@ -1328,6 +1331,15 @@ class MainWindow(QMainWindow):
|
||||
# ── Panel hidrostáticos ───────────────────────────────────
|
||||
self._update_hydrostatics(hull)
|
||||
|
||||
def _on_offsets_dragging(self, offsets_table) -> None:
|
||||
"""Slot ligero — actualiza vistas 2D durante drag sin resetear zoom ni actualizar 3D."""
|
||||
hull = self._current_hull
|
||||
if hull is None:
|
||||
return
|
||||
self._viewer_bodyplan.update_offsets(hull)
|
||||
self._viewer_profile.update_offsets(hull)
|
||||
self._viewer_plan.update_offsets(hull)
|
||||
|
||||
def _on_hull_changed_from_editor(self, hull) -> None:
|
||||
"""Slot: el editor de offsets reconstruyo el Hull — propagar a visores y proyecto."""
|
||||
self._current_hull = hull
|
||||
@@ -1337,31 +1349,30 @@ class MainWindow(QMainWindow):
|
||||
self.statusBar().showMessage(f"Offsets actualizados — {hull.name}")
|
||||
|
||||
def _on_offsets_edited_from_viewer(self, offsets_table) -> None:
|
||||
"""Slot: un visor 2D editó un punto de control — sincronizar todos los visores.
|
||||
"""Slot: fin del drag — persistir + actualizar 3D + hidrostáticos.
|
||||
|
||||
La OffsetsTable ya fue modificada in-place por el visor (durante el drag).
|
||||
Aquí propagamos el cambio al visor 3D, al panel de hidrostáticos y al
|
||||
editor de offsets, e informamos al proyecto del estado nuevo.
|
||||
Usa update_offsets (no set_hull) para que los visores 2D NO reseteen
|
||||
su zoom/pan al terminar una edición.
|
||||
"""
|
||||
hull = self._current_hull
|
||||
if hull is None:
|
||||
return
|
||||
# hull.offsets ya contiene los cambios (modificación in-place del visor)
|
||||
# hull.offsets ya fue modificado in-place durante el drag
|
||||
if self._project is not None:
|
||||
self._project.set_hull(hull)
|
||||
# Refrescar la vista cruzada (edición body plan actualiza planta y viceversa)
|
||||
self._viewer_bodyplan.set_hull(hull)
|
||||
self._viewer_profile.set_hull(hull)
|
||||
self._viewer_plan.set_hull(hull)
|
||||
# Sincronizar editor de tabla de offsets
|
||||
# Actualizar vistas 2D SIN resetear zoom/pan
|
||||
self._viewer_bodyplan.update_offsets(hull)
|
||||
self._viewer_profile.update_offsets(hull)
|
||||
self._viewer_plan.update_offsets(hull)
|
||||
# Sincronizar editor de tabla
|
||||
self._offsets_editor.set_hull(hull)
|
||||
# Actualizar visor 3D con la geometría nueva
|
||||
# Visor 3D — sólo al soltar (no durante drag)
|
||||
if self._viewer_3d is not None:
|
||||
try:
|
||||
self._viewer_3d.load_hull(hull)
|
||||
except Exception as exc:
|
||||
logger.warning("Error al actualizar visor 3D: %s", exc)
|
||||
# Actualizar barra de hidrostáticos
|
||||
# Barra de hidrostáticos
|
||||
self._update_hydrostatics(hull)
|
||||
self.statusBar().showMessage(f"Geometría editada — {hull.name}")
|
||||
|
||||
|
||||
@@ -63,8 +63,10 @@ _CPT_HIT = 14.0 # px umbral de captura
|
||||
class _BaseViewer(QWidget):
|
||||
"""Widget base con zoom/paneo y edición de puntos de control."""
|
||||
|
||||
# Emitido cuando el usuario arrastra un punto y suelta el botón
|
||||
offsets_edited = Signal(object) # OffsetsTable modificada
|
||||
# Emitido mientras el usuario arrastra (en cada mouseMoveEvent con drag)
|
||||
offsets_dragging = Signal(object) # OffsetsTable — actualización en vivo
|
||||
# Emitido cuando el usuario suelta el botón (fin del drag)
|
||||
offsets_edited = Signal(object) # OffsetsTable modificada
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -78,18 +80,27 @@ class _BaseViewer(QWidget):
|
||||
self._drag_idx: Optional[tuple[int, int]] = None
|
||||
self._drag_orig: float = 0.0 # valor antes del drag (para deshacer si se escapa)
|
||||
|
||||
self._show_curvature = False # toggle con tecla C
|
||||
self.setMouseTracking(True)
|
||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
# ─── API pública ──────────────────────────────────────────────────────────
|
||||
|
||||
def set_hull(self, hull: Optional[Hull]) -> None:
|
||||
"""Carga el casco y resetea zoom/pan al autofit (para carga inicial)."""
|
||||
self._hull = hull
|
||||
self._hover_idx = None
|
||||
self._drag_idx = None
|
||||
self._fit_to_view()
|
||||
self.update()
|
||||
|
||||
def update_offsets(self, hull: Optional[Hull]) -> None:
|
||||
"""Actualiza datos SIN resetear zoom/pan — usar para ediciones live."""
|
||||
self._hull = hull
|
||||
self._hover_idx = None
|
||||
self.update()
|
||||
|
||||
# ─── Transform mundo ↔ pantalla ──────────────────────────────────────────
|
||||
|
||||
def _w2s(self, wx: float, wy: float) -> QPointF:
|
||||
@@ -124,6 +135,13 @@ class _BaseViewer(QWidget):
|
||||
cy = ph / 2 - (wy0 + wh / 2) * self._scale
|
||||
self._offset = QPointF(cx, cy)
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
if event.key() == Qt.Key.Key_C:
|
||||
self._show_curvature = not self._show_curvature
|
||||
self.update()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def _world_bbox(self) -> Optional[tuple[float, float, float, float]]:
|
||||
return None # subclases
|
||||
|
||||
@@ -147,6 +165,7 @@ class _BaseViewer(QWidget):
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
self.setFocus() # captura el foco de teclado al hacer clic
|
||||
btn = event.button()
|
||||
if btn == Qt.MouseButton.LeftButton and self._hull is not None:
|
||||
idx = self._hit_test(event.position())
|
||||
@@ -172,6 +191,7 @@ class _BaseViewer(QWidget):
|
||||
if self._drag_idx is not None and self._hull is not None:
|
||||
self._apply_drag(event.position(), self._drag_idx)
|
||||
self.update()
|
||||
self.offsets_dragging.emit(self._hull.offsets) # live cross-view
|
||||
return
|
||||
|
||||
# ── Hover ─────────────────────────────────────────────────────────
|
||||
@@ -231,6 +251,15 @@ class _BaseViewer(QWidget):
|
||||
p.setFont(QFont("Monospace", 10))
|
||||
p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg)
|
||||
|
||||
def _draw_hint_overlay(self, p: QPainter) -> None:
|
||||
"""Esquina inferior-derecha: atajo de teclado para curvatura."""
|
||||
txt = "[C] Curvatura ON" if self._show_curvature else "[C] Curvatura"
|
||||
col = QColor("#ffd700") if self._show_curvature else QColor("#3a4870")
|
||||
p.setFont(QFont("Monospace", 7))
|
||||
p.setPen(QPen(col))
|
||||
r = self.rect().adjusted(0, 0, -4, -4)
|
||||
p.drawText(r, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, txt)
|
||||
|
||||
def _draw_control_point(
|
||||
self,
|
||||
p: QPainter,
|
||||
@@ -377,6 +406,23 @@ class BodyPlanViewer(_BaseViewer):
|
||||
for j in range(ot.n_waterlines):
|
||||
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
|
||||
|
||||
# ── Peine de curvatura (toggle C) ─────────────────────────────
|
||||
if self._show_curvature:
|
||||
for i in range(n):
|
||||
sign = 1.0 if i >= n // 2 else -1.0
|
||||
z_arr = ot.z_waterlines
|
||||
y_arr = ot.data[i, :]
|
||||
# En el body plan: curva en espacio (z, y) — normal en dirección y
|
||||
_draw_curvature_comb(
|
||||
p,
|
||||
xs=z_arr, ys=y_arr * sign,
|
||||
w2s_fn=lambda z, y: self._w2s(y, z),
|
||||
scale=ot.draft * 0.25,
|
||||
color_pos=QColor("#ff6b6b"),
|
||||
color_neg=QColor("#6baaff"),
|
||||
)
|
||||
|
||||
self._draw_hint_overlay(p)
|
||||
self._draw_label(p, "BODY PLAN")
|
||||
p.end()
|
||||
|
||||
@@ -574,13 +620,129 @@ class PlanViewer(_BaseViewer):
|
||||
for j in range(n_wl):
|
||||
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
|
||||
|
||||
# ── Peine de curvatura (toggle C) ─────────────────────────────
|
||||
if self._show_curvature:
|
||||
x_arr = ot.x_stations
|
||||
for j in range(n_wl):
|
||||
y_arr = ot.data[:, j]
|
||||
_draw_curvature_comb(
|
||||
p,
|
||||
xs=x_arr, ys=y_arr,
|
||||
w2s_fn=self._w2s,
|
||||
scale=self._hull.beam * 0.18,
|
||||
color_pos=QColor("#ff6b6b"),
|
||||
color_neg=QColor("#6baaff"),
|
||||
)
|
||||
|
||||
self._draw_hint_overlay(p)
|
||||
self._draw_label(p, "VISTA DE PLANTA")
|
||||
p.end()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Utilidad interna
|
||||
# Utilidades internas
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _dist(a: QPointF, b: QPointF) -> float:
|
||||
return math.hypot(a.x() - b.x(), a.y() - b.y())
|
||||
|
||||
|
||||
def _curvature_comb_data(
|
||||
xs: np.ndarray, ys: np.ndarray
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Calcula curvatura discreta firmada y normales unitarias para una curva (xs, ys).
|
||||
|
||||
Retorna (kappas, nx, ny):
|
||||
- kappas[i]: curvatura firmada en el punto i [1/unidad de longitud]
|
||||
- (nx[i], ny[i]): normal unitaria (90° a la izquierda del tangente)
|
||||
- Los extremos (i=0, i=n-1) tienen kappas=0.
|
||||
"""
|
||||
n = len(xs)
|
||||
kappas = np.zeros(n)
|
||||
nxs = np.zeros(n)
|
||||
nys = np.zeros(n)
|
||||
|
||||
for i in range(1, n - 1):
|
||||
dx1, dy1 = float(xs[i] - xs[i-1]), float(ys[i] - ys[i-1])
|
||||
dx2, dy2 = float(xs[i+1] - xs[i]), float(ys[i+1] - ys[i])
|
||||
l1 = math.hypot(dx1, dy1)
|
||||
l2 = math.hypot(dx2, dy2)
|
||||
if l1 < 1e-9 or l2 < 1e-9:
|
||||
continue
|
||||
# Tangente promediada normalizada
|
||||
tx = dx1/l1 + dx2/l2
|
||||
ty = dy1/l1 + dy2/l2
|
||||
tl = math.hypot(tx, ty)
|
||||
if tl < 1e-9:
|
||||
continue
|
||||
tx /= tl; ty /= tl
|
||||
nxs[i] = -ty
|
||||
nys[i] = tx
|
||||
# Curvatura firmada (producto cruzado de tangentes unitarias)
|
||||
cross = (dx1/l1) * (dy2/l2) - (dy1/l1) * (dx2/l2)
|
||||
kappas[i] = 2.0 * cross / (l1 + l2 + 1e-12)
|
||||
|
||||
return kappas, nxs, nys
|
||||
|
||||
|
||||
def _draw_curvature_comb(
|
||||
p: QPainter,
|
||||
xs: np.ndarray,
|
||||
ys: np.ndarray,
|
||||
w2s_fn,
|
||||
scale: float,
|
||||
color_pos: QColor,
|
||||
color_neg: QColor,
|
||||
) -> None:
|
||||
"""
|
||||
Dibuja el peine de curvatura sobre la curva discreta (xs, ys).
|
||||
|
||||
Cada 'diente' es una línea perpendicular a la curva con longitud k·scale.
|
||||
Se dibuja también el spine conectando las puntas de los dientes.
|
||||
|
||||
Parámetros
|
||||
----------
|
||||
w2s_fn : callable(x, y) → QPointF
|
||||
Función de conversión mundo→pantalla del visor.
|
||||
scale : float
|
||||
Factor de amplificación en unidades de mundo.
|
||||
color_pos / color_neg : QColor
|
||||
Colores para curvatura positiva / negativa.
|
||||
"""
|
||||
if len(xs) < 3:
|
||||
return
|
||||
|
||||
kappas, nxs, nys = _curvature_comb_data(xs, ys)
|
||||
|
||||
tips_world: list[Optional[tuple[float, float]]] = []
|
||||
|
||||
for i in range(len(xs)):
|
||||
k = kappas[i]
|
||||
if abs(k) < 1e-9:
|
||||
tips_world.append(None)
|
||||
continue
|
||||
ex = float(xs[i]) + nxs[i] * k * scale
|
||||
ey = float(ys[i]) + nys[i] * k * scale
|
||||
tips_world.append((ex, ey))
|
||||
# Diente
|
||||
col = color_pos if k > 0 else color_neg
|
||||
p.setPen(QPen(col, 0.8))
|
||||
p.drawLine(w2s_fn(float(xs[i]), float(ys[i])), w2s_fn(ex, ey))
|
||||
|
||||
# Spine (línea que une las puntas)
|
||||
spine = QPainterPath()
|
||||
started = False
|
||||
for tip in tips_world:
|
||||
if tip is None:
|
||||
started = False
|
||||
continue
|
||||
pt = w2s_fn(tip[0], tip[1])
|
||||
if not started:
|
||||
spine.moveTo(pt)
|
||||
started = True
|
||||
else:
|
||||
spine.lineTo(pt)
|
||||
p.setPen(QPen(color_pos, 1.0))
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
p.drawPath(spine)
|
||||
|
||||
Reference in New Issue
Block a user