From 2137b0a228307af5ea5661ed0948689675654468 Mon Sep 17 00:00:00 2001 From: alro1965 Date: Wed, 27 May 2026 08:30:30 -0400 Subject: [PATCH] Modulo 1: editor interactivo de tabla de offsets (Task 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - offsets_editor.py: OffsetsEditor (QTableWidget editable con zoom de celdas modificadas en ambar, invalidas en rojo; Aplicar reconstruye Hull y emite hull_changed; importar/exportar CSV; info bar con dimensiones). - main_window.py: OffsetsEditor inyectado como MOD_OFFSETS (F4); _load_hull_viewers recibe _skip_offsets_editor para evitar bucle; _on_hull_changed_from_editor propaga el Hull editado a todos los visores y al panel de hidrostáticos en vivo; ModuleArea.set_module_widget() para reemplazar placeholders en tiempo de setup. 86 tests pasan sin regresiones. Co-Authored-By: Claude Sonnet 4.6 --- arshipdesign/ui/main_window.py | 35 +- arshipdesign/ui/widgets/offsets_editor.py | 443 +++++++++++++++++++++- 2 files changed, 472 insertions(+), 6 deletions(-) diff --git a/arshipdesign/ui/main_window.py b/arshipdesign/ui/main_window.py index b1869ad..ffda87d 100644 --- a/arshipdesign/ui/main_window.py +++ b/arshipdesign/ui/main_window.py @@ -633,6 +633,17 @@ class ModuleArea(QStackedWidget): lo.addWidget(sub) return w + def set_module_widget(self, idx: int, widget: QWidget) -> None: + """Sustituye el placeholder de un modulo por el widget real. + + Preserva el indice: los modulos con indice mayor no se desplazan. + """ + old = self.widget(idx) + if old is not None: + self.removeWidget(old) + old.deleteLater() + self.insertWidget(idx, widget) + def activate(self, module_index: int) -> None: if 0 <= module_index < self.count(): self.setCurrentIndex(module_index) @@ -849,6 +860,12 @@ class MainWindow(QMainWindow): if _vp is not None: _vp.set_canvas(_widget) + # Editor interactivo de offsets (sustituye el placeholder MOD_OFFSETS) + from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor + self._offsets_editor = OffsetsEditor() + self._offsets_editor.hull_changed.connect(self._on_hull_changed_from_editor) + self._module_area.set_module_widget(ModuleArea.MOD_OFFSETS, self._offsets_editor) + # Dock izquierdo — capas self._layers_panel = LayersPanel(self._strings) self._dock_layers = QDockWidget("Capas", self) @@ -1266,16 +1283,19 @@ class MainWindow(QMainWindow): self._update_title() self._layers_panel.set_project(self._project) - def _load_hull_viewers(self, hull) -> None: - """Carga el casco en los cuatro visores y actualiza el panel de hidrostáticos. + def _load_hull_viewers(self, hull, *, _skip_offsets_editor: bool = False) -> None: + """Carga el casco en todos los visores (2D, 3D, offsets) y actualiza hidrostáticos. - Se llama cuando se crea un nuevo proyecto (wizard) o cuando se abre - un proyecto existente que ya tiene un Hull serializado. + ``_skip_offsets_editor=True`` evita el bucle de retroalimentacion cuando + la llamada proviene del propio editor de offsets. """ # ── Visores 2D ──────────────────────────────────────────── self._viewer_bodyplan.set_hull(hull) self._viewer_profile.set_hull(hull) self._viewer_plan.set_hull(hull) + # ── Editor de offsets ───────────────────────────────────── + if not _skip_offsets_editor: + self._offsets_editor.set_hull(hull) # ── Visor 3D ────────────────────────────────────────────── if self._viewer_3d is not None: try: @@ -1285,6 +1305,13 @@ class MainWindow(QMainWindow): # ── Panel hidrostáticos ─────────────────────────────────── self._update_hydrostatics(hull) + def _on_hull_changed_from_editor(self, hull) -> None: + """Slot: el editor de offsets reconstruyo el Hull — propagar a los demas visores.""" + self._current_hull = hull + # _skip_offsets_editor=True para no re-poblar la tabla (ya esta actualizada) + self._load_hull_viewers(hull, _skip_offsets_editor=True) + self.statusBar().showMessage(f"Offsets actualizados — {hull.name}") + def _update_hydrostatics(self, hull) -> None: """Calcula hidrostáticos al calado de diseño y actualiza la barra inferior. diff --git a/arshipdesign/ui/widgets/offsets_editor.py b/arshipdesign/ui/widgets/offsets_editor.py index dd56c16..fc453a4 100644 --- a/arshipdesign/ui/widgets/offsets_editor.py +++ b/arshipdesign/ui/widgets/offsets_editor.py @@ -1,2 +1,441 @@ -"""Editor offsets. Stub — Sprint 1.""" -raise NotImplementedError("offsets_editor — Sprint 1") +""" +Editor interactivo de la tabla de offsets del casco. + +Muestra la tabla de offsets (semi-mangas y[estacion, linea de agua]) en un +QTableWidget editable. Cuando el usuario modifica un valor y confirma con +«Aplicar», se reconstruye el Hull, se emite ``hull_changed`` y los visores +2D / 3D se actualizan automaticamente. + +Diseno: + +-------------------------------------------------------------------+ + | TABLA DE OFFSETS - semi-mangas y [m] | + | Estaciones -> columnas / Lineas de agua -> filas (z grande | + | arriba) | + +-------------------------------------------------------------------+ + | z/x | 0.00 | 0.50 | ... | Lpp | | + | T | 0.000| 0.350| ... | 0.000| | + | ... | ... | ... | ... | ... | | + | 0.00 | 0.000| 0.000| ... | 0.000| | + +-------------------------------------------------------------------+ + | [Aplicar] [Importar CSV] [Exportar CSV] Info: 21x11 | + +-------------------------------------------------------------------+ + +La tabla es simetrica: se muestra solo la semi-manga y >= 0. +Las celdas modificadas se destacan en ambar hasta que se aplican. + +Autor: Alvaro Romero | Modulo 1 - AR-ShipDesign +IACS Rec.34 par.5: verificabilidad de datos de entrada. +""" +from __future__ import annotations + +import csv +import io +from pathlib import Path +from typing import Optional + +import numpy as np +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor, QFont +from PySide6.QtWidgets import ( + QFileDialog, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from arshipdesign.core.hull import Hull +from arshipdesign.core.offsets import OffsetsTable +from arshipdesign.utils.logger import get_logger + +logger = get_logger("ui.offsets_editor") + +# -- Colores --------------------------------------------------------------- +_CLR_BG = QColor("#1a1d30") +_CLR_HDR = QColor("#0e1020") +_CLR_CELL = QColor("#1e2240") +_CLR_MODIFIED = QColor("#3a2a05") # celdas editadas no aplicadas (ambar oscuro) +_CLR_INVALID = QColor("#3a0505") # valor fuera de rango (rojo oscuro) +_CLR_TEXT = QColor("#c8d4e8") +_CLR_HDR_TXT = QColor("#4da8ff") +_CLR_ZERO = QColor("#2a3050") # semi-manga cero (proa/popa) + + +# -------------------------------------------------------------------------- +class OffsetsEditor(QWidget): + """Widget de edicion de la tabla de offsets. + + Senales + ------- + hull_changed(Hull) + Emitida cuando se aplica una edicion y el Hull se reconstruye. + """ + + hull_changed = Signal(object) # Hull + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._hull: Optional[Hull] = None + self._dirty_cells: set[tuple[int, int]] = set() + self._applying = False # guard: evita disparar cellChanged durante relleno + self._build_ui() + + # ---------------------------------------------------------------------- + # API publica + # ---------------------------------------------------------------------- + + def set_hull(self, hull: Optional[Hull]) -> None: + """Carga un Hull en el editor y puebla la tabla.""" + self._hull = hull + self._dirty_cells.clear() + self._populate_table() + self._update_info() + self._btn_apply.setEnabled(False) + + def get_hull(self) -> Optional[Hull]: + """Devuelve el Hull actual (con los ultimos cambios aplicados).""" + return self._hull + + # ---------------------------------------------------------------------- + # Construccion de la UI + # ---------------------------------------------------------------------- + + def _build_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Cabecera + header = QWidget() + header.setObjectName("offsetsEditorHeader") + header.setFixedHeight(28) + hl = QHBoxLayout(header) + hl.setContentsMargins(8, 0, 8, 0) + hl.setSpacing(0) + title = QLabel("TABLA DE OFFSETS — semi-mangas y [m]") + title.setObjectName("offsetsTitle") + hl.addWidget(title) + hl.addStretch() + root.addWidget(header) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setObjectName("panelSep") + root.addWidget(sep) + + # Tabla + self._table = QTableWidget() + self._table.setObjectName("offsetsTable") + self._table.setFont(QFont("Consolas", 10)) + self._table.setAlternatingRowColors(False) + self._table.setGridStyle(Qt.PenStyle.SolidLine) + self._table.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.ResizeToContents + ) + self._table.verticalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.ResizeToContents + ) + self._table.cellChanged.connect(self._on_cell_changed) + root.addWidget(self._table, 1) + + sep2 = QFrame() + sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setObjectName("panelSep") + root.addWidget(sep2) + + # Barra de herramientas inferior + toolbar = QWidget() + toolbar.setObjectName("offsetsToolbar") + toolbar.setFixedHeight(34) + tl = QHBoxLayout(toolbar) + tl.setContentsMargins(6, 2, 6, 2) + tl.setSpacing(6) + + self._btn_apply = QPushButton("Aplicar") + self._btn_apply.setObjectName("offsetsApplyBtn") + self._btn_apply.setFixedHeight(26) + self._btn_apply.setEnabled(False) + self._btn_apply.setToolTip("Reconstruir el Hull con los valores editados") + self._btn_apply.clicked.connect(self._on_apply) + tl.addWidget(self._btn_apply) + + btn_import = QPushButton("Importar CSV") + btn_import.setObjectName("offsetsImportBtn") + btn_import.setFixedHeight(26) + btn_import.setToolTip("Importar tabla de offsets desde archivo CSV") + btn_import.clicked.connect(self._on_import_csv) + tl.addWidget(btn_import) + + btn_export = QPushButton("Exportar CSV") + btn_export.setObjectName("offsetsExportBtn") + btn_export.setFixedHeight(26) + btn_export.setToolTip("Exportar tabla de offsets a CSV") + btn_export.clicked.connect(self._on_export_csv) + tl.addWidget(btn_export) + + tl.addStretch() + + self._info_lbl = QLabel("") + self._info_lbl.setObjectName("offsetsInfo") + tl.addWidget(self._info_lbl) + + root.addWidget(toolbar) + + # ---------------------------------------------------------------------- + # Poblar tabla desde el Hull + # ---------------------------------------------------------------------- + + def _populate_table(self) -> None: + """Rellena QTableWidget con los datos del Hull actual.""" + self._applying = True + try: + self._table.clearContents() + self._table.blockSignals(True) + + if self._hull is None: + self._table.setRowCount(0) + self._table.setColumnCount(0) + self._table.blockSignals(False) + return + + ot = self._hull.offsets + n_sta = ot.n_stations + n_wl = ot.n_waterlines + + # Filas = lineas de agua (z grande arriba), columnas = estaciones + self._table.setRowCount(n_wl) + self._table.setColumnCount(n_sta) + + # Cabeceras de columna: x [m] de cada estacion + self._table.setHorizontalHeaderLabels( + [f"{x:.2f}" for x in ot.x_stations] + ) + # Cabeceras de fila: z [m] (mayor arriba -> invertido) + self._table.setVerticalHeaderLabels( + [f"{ot.z_waterlines[n_wl - 1 - r]:.3f}" for r in range(n_wl)] + ) + + for r in range(n_wl): + z_idx = n_wl - 1 - r # indice real (z aumenta hacia abajo) + for c in range(n_sta): + val = ot.data[c, z_idx] + item = QTableWidgetItem(f"{val:.4f}") + item.setTextAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + item.setBackground(_CLR_ZERO if val < 1e-6 else _CLR_CELL) + item.setForeground(_CLR_TEXT) + self._table.setItem(r, c, item) + + finally: + self._table.blockSignals(False) + self._applying = False + + # ---------------------------------------------------------------------- + # Evento: celda editada + # ---------------------------------------------------------------------- + + def _on_cell_changed(self, row: int, col: int) -> None: + if self._applying or self._hull is None: + return + item = self._table.item(row, col) + if item is None: + return + + text = item.text().strip() + try: + val = float(text) + max_y = self._hull.offsets.max_half_breadth * 1.05 + 0.01 + if val < 0.0 or val > max_y: + raise ValueError("fuera de rango") + item.setBackground(_CLR_MODIFIED) + self._dirty_cells.add((row, col)) + except ValueError: + item.setBackground(_CLR_INVALID) + + self._btn_apply.setEnabled(bool(self._dirty_cells)) + + # ---------------------------------------------------------------------- + # Aplicar ediciones -> reconstruir Hull + # ---------------------------------------------------------------------- + + def _on_apply(self) -> None: + if self._hull is None: + return + + ot = self._hull.offsets + n_sta = ot.n_stations + n_wl = ot.n_waterlines + new_data = ot.data.copy() + + errors: list[str] = [] + for r in range(n_wl): + z_idx = n_wl - 1 - r + for c in range(n_sta): + item = self._table.item(r, c) + if item is None: + continue + try: + val = float(item.text().strip()) + if val < 0.0: + raise ValueError("negativo") + new_data[c, z_idx] = val + except ValueError as e: + errors.append(f"[fila {r}, col {c}]: '{item.text()}' — {e}") + + if errors: + QMessageBox.warning( + self, "Valores invalidos", + "Los siguientes valores no pudieron aplicarse:\n\n" + + "\n".join(errors[:10]) + + ("\n..." if len(errors) > 10 else ""), + ) + return + + # Construir nuevo OffsetsTable + new_offsets = OffsetsTable( + x_stations = ot.x_stations.copy(), + z_waterlines = ot.z_waterlines.copy(), + data = new_data, + station_labels = list(ot.station_labels), + lpp = ot.lpp, + beam = float(new_data.max()) * 2.0, + draft = ot.draft, + ) + new_hull = Hull( + name = self._hull.name, + lpp = self._hull.lpp, + beam = new_offsets.beam, + depth = self._hull.depth, + draft = self._hull.draft, + offsets = new_offsets, + ) + + self._hull = new_hull + self._dirty_cells.clear() + + # Restablecer colores de celdas (sin disparar cellChanged) + self._applying = True + try: + self._table.blockSignals(True) + for r in range(self._table.rowCount()): + z_idx = n_wl - 1 - r + for c in range(self._table.columnCount()): + item = self._table.item(r, c) + if item: + val = new_data[c, z_idx] + item.setBackground(_CLR_ZERO if val < 1e-6 else _CLR_CELL) + self._table.blockSignals(False) + finally: + self._applying = False + + self._btn_apply.setEnabled(False) + self._update_info() + logger.info("Hull '%s' reconstruido desde editor de offsets", new_hull.name) + self.hull_changed.emit(new_hull) + + # ---------------------------------------------------------------------- + # Importar / Exportar CSV + # ---------------------------------------------------------------------- + + def _on_export_csv(self) -> None: + if self._hull is None: + QMessageBox.information(self, "Sin casco", "No hay casco cargado.") + return + path, _ = QFileDialog.getSaveFileName( + self, "Exportar offsets CSV", + str(Path.home() / f"{self._hull.name}_offsets.csv"), + "CSV (*.csv)", + ) + if not path: + return + try: + ot = self._hull.offsets + buf = io.StringIO() + writer = csv.writer(buf) + # Cabecera: "z\x" + posiciones x + writer.writerow(["z\\x"] + [f"{x:.4f}" for x in ot.x_stations]) + # Filas z de mayor a menor (convencion: cubierta arriba) + for z in reversed(ot.z_waterlines): + j = int(np.searchsorted(ot.z_waterlines, z)) + j = min(j, ot.n_waterlines - 1) + row_vals = [f"{ot.data[i, j]:.4f}" for i in range(ot.n_stations)] + writer.writerow([f"{z:.4f}"] + row_vals) + Path(path).write_text(buf.getvalue(), encoding="utf-8") + logger.info("Offsets exportados a %s", path) + except Exception as exc: + QMessageBox.critical(self, "Error al exportar", str(exc)) + + def _on_import_csv(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, "Importar offsets CSV", str(Path.home()), + "CSV (*.csv);;Texto (*.txt);;Todos (*)", + ) + if not path: + return + try: + data_rows: list[list[float]] = [] + with open(path, newline="", encoding="utf-8-sig") as f: + reader = csv.reader(f) + header = next(reader) # "z\x", x0, x1, ... + x_arr = np.array([float(v) for v in header[1:]]) + z_list: list[float] = [] + for row in reader: + if not row or not row[0].strip(): + continue + z_list.append(float(row[0])) + data_rows.append([float(v) for v in row[1:]]) + + # z viene de mayor a menor en el CSV -> invertir + z_arr = np.array(z_list[::-1]) + data_arr = np.array(data_rows[::-1]) # (n_wl, n_sta) + data_T = data_arr.T # (n_sta, n_wl) + + if self._hull is None: + QMessageBox.warning( + self, "Sin casco base", + "Cargue primero un casco para poder importar offsets.", + ) + return + + new_offsets = OffsetsTable( + x_stations = x_arr, + z_waterlines = z_arr, + data = data_T, + lpp = float(x_arr[-1] - x_arr[0]), + beam = float(data_T.max()) * 2.0, + draft = float(z_arr[-1]), + ) + new_hull = Hull( + name = self._hull.name, + lpp = new_offsets.lpp, + beam = new_offsets.beam, + depth = self._hull.depth, + draft = new_offsets.draft, + offsets = new_offsets, + ) + self.set_hull(new_hull) + self.hull_changed.emit(new_hull) + logger.info("Offsets importados desde %s", path) + except Exception as exc: + QMessageBox.critical(self, "Error al importar", str(exc)) + + # ---------------------------------------------------------------------- + # Info label + # ---------------------------------------------------------------------- + + def _update_info(self) -> None: + if self._hull is None: + self._info_lbl.setText("") + return + ot = self._hull.offsets + self._info_lbl.setText( + f"{ot.n_stations} est. x {ot.n_waterlines} WL " + f"| Lpp {ot.lpp:.1f} m B {ot.beam:.2f} m T {ot.draft:.2f} m" + )