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:
2026-05-27 13:26:35 -04:00
parent 652fdca358
commit 62de89d63c
2 changed files with 189 additions and 16 deletions
+24 -13
View File
@@ -860,7 +860,10 @@ class MainWindow(QMainWindow):
if _vp is not None: if _vp is not None:
_vp.set_canvas(_widget) _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_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
self._viewer_plan.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 ─────────────────────────────────── # ── Panel hidrostáticos ───────────────────────────────────
self._update_hydrostatics(hull) 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: def _on_hull_changed_from_editor(self, hull) -> None:
"""Slot: el editor de offsets reconstruyo el Hull — propagar a visores y proyecto.""" """Slot: el editor de offsets reconstruyo el Hull — propagar a visores y proyecto."""
self._current_hull = hull self._current_hull = hull
@@ -1337,31 +1349,30 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage(f"Offsets actualizados — {hull.name}") self.statusBar().showMessage(f"Offsets actualizados — {hull.name}")
def _on_offsets_edited_from_viewer(self, offsets_table) -> None: 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). Usa update_offsets (no set_hull) para que los visores 2D NO reseteen
Aquí propagamos el cambio al visor 3D, al panel de hidrostáticos y al su zoom/pan al terminar una edición.
editor de offsets, e informamos al proyecto del estado nuevo.
""" """
hull = self._current_hull hull = self._current_hull
if hull is None: if hull is None:
return 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: if self._project is not None:
self._project.set_hull(hull) self._project.set_hull(hull)
# Refrescar la vista cruzada (edición body plan actualiza planta y viceversa) # Actualizar vistas 2D SIN resetear zoom/pan
self._viewer_bodyplan.set_hull(hull) self._viewer_bodyplan.update_offsets(hull)
self._viewer_profile.set_hull(hull) self._viewer_profile.update_offsets(hull)
self._viewer_plan.set_hull(hull) self._viewer_plan.update_offsets(hull)
# Sincronizar editor de tabla de offsets # Sincronizar editor de tabla
self._offsets_editor.set_hull(hull) 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: if self._viewer_3d is not None:
try: try:
self._viewer_3d.load_hull(hull) self._viewer_3d.load_hull(hull)
except Exception as exc: except Exception as exc:
logger.warning("Error al actualizar visor 3D: %s", exc) logger.warning("Error al actualizar visor 3D: %s", exc)
# Actualizar barra de hidrostáticos # Barra de hidrostáticos
self._update_hydrostatics(hull) self._update_hydrostatics(hull)
self.statusBar().showMessage(f"Geometría editada — {hull.name}") self.statusBar().showMessage(f"Geometría editada — {hull.name}")
+165 -3
View File
@@ -63,8 +63,10 @@ _CPT_HIT = 14.0 # px umbral de captura
class _BaseViewer(QWidget): class _BaseViewer(QWidget):
"""Widget base con zoom/paneo y edición de puntos de control.""" """Widget base con zoom/paneo y edición de puntos de control."""
# Emitido cuando el usuario arrastra un punto y suelta el botón # Emitido mientras el usuario arrastra (en cada mouseMoveEvent con drag)
offsets_edited = Signal(object) # OffsetsTable modificada 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: def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -78,18 +80,27 @@ class _BaseViewer(QWidget):
self._drag_idx: Optional[tuple[int, int]] = None self._drag_idx: Optional[tuple[int, int]] = None
self._drag_orig: float = 0.0 # valor antes del drag (para deshacer si se escapa) 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.setMouseTracking(True)
self.setCursor(Qt.CursorShape.ArrowCursor) self.setCursor(Qt.CursorShape.ArrowCursor)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# ─── API pública ────────────────────────────────────────────────────────── # ─── API pública ──────────────────────────────────────────────────────────
def set_hull(self, hull: Optional[Hull]) -> None: def set_hull(self, hull: Optional[Hull]) -> None:
"""Carga el casco y resetea zoom/pan al autofit (para carga inicial)."""
self._hull = hull self._hull = hull
self._hover_idx = None self._hover_idx = None
self._drag_idx = None self._drag_idx = None
self._fit_to_view() self._fit_to_view()
self.update() 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 ────────────────────────────────────────── # ─── Transform mundo ↔ pantalla ──────────────────────────────────────────
def _w2s(self, wx: float, wy: float) -> QPointF: def _w2s(self, wx: float, wy: float) -> QPointF:
@@ -124,6 +135,13 @@ class _BaseViewer(QWidget):
cy = ph / 2 - (wy0 + wh / 2) * self._scale cy = ph / 2 - (wy0 + wh / 2) * self._scale
self._offset = QPointF(cx, cy) 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]]: def _world_bbox(self) -> Optional[tuple[float, float, float, float]]:
return None # subclases return None # subclases
@@ -147,6 +165,7 @@ class _BaseViewer(QWidget):
self.update() self.update()
def mousePressEvent(self, event) -> None: def mousePressEvent(self, event) -> None:
self.setFocus() # captura el foco de teclado al hacer clic
btn = event.button() btn = event.button()
if btn == Qt.MouseButton.LeftButton and self._hull is not None: if btn == Qt.MouseButton.LeftButton and self._hull is not None:
idx = self._hit_test(event.position()) 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: if self._drag_idx is not None and self._hull is not None:
self._apply_drag(event.position(), self._drag_idx) self._apply_drag(event.position(), self._drag_idx)
self.update() self.update()
self.offsets_dragging.emit(self._hull.offsets) # live cross-view
return return
# ── Hover ───────────────────────────────────────────────────────── # ── Hover ─────────────────────────────────────────────────────────
@@ -231,6 +251,15 @@ class _BaseViewer(QWidget):
p.setFont(QFont("Monospace", 10)) p.setFont(QFont("Monospace", 10))
p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg) 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( def _draw_control_point(
self, self,
p: QPainter, p: QPainter,
@@ -377,6 +406,23 @@ class BodyPlanViewer(_BaseViewer):
for j in range(ot.n_waterlines): for j in range(ot.n_waterlines):
self._draw_control_point(p, self._screen_pt(i, j), (i, j)) 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") self._draw_label(p, "BODY PLAN")
p.end() p.end()
@@ -574,13 +620,129 @@ class PlanViewer(_BaseViewer):
for j in range(n_wl): for j in range(n_wl):
self._draw_control_point(p, self._screen_pt(i, j), (i, j)) 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") self._draw_label(p, "VISTA DE PLANTA")
p.end() p.end()
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Utilidad interna # Utilidades internas
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def _dist(a: QPointF, b: QPointF) -> float: def _dist(a: QPointF, b: QPointF) -> float:
return math.hypot(a.x() - b.x(), a.y() - b.y()) 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 mundopantalla 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)