Files
AR-Shipdesign/arshipdesign/ui/widgets/offsets_editor.py
T

446 lines
16 KiB
Python

"""
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"
)