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:
|
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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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 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