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
+31 -4
View File
@@ -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.
+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"
)