diff --git a/arshipdesign/ui/main_window.py b/arshipdesign/ui/main_window.py index 5d46271..527064b 100644 --- a/arshipdesign/ui/main_window.py +++ b/arshipdesign/ui/main_window.py @@ -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}") diff --git a/arshipdesign/ui/widgets/viewer_lines.py b/arshipdesign/ui/widgets/viewer_lines.py index a58cc24..744abdb 100644 --- a/arshipdesign/ui/widgets/viewer_lines.py +++ b/arshipdesign/ui/widgets/viewer_lines.py @@ -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)