""" 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: logger.error("Error al exportar offsets: %s", exc) QMessageBox.critical(self, "Error al exportar", "No se pudieron exportar los offsets. Consulte el log para más detalles.") 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: logger.error("Error al importar offsets: %s", exc) QMessageBox.critical(self, "Error al importar", "No se pudieron importar los offsets. Consulte el log para más detalles.") # ---------------------------------------------------------------------- # 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" )