Modulo 1: editor interactivo de tabla de offsets (Task 10)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 08:30:30 -04:00
parent bdfd5ac4ca
commit 2137b0a228
2 changed files with 472 additions and 6 deletions
+441 -2
View File
@@ -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"
)