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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user