446 lines
16 KiB
Python
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"
|
|
)
|