0f85935fc8
- gz_integrator.py: GZCurve, GZPoint, compute_gz_wall_sided (fórmula pared lateral), compute_gz_direct (integración Sutherland-Hodgman) - imo_is2008.py: IMOCriterion, IMOResult, check_imo_is2008 — 6 criterios A.2.1.1–A.2.1.6 del IS Code 2008 Cap.2 - gz_curve_widget.py: GZCurveWidget QPainter — curva cian, áreas sombreadas, líneas IMO, marcador AVS, tabla PASS/FAIL integrada - main_window.py: GZCurveWidget en MOD_STABILITY, _compute_and_show_gz, _on_show_stability conectado al ribbon - dark.qss: estilos GZCurveWidget - test_module3_stability.py: 33 tests S-01..S-28 (315 total, todos pasan) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1623 lines
74 KiB
Python
1623 lines
74 KiB
Python
"""
|
||
Ventana principal de AR-ShipDesign.
|
||
|
||
Layout estilo DELFTship / Maxsurf:
|
||
- Ribbon bar con pestañas funcionales
|
||
(Home / Geometría / Análisis / Tanques / Sistemas / Fabricación)
|
||
- Dock izquierdo: panel de capas (estilo DELFTship)
|
||
- Área central: 4 viewports por defecto
|
||
(Perspectiva 3D · Profile · Body Plan · Vista de Planta)
|
||
- Dock derecho: propiedades del elemento seleccionado
|
||
- Panel inferior fijo: hidrostáticos en vivo (36 px)
|
||
- Barra de estado
|
||
|
||
Los módulos de análisis se activan desde el menú o el ribbon y
|
||
reemplazan el área central temporalmente.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from PySide6.QtCore import Qt, QSize, Signal
|
||
from PySide6.QtGui import QAction, QFont, QKeySequence, QIcon
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QDockWidget,
|
||
QFileDialog,
|
||
QFrame,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QMainWindow,
|
||
QMessageBox,
|
||
QScrollArea,
|
||
QSizePolicy,
|
||
QSplitter,
|
||
QStackedWidget,
|
||
QToolBar,
|
||
QToolButton,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
QStyle,
|
||
QAbstractItemView,
|
||
)
|
||
|
||
from arshipdesign import __version__
|
||
from arshipdesign.core.project import Project
|
||
from arshipdesign.utils.logger import get_logger
|
||
from arshipdesign.stability import compute_gz_wall_sided, GZCurve, check_imo_is2008
|
||
from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget
|
||
from arshipdesign.utils.settings import (
|
||
add_recent_file,
|
||
get_language,
|
||
get_recent_files,
|
||
get_settings,
|
||
get_theme,
|
||
set_theme,
|
||
)
|
||
|
||
logger = get_logger("ui.main_window")
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Utilidades
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _load_i18n(lang: str = "es") -> dict:
|
||
path = Path(__file__).parent / "i18n" / f"{lang}.json"
|
||
if not path.exists():
|
||
path = Path(__file__).parent / "i18n" / "es.json"
|
||
try:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def _spi(sp) -> QIcon:
|
||
"""Icono estándar del sistema."""
|
||
return QApplication.style().standardIcon(sp)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# PANEL DE HIDROSTÁTICOS
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class HydrostaticsPanel(QFrame):
|
||
"""
|
||
Barra de hidrostáticos en vivo — siempre visible en la parte inferior.
|
||
Se conecta al motor de cálculo en Sprint 2.
|
||
"""
|
||
|
||
def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self.strings = strings
|
||
self.setObjectName("hydrostaticsPanel")
|
||
self.setFixedHeight(36)
|
||
self._build_ui()
|
||
|
||
def _build_ui(self) -> None:
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(10, 0, 10, 0)
|
||
layout.setSpacing(0)
|
||
|
||
title = QLabel("HIDROSTÁTICOS")
|
||
title.setObjectName("hydroTitle")
|
||
layout.addWidget(title)
|
||
layout.addWidget(self._sep())
|
||
|
||
self._fields: dict[str, QLabel] = {}
|
||
mono = QFont("Consolas", 11)
|
||
|
||
items = [
|
||
("T", "—", "Calado [m]"),
|
||
("Δ", "—", "Desplazamiento [t]"),
|
||
("LCB", "—", "Centro Longitudinal de Carena [m desde AP]"),
|
||
("KB", "—", "Centro Vertical de Carena [m]"),
|
||
("KMT", "—", "Altura Metacéntrica Transversal [m]"),
|
||
("GMT", "—", "Altura Metacéntrica Corregida [m]"),
|
||
("TPC", "—", "Toneladas por cm de Inmersión"),
|
||
("MCT", "—", "Momento para Cambiar Asiento 1 cm [t·m/cm]"),
|
||
("Cb", "—", "Coeficiente de Bloque"),
|
||
("Cw", "—", "Coeficiente de Plano de Flotación"),
|
||
("Cm", "—", "Coeficiente de Cuaderna Maestra"),
|
||
]
|
||
|
||
for key, default, tip in items:
|
||
k = QLabel(f" {key} ")
|
||
k.setObjectName("hydroKey")
|
||
k.setToolTip(tip)
|
||
v = QLabel(default)
|
||
v.setObjectName("hydroVal")
|
||
v.setFont(mono)
|
||
v.setToolTip(tip)
|
||
v.setMinimumWidth(52)
|
||
self._fields[key] = v
|
||
layout.addWidget(k)
|
||
layout.addWidget(v)
|
||
layout.addWidget(self._sep())
|
||
|
||
imo_k = QLabel(" IMO ")
|
||
imo_k.setObjectName("hydroKey")
|
||
self._imo = QLabel("SIN DATOS")
|
||
self._imo.setObjectName("hydroImoNone")
|
||
self._imo.setFont(mono)
|
||
self._imo.setToolTip("Cumplimiento IMO IS Code 2008")
|
||
layout.addWidget(imo_k)
|
||
layout.addWidget(self._imo)
|
||
layout.addStretch()
|
||
|
||
@staticmethod
|
||
def _sep() -> QFrame:
|
||
f = QFrame()
|
||
f.setFrameShape(QFrame.Shape.VLine)
|
||
f.setObjectName("hydroSep")
|
||
f.setFixedWidth(1)
|
||
return f
|
||
|
||
def update_values(self, values: dict[str, str]) -> None:
|
||
for key, val in values.items():
|
||
if key in self._fields:
|
||
self._fields[key].setText(val)
|
||
|
||
def set_imo_status(self, ok: bool, detail: str = "") -> None:
|
||
if ok:
|
||
self._imo.setText("CUMPLE")
|
||
self._imo.setObjectName("hydroImoOk")
|
||
else:
|
||
self._imo.setText(f"FALLA {detail}".strip())
|
||
self._imo.setObjectName("hydroImoFail")
|
||
self._imo.style().polish(self._imo)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# VIEWPORTS — 4 vistas estilo Maxsurf / DELFTship
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
_VIEW_LABELS: dict[str, str] = {
|
||
"perspective": "Perspectiva 3D",
|
||
"profile": "Vista Lateral — Perfil",
|
||
"bodyplan": "Body Plan — Secciones",
|
||
"plan": "Vista de Planta",
|
||
}
|
||
|
||
|
||
class ViewportFrame(QFrame):
|
||
"""Un viewport individual con barra de título."""
|
||
|
||
def __init__(self, view_type: str, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self.view_type = view_type
|
||
self.setObjectName("viewportFrame")
|
||
self._build_ui()
|
||
|
||
def _build_ui(self) -> None:
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
|
||
# ── Barra de título (objectName único por vista) ──────────
|
||
title_bar = QWidget()
|
||
# p.ej. "viewportTitleBar_perspective", "viewportTitleBar_profile"…
|
||
title_bar.setObjectName(f"viewportTitleBar_{self.view_type}")
|
||
title_bar.setFixedHeight(24)
|
||
tbl = QHBoxLayout(title_bar)
|
||
tbl.setContentsMargins(10, 0, 4, 0)
|
||
tbl.setSpacing(0)
|
||
|
||
lbl = QLabel(_VIEW_LABELS.get(self.view_type, self.view_type).upper())
|
||
lbl.setObjectName(f"viewportTitle_{self.view_type}")
|
||
tbl.addWidget(lbl)
|
||
tbl.addStretch()
|
||
layout.addWidget(title_bar)
|
||
|
||
# ── Área de dibujo (placeholder Sprint 0) ────────────────
|
||
self._canvas = QWidget()
|
||
self._canvas.setObjectName(f"viewportCanvas_{self.view_type}")
|
||
cl = QVBoxLayout(self._canvas)
|
||
cl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
ph = QLabel(_VIEW_LABELS.get(self.view_type, "").upper())
|
||
ph.setObjectName("viewportPlaceholder")
|
||
ph.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
cl.addWidget(ph)
|
||
layout.addWidget(self._canvas, 1)
|
||
|
||
def set_canvas(self, widget: QWidget) -> None:
|
||
"""Sprint 1: sustituye el placeholder por el widget 3D / 2D real."""
|
||
lo = self.layout()
|
||
if lo.count() > 1:
|
||
old = lo.takeAt(1).widget()
|
||
if old:
|
||
old.deleteLater()
|
||
lo.addWidget(widget, 1)
|
||
self._canvas = widget
|
||
|
||
|
||
class FourViewport(QWidget):
|
||
"""
|
||
Layout de 4 viewports con separadores arrastrables.
|
||
Topología:
|
||
┌─────────────┬──────────────┐
|
||
│ Perspectiva │ Perf. Lateral│
|
||
├─────────────┼──────────────┤
|
||
│ Body Plan │ Vista Planta│
|
||
└─────────────┴──────────────┘
|
||
"""
|
||
|
||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("fourViewport")
|
||
self._build_ui()
|
||
|
||
def _build_ui(self) -> None:
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
|
||
v_split = QSplitter(Qt.Orientation.Vertical)
|
||
v_split.setObjectName("viewportSplitter")
|
||
v_split.setHandleWidth(5)
|
||
|
||
top_split = QSplitter(Qt.Orientation.Horizontal)
|
||
top_split.setObjectName("viewportSplitter")
|
||
top_split.setHandleWidth(5)
|
||
self._vp_perspective = ViewportFrame("perspective")
|
||
self._vp_profile = ViewportFrame("profile")
|
||
top_split.addWidget(self._vp_perspective)
|
||
top_split.addWidget(self._vp_profile)
|
||
top_split.setSizes([600, 600])
|
||
|
||
bot_split = QSplitter(Qt.Orientation.Horizontal)
|
||
bot_split.setObjectName("viewportSplitter")
|
||
bot_split.setHandleWidth(5)
|
||
self._vp_bodyplan = ViewportFrame("bodyplan")
|
||
self._vp_plan = ViewportFrame("plan")
|
||
bot_split.addWidget(self._vp_bodyplan)
|
||
bot_split.addWidget(self._vp_plan)
|
||
bot_split.setSizes([600, 600])
|
||
|
||
v_split.addWidget(top_split)
|
||
v_split.addWidget(bot_split)
|
||
v_split.setSizes([400, 400])
|
||
layout.addWidget(v_split)
|
||
|
||
def viewport(self, view_type: str) -> Optional[ViewportFrame]:
|
||
return {
|
||
"perspective": self._vp_perspective,
|
||
"profile": self._vp_profile,
|
||
"bodyplan": self._vp_bodyplan,
|
||
"plan": self._vp_plan,
|
||
}.get(view_type)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# PANEL DE CAPAS (estilo DELFTship)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
_LAYER_PRESETS: list[tuple[str, str, bool, bool]] = [
|
||
# (nombre, color, visible, bloqueado)
|
||
("Casco", "#4a9eff", True, False),
|
||
("Superestructura", "#6ee7b7", True, False),
|
||
("Apéndices", "#fbbf24", True, False),
|
||
("Cubierta", "#f87171", True, False),
|
||
("Interiores", "#a78bfa", False, False),
|
||
("Tanques", "#34d399", True, False),
|
||
("Estructuras", "#fb923c", False, False),
|
||
("Líneas de ref.", "#94a3b8", True, False),
|
||
("Moldes FRP", "#e879f9", False, False),
|
||
("CNC Corte", "#e2e8f0", False, False),
|
||
]
|
||
|
||
|
||
class LayerRow(QWidget):
|
||
"""Fila de una capa: ojo de visibilidad · candado · muestra de color · nombre."""
|
||
|
||
visibility_toggled = Signal(str, bool)
|
||
lock_toggled = Signal(str, bool)
|
||
|
||
def __init__(
|
||
self,
|
||
name: str,
|
||
color: str = "#4a9eff",
|
||
visible: bool = True,
|
||
locked: bool = False,
|
||
parent: Optional[QWidget] = None,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.layer_name = name
|
||
self.setObjectName("layerRow")
|
||
self._build_ui(name, color, visible, locked)
|
||
|
||
def _build_ui(self, name: str, color: str, visible: bool, locked: bool) -> None:
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(4, 1, 4, 1)
|
||
layout.setSpacing(3)
|
||
|
||
self._vis_btn = QToolButton()
|
||
self._vis_btn.setObjectName("layerVisBtn")
|
||
self._vis_btn.setCheckable(True)
|
||
self._vis_btn.setChecked(visible)
|
||
self._vis_btn.setFixedSize(16, 16)
|
||
self._vis_btn.setToolTip("Mostrar / Ocultar capa")
|
||
self._vis_btn.clicked.connect(lambda v: self.visibility_toggled.emit(name, v))
|
||
|
||
self._lock_btn = QToolButton()
|
||
self._lock_btn.setObjectName("layerLockBtn")
|
||
self._lock_btn.setCheckable(True)
|
||
self._lock_btn.setChecked(locked)
|
||
self._lock_btn.setFixedSize(16, 16)
|
||
self._lock_btn.setToolTip("Bloquear / Desbloquear capa")
|
||
self._lock_btn.clicked.connect(lambda v: self.lock_toggled.emit(name, v))
|
||
|
||
swatch = QLabel()
|
||
swatch.setObjectName("layerColorSwatch")
|
||
swatch.setFixedSize(12, 12)
|
||
swatch.setStyleSheet(
|
||
f"background-color:{color};border:1px solid #3a3f4b;border-radius:2px;"
|
||
)
|
||
|
||
lbl = QLabel(name)
|
||
lbl.setObjectName("layerName")
|
||
|
||
layout.addWidget(self._vis_btn)
|
||
layout.addWidget(self._lock_btn)
|
||
layout.addWidget(swatch)
|
||
layout.addWidget(lbl)
|
||
layout.addStretch()
|
||
|
||
|
||
class LayersPanel(QWidget):
|
||
"""Panel de capas — dock izquierdo (estilo DELFTship)."""
|
||
|
||
layer_selected = Signal(str)
|
||
|
||
def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self.strings = strings
|
||
self.setObjectName("layersPanel")
|
||
self.setMinimumWidth(175)
|
||
self._build_ui()
|
||
|
||
def _build_ui(self) -> None:
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
|
||
# ── Cabecera ─────────────────────────────────────────────
|
||
header = QWidget()
|
||
header.setObjectName("layersPanelHeader")
|
||
header.setFixedHeight(28)
|
||
hl = QHBoxLayout(header)
|
||
hl.setContentsMargins(4, 2, 4, 2)
|
||
hl.setSpacing(2)
|
||
|
||
sp = QStyle.StandardPixmap
|
||
for icon_sp, tip in (
|
||
(sp.SP_FileIcon, "Nueva capa"),
|
||
(sp.SP_DialogSaveButton, "Duplicar capa"),
|
||
):
|
||
btn = QToolButton()
|
||
btn.setIcon(_spi(icon_sp))
|
||
btn.setFixedSize(22, 22)
|
||
btn.setToolTip(tip)
|
||
btn.setEnabled(False)
|
||
hl.addWidget(btn)
|
||
hl.addStretch()
|
||
layout.addWidget(header)
|
||
|
||
sep = QFrame()
|
||
sep.setFrameShape(QFrame.Shape.HLine)
|
||
sep.setObjectName("panelSep")
|
||
layout.addWidget(sep)
|
||
|
||
# ── Lista de capas ────────────────────────────────────────
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
scroll.setObjectName("layersScrollArea")
|
||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
|
||
container = QWidget()
|
||
container.setObjectName("layersContainer")
|
||
self._layer_layout = QVBoxLayout(container)
|
||
self._layer_layout.setContentsMargins(0, 0, 0, 0)
|
||
self._layer_layout.setSpacing(0)
|
||
|
||
for name, color, visible, locked in _LAYER_PRESETS:
|
||
row = LayerRow(name, color, visible, locked)
|
||
row.visibility_toggled.connect(
|
||
lambda n, v: logger.debug("Capa '%s' visibilidad=%s", n, v)
|
||
)
|
||
self._layer_layout.addWidget(row)
|
||
|
||
self._layer_layout.addStretch()
|
||
scroll.setWidget(container)
|
||
layout.addWidget(scroll, 1)
|
||
|
||
def set_project(self, project: Project) -> None:
|
||
"""Sprint 1: recargar capas desde los datos reales del proyecto."""
|
||
pass
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# PANEL DE PROPIEDADES
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class PropertiesPanel(QWidget):
|
||
"""Propiedades del elemento seleccionado — dock derecho."""
|
||
|
||
def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self.strings = strings
|
||
self.setObjectName("propertiesPanel")
|
||
self.setMinimumWidth(200)
|
||
self._build_ui()
|
||
|
||
def _build_ui(self) -> None:
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
content = QWidget()
|
||
content.setObjectName("propContent")
|
||
cl = QVBoxLayout(content)
|
||
cl.setContentsMargins(10, 10, 10, 10)
|
||
cl.setSpacing(4)
|
||
|
||
sec = QLabel("Dimensiones principales")
|
||
sec.setObjectName("propSection")
|
||
cl.addWidget(sec)
|
||
|
||
mono = QFont("Consolas", 11)
|
||
for lbl_text, tip in [
|
||
("LOA", "Eslora total [m]"),
|
||
("LPP", "Eslora entre perpendiculares [m]"),
|
||
("B", "Manga [m]"),
|
||
("T", "Calado [m]"),
|
||
("D", "Puntal [m]"),
|
||
("Δ", "Desplazamiento [t]"),
|
||
("GMT", "Altura metacéntrica [m]"),
|
||
]:
|
||
row = QHBoxLayout()
|
||
row.setSpacing(8)
|
||
k = QLabel(lbl_text)
|
||
k.setObjectName("propKey")
|
||
k.setFixedWidth(40)
|
||
k.setToolTip(tip)
|
||
v = QLabel("—")
|
||
v.setObjectName("propVal")
|
||
v.setFont(mono)
|
||
v.setToolTip(tip)
|
||
row.addWidget(k)
|
||
row.addWidget(v)
|
||
row.addStretch()
|
||
cl.addLayout(row)
|
||
|
||
cl.addStretch()
|
||
layout.addWidget(content)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# ÁREA CENTRAL — módulo activo
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class ModuleArea(QStackedWidget):
|
||
"""
|
||
Área central con un módulo visible a la vez.
|
||
Índice 0 → FourViewport (vista de modelado estándar)
|
||
Índices 1..N → módulos de análisis / fabricación en pantalla completa
|
||
"""
|
||
|
||
# Índices de módulos
|
||
MOD_4VP = 0 # 4 viewports — vista de modelado (por defecto)
|
||
MOD_LINES = 1
|
||
MOD_OFFSETS = 2
|
||
MOD_CURVES = 3
|
||
MOD_TANKS = 4
|
||
MOD_CAPACITY = 5
|
||
MOD_STABILITY = 6
|
||
MOD_RESISTANCE = 7
|
||
MOD_PROPULSION = 8
|
||
MOD_VPP = 9
|
||
MOD_SEAKEEPING = 10
|
||
MOD_ELECTRICAL = 11
|
||
MOD_FUEL = 12
|
||
MOD_FRESHWATER = 13
|
||
MOD_BILGE = 14
|
||
MOD_FIREFIGHT = 15
|
||
MOD_HVAC = 16
|
||
MOD_SCANTLING = 17
|
||
MOD_CNC = 18
|
||
MOD_MOLDS = 19
|
||
MOD_REPORT = 20
|
||
MOD_ROUTING_PIPES = 21
|
||
MOD_ROUTING_CABLES = 22
|
||
|
||
# (nombre, descripción) — None en índice 0 (FourViewport manejado aparte)
|
||
_MODULE_INFO: list[Optional[tuple[str, str]]] = [
|
||
None,
|
||
("Plano de Líneas",
|
||
"Visualización del plano de líneas tradicional (body plan, profile, plan)."),
|
||
("Tabla de Offsets",
|
||
"Editor de offsets de secciones transversales."),
|
||
("Curvas Hidrostáticas",
|
||
"Gráficas de hidrostáticos en función del calado."),
|
||
("Modelado de Tanques",
|
||
"Definición geométrica de tanques y sentinas NURBS."),
|
||
("Plan de Capacidad",
|
||
"Tablas de sondeo y plan de capacidad de tanques."),
|
||
("Estabilidad GZ",
|
||
"Curva de brazos adrizantes GZ · Criterios IMO IS Code 2008."),
|
||
("Resistencia al Avance",
|
||
"Métodos: Holtrop & Mennen · Savitsky · DSYHS · Hollenbach."),
|
||
("Propulsión",
|
||
"Hélice de paso variable / fijo — Wageningen B-series."),
|
||
("VPP Velero",
|
||
"Programa Velocidad-Potencia polar para veleros (DSYHS / ORC)."),
|
||
("Seakeeping — Movimientos",
|
||
"Strip Theory (Salvesen-Tuck-Faltinsen). Espectros de respuesta."),
|
||
("Balance Eléctrico (EPLA)",
|
||
"Lista de consumidores eléctricos y cálculo de generación."),
|
||
("Sistema de Combustible",
|
||
"Diagrama de tuberías de combustible y cálculo de consumo."),
|
||
("Sistema de Agua Dulce",
|
||
"Balance hídrico, tanques de agua dulce, producción."),
|
||
("Sistema de Achique",
|
||
"Cálculo de bombas, diámetros de tuberías, caudal."),
|
||
("Sistema Contra Incendios",
|
||
"Detección, supresión y cumplimiento SOLAS."),
|
||
("HVAC",
|
||
"Sistema de climatización, renovación de aire, ventilación."),
|
||
("Escantillado ISO 12215-5",
|
||
"Espesores de paneles, cuadernas y estructuras según ISO 12215."),
|
||
("Fabricación CNC — Nesting",
|
||
"Optimización de corte de planchas, generación de G-code."),
|
||
("Moldes FRP",
|
||
"Lofting de moldes, schedule de laminado, BOM de materiales."),
|
||
("Generador de Reportes",
|
||
"Exporta hidrostáticos, estabilidad, escantillado a PDF / Excel."),
|
||
("Routing de Tuberías 3D",
|
||
"Trazado de tuberías en 3D dentro del casco "
|
||
"con cálculo hidráulico e isométricos automáticos."),
|
||
("Routing de Cableados 3D",
|
||
"Trazado de bandejas y cables eléctricos con gestión "
|
||
"de conductores y cálculo de caídas de tensión."),
|
||
]
|
||
|
||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
|
||
# Página 0: 4 viewports
|
||
self._four_vp = FourViewport()
|
||
self.addWidget(self._four_vp)
|
||
|
||
# Páginas 1..N: módulos de análisis/fabricación
|
||
for info in self._MODULE_INFO[1:]:
|
||
if info:
|
||
name, desc = info
|
||
self.addWidget(self._make_placeholder(name, desc))
|
||
|
||
self.setCurrentIndex(self.MOD_4VP)
|
||
|
||
@property
|
||
def four_viewport(self) -> FourViewport:
|
||
return self._four_vp
|
||
|
||
@staticmethod
|
||
def _make_placeholder(name: str, desc: str = "") -> QWidget:
|
||
w = QWidget()
|
||
w.setObjectName("modulePlaceholder")
|
||
lo = QVBoxLayout(w)
|
||
lo.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
lo.setSpacing(10)
|
||
|
||
title = QLabel(name)
|
||
title.setObjectName("placeholderTitle")
|
||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
f = title.font()
|
||
f.setPointSize(16)
|
||
f.setBold(True)
|
||
title.setFont(f)
|
||
lo.addWidget(title)
|
||
|
||
if desc:
|
||
desc_lbl = QLabel(desc)
|
||
desc_lbl.setObjectName("placeholderDesc")
|
||
desc_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
desc_lbl.setWordWrap(True)
|
||
desc_lbl.setMaximumWidth(520)
|
||
lo.addWidget(desc_lbl)
|
||
|
||
sub = QLabel("Módulo en desarrollo — se implementará en el sprint correspondiente.")
|
||
sub.setObjectName("placeholderMsg")
|
||
sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
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)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# RIBBON BAR
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
_RIBBON_TABS = [
|
||
"Home", "Geometría", "Análisis", "Tanques", "Sistemas", "Fabricación",
|
||
]
|
||
|
||
|
||
class RibbonGroup(QFrame):
|
||
"""Un grupo de botones de la ribbon con etiqueta de categoría."""
|
||
|
||
def __init__(self, title: str, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("ribbonGroup")
|
||
|
||
lo = QVBoxLayout(self)
|
||
lo.setContentsMargins(3, 2, 3, 0)
|
||
lo.setSpacing(0)
|
||
|
||
self._btn_row = QWidget()
|
||
self._btn_row.setObjectName("ribbonBtnArea")
|
||
self._btn_lo = QHBoxLayout(self._btn_row)
|
||
self._btn_lo.setContentsMargins(0, 0, 0, 0)
|
||
self._btn_lo.setSpacing(2)
|
||
|
||
lbl = QLabel(title)
|
||
lbl.setObjectName("ribbonGroupTitle")
|
||
lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||
|
||
lo.addWidget(self._btn_row)
|
||
lo.addWidget(lbl)
|
||
|
||
def add_button(
|
||
self,
|
||
icon: QIcon,
|
||
text: str,
|
||
tooltip: str,
|
||
slot=None,
|
||
enabled: bool = True,
|
||
) -> QToolButton:
|
||
btn = QToolButton()
|
||
btn.setObjectName("ribbonButton")
|
||
btn.setIcon(icon)
|
||
btn.setText(text)
|
||
btn.setToolTip(tooltip)
|
||
btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
|
||
btn.setIconSize(QSize(22, 22))
|
||
btn.setEnabled(enabled)
|
||
if slot:
|
||
btn.clicked.connect(slot)
|
||
|
||
# Forzar ancho mínimo basado en el texto para que nunca se parta en 2 líneas.
|
||
# Qt calcula el ancho por el ícono (22 px), no por el texto →
|
||
# "Nuevo Tq." se rompe y la segunda línea queda cortada.
|
||
fm = btn.fontMetrics()
|
||
min_w = max(56, fm.horizontalAdvance(text) + 20)
|
||
btn.setMinimumWidth(min_w)
|
||
|
||
self._btn_lo.addWidget(btn)
|
||
return btn
|
||
|
||
|
||
class RibbonBar(QWidget):
|
||
"""
|
||
Ribbon bar con 6 pestañas funcionales.
|
||
Cada pestaña muestra grupos de botones relevantes para esa fase de trabajo.
|
||
"""
|
||
|
||
TAB_HOME = 0
|
||
TAB_GEOMETRY = 1
|
||
TAB_ANALYSIS = 2
|
||
TAB_TANKS = 3
|
||
TAB_SYSTEMS = 4
|
||
TAB_FABRICATION = 5
|
||
|
||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("ribbonBar")
|
||
self.setFixedHeight(110) # 26 tab-row + 84 content (suficiente para 22px icon + texto 1 línea + título grupo)
|
||
self._tab_btns: list[QToolButton] = []
|
||
self._build_ui()
|
||
|
||
def _build_ui(self) -> None:
|
||
main = QVBoxLayout(self)
|
||
main.setContentsMargins(0, 0, 0, 0)
|
||
main.setSpacing(0)
|
||
|
||
# ── Fila de pestañas ──────────────────────────────────────
|
||
tab_row = QWidget()
|
||
tab_row.setObjectName("ribbonTabRow")
|
||
tab_row.setFixedHeight(26)
|
||
trl = QHBoxLayout(tab_row)
|
||
trl.setContentsMargins(2, 0, 0, 0)
|
||
trl.setSpacing(0)
|
||
|
||
for i, name in enumerate(_RIBBON_TABS):
|
||
btn = QToolButton()
|
||
btn.setText(name)
|
||
btn.setObjectName("ribbonTabBtn")
|
||
btn.setCheckable(True)
|
||
btn.setChecked(i == 0)
|
||
btn.setFixedHeight(26)
|
||
btn.setMinimumWidth(80)
|
||
btn.clicked.connect(lambda _=False, idx=i: self._select_tab(idx))
|
||
self._tab_btns.append(btn)
|
||
trl.addWidget(btn)
|
||
|
||
trl.addStretch()
|
||
main.addWidget(tab_row)
|
||
|
||
# ── Stack de contenido por pestaña ────────────────────────
|
||
self._stack = QStackedWidget()
|
||
self._stack.setObjectName("ribbonContent")
|
||
for _ in _RIBBON_TABS:
|
||
page = QWidget()
|
||
pl = QHBoxLayout(page)
|
||
pl.setContentsMargins(4, 2, 4, 0)
|
||
pl.setSpacing(0)
|
||
pl.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||
self._stack.addWidget(page)
|
||
|
||
main.addWidget(self._stack)
|
||
|
||
def _select_tab(self, idx: int) -> None:
|
||
for i, btn in enumerate(self._tab_btns):
|
||
btn.setChecked(i == idx)
|
||
self._stack.setCurrentIndex(idx)
|
||
|
||
def new_group(self, tab_idx: int, title: str) -> RibbonGroup:
|
||
"""Crea un grupo de botones en la pestaña indicada y devuelve el grupo."""
|
||
page_lo: QHBoxLayout = self._stack.widget(tab_idx).layout()
|
||
group = RibbonGroup(title)
|
||
page_lo.addWidget(group)
|
||
sep = QFrame()
|
||
sep.setObjectName("ribbonSep")
|
||
sep.setFrameShape(QFrame.Shape.VLine)
|
||
page_lo.addWidget(sep)
|
||
return group
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# VENTANA PRINCIPAL
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class MainWindow(QMainWindow):
|
||
"""
|
||
Ventana principal de AR-ShipDesign.
|
||
|
||
Arquitectura:
|
||
- Ribbon bar con 6 pestañas funcionales (Home / Geometría / Análisis /
|
||
Tanques / Sistemas / Fabricación)
|
||
- Menú completo con submenús por módulo
|
||
- Dock izquierdo: panel de capas (LayersPanel)
|
||
- Área central: 4 viewports de modelado (default) o módulo de análisis
|
||
- Dock derecho: propiedades del elemento seleccionado
|
||
- Panel inferior fijo (36 px): hidrostáticos en vivo
|
||
- Barra de estado
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self._project: Optional[Project] = None
|
||
self._current_hull = None # Hull activo en todos los visores
|
||
self._gz_widget: Optional[GZCurveWidget] = None
|
||
self._lang = get_language()
|
||
self._strings = _load_i18n(self._lang)
|
||
self._setup_ui()
|
||
self._setup_ribbon()
|
||
self._setup_menu()
|
||
self._setup_status_bar()
|
||
self._restore_geometry()
|
||
self._update_title()
|
||
logger.info("MainWindow inicializada")
|
||
|
||
# ─────────────────────────────────────────────────────────
|
||
# SETUP
|
||
# ─────────────────────────────────────────────────────────
|
||
|
||
def _setup_ui(self) -> None:
|
||
self.setMinimumSize(1100, 700)
|
||
|
||
# Área central
|
||
self._module_area = ModuleArea()
|
||
self.setCentralWidget(self._module_area)
|
||
|
||
# Inyectar visor 3D en el viewport Perspectiva (diferido)
|
||
from arshipdesign.ui.widgets.viewer_3d import Viewer3DWidget, _PYVISTA_OK
|
||
if _PYVISTA_OK:
|
||
self._viewer_3d = Viewer3DWidget()
|
||
vp = self._module_area.four_viewport.viewport("perspective")
|
||
if vp is not None:
|
||
vp.set_canvas(self._viewer_3d)
|
||
else:
|
||
self._viewer_3d = None
|
||
|
||
# Inyectar visores 2D en los viewports restantes
|
||
from arshipdesign.ui.widgets.viewer_lines import (
|
||
BodyPlanViewer, ProfileViewer, PlanViewer,
|
||
)
|
||
self._viewer_bodyplan = BodyPlanViewer()
|
||
self._viewer_profile = ProfileViewer()
|
||
self._viewer_plan = PlanViewer()
|
||
for _vtype, _widget in (
|
||
("bodyplan", self._viewer_bodyplan),
|
||
("profile", self._viewer_profile),
|
||
("plan", self._viewer_plan),
|
||
):
|
||
_vp = self._module_area.four_viewport.viewport(_vtype)
|
||
if _vp is not None:
|
||
_vp.set_canvas(_widget)
|
||
|
||
# Edición live durante drag → actualizar vistas cruzadas sin resetear zoom
|
||
self._viewer_bodyplan.offsets_dragging.connect(self._on_offsets_dragging)
|
||
self._viewer_plan.offsets_dragging.connect(self._on_offsets_dragging)
|
||
# Fin del drag → persistir + actualizar 3D + hidrostáticos
|
||
self._viewer_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
||
self._viewer_plan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
||
|
||
# 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)
|
||
|
||
# Visor de curvas hidrostáticas (sustituye el placeholder MOD_CURVES)
|
||
from arshipdesign.ui.widgets.hydrostatics_chart import HydrostaticsChartWidget
|
||
self._hydro_chart = HydrostaticsChartWidget()
|
||
self._module_area.set_module_widget(ModuleArea.MOD_CURVES, self._hydro_chart)
|
||
|
||
# Módulo de estabilidad GZ (sustituye el placeholder MOD_STABILITY)
|
||
self._gz_widget = GZCurveWidget()
|
||
self._module_area.set_module_widget(ModuleArea.MOD_STABILITY, self._gz_widget)
|
||
|
||
# Dock izquierdo — capas
|
||
self._layers_panel = LayersPanel(self._strings)
|
||
self._dock_layers = QDockWidget("Capas", self)
|
||
self._dock_layers.setObjectName("dockLayers")
|
||
self._dock_layers.setWidget(self._layers_panel)
|
||
self._dock_layers.setFeatures(
|
||
QDockWidget.DockWidgetFeature.DockWidgetMovable |
|
||
QDockWidget.DockWidgetFeature.DockWidgetFloatable |
|
||
QDockWidget.DockWidgetFeature.DockWidgetClosable
|
||
)
|
||
self._dock_layers.setMinimumWidth(175)
|
||
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self._dock_layers)
|
||
|
||
# Dock derecho — propiedades
|
||
self._props_panel = PropertiesPanel(self._strings)
|
||
self._dock_props = QDockWidget("Propiedades", self)
|
||
self._dock_props.setObjectName("dockProperties")
|
||
self._dock_props.setWidget(self._props_panel)
|
||
self._dock_props.setFeatures(
|
||
QDockWidget.DockWidgetFeature.DockWidgetMovable |
|
||
QDockWidget.DockWidgetFeature.DockWidgetFloatable |
|
||
QDockWidget.DockWidgetFeature.DockWidgetClosable
|
||
)
|
||
self._dock_props.setMinimumWidth(200)
|
||
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self._dock_props)
|
||
|
||
# Dock inferior — hidrostáticos (sin barra de título)
|
||
self._hydro = HydrostaticsPanel(self._strings)
|
||
dock_hydro = QDockWidget(self)
|
||
dock_hydro.setObjectName("dockHydrostatics")
|
||
dock_hydro.setWidget(self._hydro)
|
||
dock_hydro.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)
|
||
dock_hydro.setTitleBarWidget(QWidget())
|
||
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, dock_hydro)
|
||
|
||
def _setup_ribbon(self) -> None:
|
||
sp = QStyle.StandardPixmap
|
||
M = ModuleArea
|
||
|
||
self._ribbon = RibbonBar()
|
||
|
||
# Envuelto en QToolBar para integración correcta con QMainWindow
|
||
tb = QToolBar("Ribbon", self)
|
||
tb.setObjectName("ribbonToolbar")
|
||
tb.setMovable(False)
|
||
tb.setFloatable(False)
|
||
tb.setContentsMargins(0, 0, 0, 0)
|
||
tb.addWidget(self._ribbon)
|
||
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
|
||
|
||
# ── Tab HOME ─────────────────────────────────────────────
|
||
g = self._ribbon.new_group(RibbonBar.TAB_HOME, "Archivo")
|
||
g.add_button(_spi(sp.SP_FileIcon), "Nuevo", "Nuevo proyecto Ctrl+N",
|
||
self._on_new_project)
|
||
g.add_button(_spi(sp.SP_DirOpenIcon), "Abrir", "Abrir proyecto Ctrl+O",
|
||
self._on_open_project)
|
||
g.add_button(_spi(sp.SP_DialogSaveButton), "Guardar", "Guardar Ctrl+S",
|
||
self._on_save_project)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_HOME, "Editar")
|
||
g.add_button(_spi(sp.SP_ArrowBack), "Deshacer", "Deshacer Ctrl+Z", enabled=False)
|
||
g.add_button(_spi(sp.SP_ArrowForward), "Rehacer", "Rehacer Ctrl+Y", enabled=False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_HOME, "Vistas")
|
||
g.add_button(_spi(sp.SP_DesktopIcon), "4 Vistas", "4 Viewports F2",
|
||
lambda: self._module_area.activate(M.MOD_4VP))
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Líneas", "Plano de Líneas F3",
|
||
lambda: self._module_area.activate(M.MOD_LINES))
|
||
g.add_button(_spi(sp.SP_FileDialogListView), "Offsets", "Tabla de Offsets F4",
|
||
lambda: self._module_area.activate(M.MOD_OFFSETS))
|
||
|
||
# ── Tab GEOMETRÍA ─────────────────────────────────────────
|
||
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Nuevo")
|
||
g.add_button(_spi(sp.SP_FileIcon), "Asistente", "Asistente de embarcación", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileIcon), "Casco NURBS", "Nuevo casco NURBS", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileIcon), "Apéndice", "Añadir apéndice", enabled=False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Edición NURBS")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Pts. Ctrl.", "Editar puntos de control", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Extruir", "Extruir sección", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Espejo", "Espejo por eje de crujía", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Lackenby", "Transformación de Lackenby", enabled=False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Importar")
|
||
g.add_button(_spi(sp.SP_DirOpenIcon), "Offsets", "Importar tabla de offsets (.txt / .csv)", enabled=False)
|
||
g.add_button(_spi(sp.SP_DirOpenIcon), "DXF", "Importar plano DXF", enabled=False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Exportar")
|
||
g.add_button(_spi(sp.SP_DialogSaveButton), "IGES", "Exportar IGES", enabled=False)
|
||
g.add_button(_spi(sp.SP_DialogSaveButton), "STEP", "Exportar STEP", enabled=False)
|
||
g.add_button(_spi(sp.SP_DialogSaveButton), "DXF", "Exportar DXF", enabled=False)
|
||
|
||
# ── Tab ANÁLISIS ──────────────────────────────────────────
|
||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Hidrostática")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calcular", "Calcular curvas hidrostáticas",
|
||
self._on_compute_hydrostatics)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curvas", "Curvas hidrostáticas",
|
||
self._on_show_hydrostatics)
|
||
g.add_button(_spi(sp.SP_DialogSaveButton), "Exp. CSV", "Exportar curvas como CSV",
|
||
self._on_export_hydrostatics_csv)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curva GZ", "Curva GZ estática",
|
||
self._on_show_stability)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "IMO IS2008", "Criterios IMO IS Code 2008", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Avería", "Estabilidad en avería", enabled=False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Resistencia")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Holtrop", "Holtrop & Mennen",
|
||
lambda: self._module_area.activate(M.MOD_RESISTANCE), False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Savitsky", "Savitsky (planeo)", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "VPP", "VPP Velero / DSYHS",
|
||
lambda: self._module_area.activate(M.MOD_VPP), False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Seakeeping")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "STF", "Strip Theory (STF)",
|
||
lambda: self._module_area.activate(M.MOD_SEAKEEPING), False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Espectro", "Espectro de respuesta", enabled=False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estructura")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "ISO 12215", "Escantillado ISO 12215-5",
|
||
lambda: self._module_area.activate(M.MOD_SCANTLING), False)
|
||
|
||
# ── Tab TANQUES ───────────────────────────────────────────
|
||
g = self._ribbon.new_group(RibbonBar.TAB_TANKS, "Tanques")
|
||
g.add_button(_spi(sp.SP_FileIcon), "Nuevo Tq.", "Definir nuevo tanque",
|
||
lambda: self._module_area.activate(M.MOD_TANKS), False)
|
||
g.add_button(_spi(sp.SP_FileIcon), "Modelar", "Modelar tanque NURBS", enabled=False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_TANKS, "Casos de Carga")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Nuevo caso", "Definir caso de carga", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Sondeos", "Tablas de sondeo",
|
||
lambda: self._module_area.activate(M.MOD_CAPACITY), False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calc. KG", "Calcular KG por caso", enabled=False)
|
||
|
||
# ── Tab SISTEMAS ──────────────────────────────────────────
|
||
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Eléctrico")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "EPLA", "Balance eléctrico (EPLA)",
|
||
lambda: self._module_area.activate(M.MOD_ELECTRICAL), False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Fluidos")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Combustible", "Sistema de combustible",
|
||
lambda: self._module_area.activate(M.MOD_FUEL), False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Agua Dulce", "Sistema de agua dulce",
|
||
lambda: self._module_area.activate(M.MOD_FRESHWATER), False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Achique", "Sistema de achique",
|
||
lambda: self._module_area.activate(M.MOD_BILGE), False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "C. Incendio", "Sistema contra incendios",
|
||
lambda: self._module_area.activate(M.MOD_FIREFIGHT), False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Routing 3D")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Tuberías", "Routing de tuberías 3D",
|
||
lambda: self._module_area.activate(M.MOD_ROUTING_PIPES), False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Cableados", "Routing de cableados 3D",
|
||
lambda: self._module_area.activate(M.MOD_ROUTING_CABLES), False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Clima / Control")
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "HVAC", "Sistema HVAC",
|
||
lambda: self._module_area.activate(M.MOD_HVAC), False)
|
||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Gobierno", "Sistema de gobierno", enabled=False)
|
||
|
||
# ── Tab FABRICACIÓN ───────────────────────────────────────
|
||
g = self._ribbon.new_group(RibbonBar.TAB_FABRICATION, "CNC")
|
||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Materiales", "Estimación de materiales", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Nesting", "Optimización de cortes (nesting)",
|
||
lambda: self._module_area.activate(M.MOD_CNC), False)
|
||
g.add_button(_spi(sp.SP_FileDialogContentsView), "G-code", "Generar G-code", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Post-Proc.", "Configurar post-procesador CNC", enabled=False)
|
||
|
||
g = self._ribbon.new_group(RibbonBar.TAB_FABRICATION, "Moldes FRP")
|
||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Lofting", "Lofting del molde",
|
||
lambda: self._module_area.activate(M.MOD_MOLDS), False)
|
||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Laminado", "Schedule de laminado", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Resina", "Calculadora de resina", enabled=False)
|
||
g.add_button(_spi(sp.SP_FileDialogContentsView), "BOM", "BOM de materiales", enabled=False)
|
||
|
||
def _setup_menu(self) -> None:
|
||
mb = self.menuBar()
|
||
sp = QStyle.StandardPixmap
|
||
M = ModuleArea
|
||
|
||
# ── ARCHIVO ────────────────────────────────────────────────
|
||
m = mb.addMenu("Archivo")
|
||
self._add_action(m, "Nuevo proyecto", QKeySequence.StandardKey.New, self._on_new_project, _spi(sp.SP_FileIcon))
|
||
self._add_action(m, "Abrir...", QKeySequence.StandardKey.Open, self._on_open_project, _spi(sp.SP_DirOpenIcon))
|
||
m.addSeparator()
|
||
self._add_action(m, "Guardar", QKeySequence.StandardKey.Save, self._on_save_project, _spi(sp.SP_DialogSaveButton))
|
||
self._add_action(m, "Guardar como...", QKeySequence("Ctrl+Shift+S"), self._on_save_as)
|
||
m.addSeparator()
|
||
self._recent_menu = m.addMenu("Recientes")
|
||
self._update_recent_menu()
|
||
m.addSeparator()
|
||
self._add_action(m, "Salir", QKeySequence("Alt+F4"), self.close)
|
||
|
||
# ── EDITAR ─────────────────────────────────────────────────
|
||
m = mb.addMenu("Editar")
|
||
self._act_undo = self._add_action(m, "Deshacer", QKeySequence.StandardKey.Undo, enabled=False)
|
||
self._act_redo = self._add_action(m, "Rehacer", QKeySequence.StandardKey.Redo, enabled=False)
|
||
m.addSeparator()
|
||
self._add_action(m, "Preferencias...", slot=self._on_preferences)
|
||
|
||
# ── VER ────────────────────────────────────────────────────
|
||
m = mb.addMenu("Ver")
|
||
self._add_action(m, "4 Viewports", QKeySequence("F2"), lambda: self._module_area.activate(M.MOD_4VP))
|
||
self._add_action(m, "Plano de Líneas", QKeySequence("F3"), lambda: self._module_area.activate(M.MOD_LINES))
|
||
self._add_action(m, "Tabla de Offsets", QKeySequence("F4"), lambda: self._module_area.activate(M.MOD_OFFSETS))
|
||
m.addSeparator()
|
||
self._add_action(m, "Panel Capas",
|
||
slot=lambda: self._dock_layers.setVisible(not self._dock_layers.isVisible()))
|
||
self._add_action(m, "Panel Propiedades",
|
||
slot=lambda: self._dock_props.setVisible(not self._dock_props.isVisible()))
|
||
m.addSeparator()
|
||
self._add_action(m, "Tema claro / oscuro", slot=self._on_toggle_theme)
|
||
|
||
# ── MODELO ────────────────────────────────────────────────
|
||
m = mb.addMenu("Modelo")
|
||
self._add_action(m, "Asistente de embarcación...", enabled=False)
|
||
self._add_action(m, "Nuevo casco NURBS...", enabled=False)
|
||
m.addSeparator()
|
||
self._add_action(m, "Importar offsets (.txt / .csv)...", enabled=False)
|
||
self._add_action(m, "Importar DXF...", enabled=False)
|
||
self._add_action(m, "Exportar IGES...", enabled=False)
|
||
self._add_action(m, "Exportar STEP...", enabled=False)
|
||
|
||
# ── ANÁLISIS ──────────────────────────────────────────────
|
||
m = mb.addMenu("Análisis")
|
||
|
||
sm = m.addMenu("Hidrostática")
|
||
self._add_action(sm, "Calcular hidrostáticos",
|
||
slot=self._on_compute_hydrostatics)
|
||
self._add_action(sm, "Curvas hidrostáticas",
|
||
slot=self._on_show_hydrostatics)
|
||
self._add_action(sm, "Exportar curvas CSV…",
|
||
slot=self._on_export_hydrostatics_csv)
|
||
|
||
sm = m.addMenu("Estabilidad")
|
||
self._add_action(sm, "Curva GZ — Estabilidad estática", slot=self._on_show_stability)
|
||
self._add_action(sm, "Criterios IMO IS Code 2008", enabled=False)
|
||
self._add_action(sm, "Criterio de viento A.749(18)", enabled=False)
|
||
self._add_action(sm, "Estabilidad en avería (SOLAS 2009)", enabled=False)
|
||
|
||
sm = m.addMenu("Resistencia y Propulsión")
|
||
self._add_action(sm, "Resistencia (Holtrop & Mennen / Savitsky)", slot=lambda: self._module_area.activate(M.MOD_RESISTANCE), enabled=False)
|
||
self._add_action(sm, "Propulsión — Wageningen B-series", slot=lambda: self._module_area.activate(M.MOD_PROPULSION), enabled=False)
|
||
self._add_action(sm, "VPP Velero (DSYHS)", slot=lambda: self._module_area.activate(M.MOD_VPP), enabled=False)
|
||
|
||
sm = m.addMenu("Seakeeping")
|
||
self._add_action(sm, "Movimientos (Strip Theory STF)", slot=lambda: self._module_area.activate(M.MOD_SEAKEEPING), enabled=False)
|
||
self._add_action(sm, "Espectro de respuesta", enabled=False)
|
||
|
||
m.addSeparator()
|
||
self._add_action(m, "Escantillado ISO 12215-5", slot=lambda: self._module_area.activate(M.MOD_SCANTLING), enabled=False)
|
||
|
||
# ── TANQUES Y CARGA ────────────────────────────────────────
|
||
m = mb.addMenu("Tanques")
|
||
self._add_action(m, "Modelado de tanques", slot=lambda: self._module_area.activate(M.MOD_TANKS), enabled=False)
|
||
self._add_action(m, "Plan de capacidad", slot=lambda: self._module_area.activate(M.MOD_CAPACITY), enabled=False)
|
||
self._add_action(m, "Tablas de sondeo", enabled=False)
|
||
self._add_action(m, "Casos de carga / KG", enabled=False)
|
||
|
||
# ── SISTEMAS ──────────────────────────────────────────────
|
||
m = mb.addMenu("Sistemas")
|
||
self._add_action(m, "Balance eléctrico (EPLA)", slot=lambda: self._module_area.activate(M.MOD_ELECTRICAL), enabled=False)
|
||
self._add_action(m, "Sistema de combustible", slot=lambda: self._module_area.activate(M.MOD_FUEL), enabled=False)
|
||
self._add_action(m, "Sistema de agua dulce", slot=lambda: self._module_area.activate(M.MOD_FRESHWATER), enabled=False)
|
||
self._add_action(m, "Sistema de achique", slot=lambda: self._module_area.activate(M.MOD_BILGE), enabled=False)
|
||
m.addSeparator()
|
||
self._add_action(m, "Sistema contra incendios", slot=lambda: self._module_area.activate(M.MOD_FIREFIGHT), enabled=False)
|
||
self._add_action(m, "HVAC", slot=lambda: self._module_area.activate(M.MOD_HVAC), enabled=False)
|
||
self._add_action(m, "Sistema de gobierno", enabled=False)
|
||
self._add_action(m, "Lastre y amarre", enabled=False)
|
||
m.addSeparator()
|
||
sm = m.addMenu("Routing 3D")
|
||
self._add_action(sm, "Routing de tuberías", slot=lambda: self._module_area.activate(M.MOD_ROUTING_PIPES), enabled=False)
|
||
self._add_action(sm, "Routing de cableados", slot=lambda: self._module_area.activate(M.MOD_ROUTING_CABLES), enabled=False)
|
||
|
||
# ── FABRICACIÓN ────────────────────────────────────────────
|
||
m = mb.addMenu("Fabricación")
|
||
sm = m.addMenu("CNC")
|
||
self._add_action(sm, "Estimación de materiales", enabled=False)
|
||
self._add_action(sm, "Optimización de cortes (Nesting)", slot=lambda: self._module_area.activate(M.MOD_CNC), enabled=False)
|
||
self._add_action(sm, "Generar G-code", enabled=False)
|
||
self._add_action(sm, "Configurar post-procesador CNC", enabled=False)
|
||
|
||
sm = m.addMenu("Moldes FRP")
|
||
self._add_action(sm, "Lofting del molde", slot=lambda: self._module_area.activate(M.MOD_MOLDS), enabled=False)
|
||
self._add_action(sm, "Schedule de laminado", enabled=False)
|
||
self._add_action(sm, "Calculadora de resina", enabled=False)
|
||
self._add_action(sm, "BOM de materiales", enabled=False)
|
||
|
||
# ── REPORTES ───────────────────────────────────────────────
|
||
m = mb.addMenu("Reportes")
|
||
self._add_action(m, "Reporte hidrostático", slot=lambda: self._module_area.activate(M.MOD_REPORT), enabled=False)
|
||
self._add_action(m, "Cuaderno de estabilidad", enabled=False)
|
||
self._add_action(m, "Plano de líneas (PDF / DXF)", enabled=False)
|
||
self._add_action(m, "Reporte de escantillado", enabled=False)
|
||
self._add_action(m, "Balance eléctrico (Excel)", enabled=False)
|
||
self._add_action(m, "Plan de capacidad", enabled=False)
|
||
|
||
# ── AYUDA ─────────────────────────────────────────────────
|
||
m = mb.addMenu("Ayuda")
|
||
self._add_action(m, "Acerca de AR-ShipDesign...", slot=self._on_about)
|
||
|
||
def _add_action(
|
||
self,
|
||
menu,
|
||
text: str,
|
||
shortcut=None,
|
||
slot=None,
|
||
icon: Optional[QIcon] = None,
|
||
enabled: bool = True,
|
||
) -> QAction:
|
||
a = QAction(text, self)
|
||
if icon:
|
||
a.setIcon(icon)
|
||
if shortcut:
|
||
a.setShortcut(shortcut)
|
||
if slot:
|
||
a.triggered.connect(slot)
|
||
a.setEnabled(enabled)
|
||
menu.addAction(a)
|
||
return a
|
||
|
||
def _setup_status_bar(self) -> None:
|
||
sb = self.statusBar()
|
||
sb.showMessage("Listo")
|
||
|
||
# Unidades
|
||
lbl_u = QLabel("Unidades: ")
|
||
lbl_u.setObjectName("statusLabel")
|
||
self._units_lbl = QLabel("SI")
|
||
self._units_lbl.setObjectName("statusValue")
|
||
|
||
# Idioma
|
||
lbl_l = QLabel(" Idioma: ")
|
||
lbl_l.setObjectName("statusLabel")
|
||
self._lang_lbl = QLabel(self._lang.upper())
|
||
self._lang_lbl.setObjectName("statusValue")
|
||
|
||
# Versión
|
||
ver = QLabel(f" AR-ShipDesign v{__version__} ")
|
||
ver.setObjectName("statusVersion")
|
||
|
||
sb.addPermanentWidget(lbl_u)
|
||
sb.addPermanentWidget(self._units_lbl)
|
||
sb.addPermanentWidget(lbl_l)
|
||
sb.addPermanentWidget(self._lang_lbl)
|
||
sb.addPermanentWidget(ver)
|
||
|
||
# ─────────────────────────────────────────────────────────
|
||
# ACCIONES DE PROYECTO
|
||
# ─────────────────────────────────────────────────────────
|
||
|
||
def _on_new_project(self) -> None:
|
||
if self._project and self._project.is_modified and not self._ask_save():
|
||
return
|
||
from arshipdesign.ui.dialogs.wizards import NewShipWizard
|
||
wiz = NewShipWizard(self)
|
||
if wiz.exec() != wiz.DialogCode.Accepted:
|
||
return
|
||
hull = wiz.result_hull()
|
||
self._project = Project.new(hull.name if hull else "Proyecto sin título")
|
||
self._on_project_loaded()
|
||
if hull is not None:
|
||
self._current_hull = hull
|
||
self._project.set_hull(hull) # persistir en ship_data
|
||
self._load_hull_viewers(hull)
|
||
self.statusBar().showMessage(
|
||
f"Nuevo proyecto: {self._project.name}"
|
||
)
|
||
|
||
def _on_open_project(self) -> None:
|
||
if self._project and self._project.is_modified and not self._ask_save():
|
||
return
|
||
path, _ = QFileDialog.getOpenFileName(
|
||
self, "Abrir proyecto", str(Path.home()),
|
||
"Proyectos AR-ShipDesign (*.arsd);;Todos los archivos (*)",
|
||
)
|
||
if not path:
|
||
return
|
||
try:
|
||
self._project = Project.load(Path(path))
|
||
add_recent_file(path)
|
||
self._update_recent_menu()
|
||
self._on_project_loaded()
|
||
self.statusBar().showMessage(f"Abierto: {path}")
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error al abrir", str(e))
|
||
|
||
def _on_save_project(self) -> None:
|
||
if not self._project:
|
||
return
|
||
if not self._project.path:
|
||
self._on_save_as()
|
||
return
|
||
try:
|
||
self._project.save()
|
||
self._update_title()
|
||
self.statusBar().showMessage(f"Guardado: {self._project.path}")
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error al guardar", str(e))
|
||
|
||
def _on_save_as(self) -> None:
|
||
if not self._project:
|
||
return
|
||
path, _ = QFileDialog.getSaveFileName(
|
||
self, "Guardar como...",
|
||
str(Path.home() / f"{self._project.name}.arsd"),
|
||
"Proyectos AR-ShipDesign (*.arsd)",
|
||
)
|
||
if not path:
|
||
return
|
||
try:
|
||
self._project.save(Path(path))
|
||
add_recent_file(path)
|
||
self._update_recent_menu()
|
||
self._update_title()
|
||
self.statusBar().showMessage(f"Guardado como: {path}")
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error al guardar", str(e))
|
||
|
||
def _on_project_loaded(self) -> None:
|
||
self._update_title()
|
||
self._layers_panel.set_project(self._project)
|
||
# Restaurar Hull si el proyecto contiene geometría guardada
|
||
hull = self._project.hull
|
||
if hull is not None:
|
||
self._current_hull = hull
|
||
self._load_hull_viewers(hull)
|
||
logger.info("Hull '%s' restaurado desde proyecto", hull.name)
|
||
|
||
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.
|
||
|
||
``_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:
|
||
self._viewer_3d.load_hull(hull)
|
||
except Exception as exc:
|
||
logger.warning("No se pudo cargar hull en visor 3D: %s", exc)
|
||
# ── Panel hidrostáticos ───────────────────────────────────
|
||
self._update_hydrostatics(hull)
|
||
# ── Curva GZ (si el módulo está activo o precalcular) ─────
|
||
self._compute_and_show_gz()
|
||
|
||
def _on_offsets_dragging(self, offsets_table) -> None:
|
||
"""Slot ligero — actualiza vistas 2D durante drag sin resetear zoom ni actualizar 3D."""
|
||
hull = self._current_hull
|
||
if hull is None:
|
||
return
|
||
self._viewer_bodyplan.update_offsets(hull)
|
||
self._viewer_profile.update_offsets(hull)
|
||
self._viewer_plan.update_offsets(hull)
|
||
|
||
def _on_hull_changed_from_editor(self, hull) -> None:
|
||
"""Slot: el editor de offsets reconstruyo el Hull — propagar a visores y proyecto."""
|
||
self._current_hull = hull
|
||
if self._project is not None:
|
||
self._project.set_hull(hull)
|
||
self._load_hull_viewers(hull, _skip_offsets_editor=True)
|
||
self.statusBar().showMessage(f"Offsets actualizados — {hull.name}")
|
||
|
||
def _on_offsets_edited_from_viewer(self, offsets_table) -> None:
|
||
"""Slot: fin del drag — persistir + actualizar 3D + hidrostáticos.
|
||
|
||
Usa update_offsets (no set_hull) para que los visores 2D NO reseteen
|
||
su zoom/pan al terminar una edición.
|
||
"""
|
||
hull = self._current_hull
|
||
if hull is None:
|
||
return
|
||
# hull.offsets ya fue modificado in-place durante el drag
|
||
if self._project is not None:
|
||
self._project.set_hull(hull)
|
||
# Actualizar vistas 2D SIN resetear zoom/pan
|
||
self._viewer_bodyplan.update_offsets(hull)
|
||
self._viewer_profile.update_offsets(hull)
|
||
self._viewer_plan.update_offsets(hull)
|
||
# Sincronizar editor de tabla
|
||
self._offsets_editor.set_hull(hull)
|
||
# Visor 3D — sólo al soltar (no durante drag)
|
||
if self._viewer_3d is not None:
|
||
try:
|
||
self._viewer_3d.load_hull(hull)
|
||
except Exception as exc:
|
||
logger.warning("Error al actualizar visor 3D: %s", exc)
|
||
# Barra de hidrostáticos
|
||
self._update_hydrostatics(hull)
|
||
self.statusBar().showMessage(f"Geometría editada — {hull.name}")
|
||
|
||
def _update_hydrostatics(self, hull) -> None:
|
||
"""Calcula hidrostáticos al calado de diseño y actualiza la barra inferior.
|
||
|
||
Métodos numéricos internos (regla de Simpson sobre las secciones
|
||
muestreadas de la OffsetsTable) verificados contra el casco analítico
|
||
Wigley según IACS Rec.34 §4.3.
|
||
"""
|
||
try:
|
||
T = hull.draft
|
||
delta = hull.displacement_tonnes(T)
|
||
lcb_v = hull.lcb(T)
|
||
kb = hull.vcb(T)
|
||
kmt = hull.km_transverse(T)
|
||
tpc = hull.tpc(T)
|
||
mct = hull.mct1cm(T)
|
||
cb = hull.block_coefficient(T)
|
||
cw = hull.waterplane_coefficient(T)
|
||
cm = hull.midship_coefficient(T)
|
||
self._hydro.update_values({
|
||
"T": f"{T:.2f}",
|
||
"Δ": f"{delta:.1f} t",
|
||
"LCB": f"{lcb_v:.2f}",
|
||
"KB": f"{kb:.2f}",
|
||
"KMT": f"{kmt:.2f}",
|
||
"GMT": "—", # requiere KG del caso de carga
|
||
"TPC": f"{tpc:.3f}",
|
||
"MCT": f"{mct:.2f}",
|
||
"Cb": f"{cb:.3f}",
|
||
"Cw": f"{cw:.3f}",
|
||
"Cm": f"{cm:.3f}",
|
||
})
|
||
except Exception as exc:
|
||
logger.warning("Error al calcular hidrostáticos: %s", exc)
|
||
|
||
# ─────────────────────────────────────────────────────────
|
||
# CURVAS HIDROSTÁTICAS
|
||
# ─────────────────────────────────────────────────────────
|
||
|
||
def _on_compute_hydrostatics(self) -> None:
|
||
"""Calcula las curvas hidrostáticas y muestra el módulo."""
|
||
if self._current_hull is None:
|
||
from PySide6.QtWidgets import QMessageBox
|
||
QMessageBox.information(
|
||
self, "Sin casco", "Crea o abre un proyecto con un casco definido."
|
||
)
|
||
return
|
||
try:
|
||
from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves
|
||
self.statusBar().showMessage("Calculando curvas hidrostáticas…")
|
||
QApplication.processEvents()
|
||
curves = HydrostaticCurves.compute(
|
||
self._current_hull, n_points=30, rho=1025.0
|
||
)
|
||
self._hydro_chart.set_curves(curves)
|
||
self._module_area.activate(ModuleArea.MOD_CURVES)
|
||
self.statusBar().showMessage(
|
||
f"Curvas hidrostáticas calculadas — {curves.hull_name} "
|
||
f"({len(curves.points)} puntos, T: "
|
||
f"{curves.drafts[0]:.2f}–{curves.drafts[-1]:.2f} m)"
|
||
)
|
||
except Exception as exc:
|
||
logger.error("Error al calcular curvas: %s", exc)
|
||
from PySide6.QtWidgets import QMessageBox
|
||
QMessageBox.critical(self, "Error al calcular", str(exc))
|
||
|
||
def _on_show_hydrostatics(self) -> None:
|
||
"""Muestra el módulo de curvas (sin recalcular si ya hay datos)."""
|
||
if self._hydro_chart.curves is None and self._current_hull is not None:
|
||
self._on_compute_hydrostatics()
|
||
else:
|
||
self._module_area.activate(ModuleArea.MOD_CURVES)
|
||
|
||
def _on_export_hydrostatics_csv(self) -> None:
|
||
"""Exporta las curvas hidrostáticas como CSV."""
|
||
if self._hydro_chart.curves is None:
|
||
from PySide6.QtWidgets import QMessageBox
|
||
QMessageBox.information(
|
||
self, "Sin datos", "Calcula las curvas hidrostáticas primero."
|
||
)
|
||
return
|
||
curves = self._hydro_chart.curves
|
||
default_name = f"{curves.hull_name}_hidrostatics.csv".replace(" ", "_")
|
||
path, _ = QFileDialog.getSaveFileName(
|
||
self, "Exportar curvas hidrostáticas",
|
||
str(Path.home() / default_name),
|
||
"CSV (*.csv);;Todos los archivos (*)",
|
||
)
|
||
if not path:
|
||
return
|
||
try:
|
||
lines = curves.to_csv_lines(sep=",", decimal=".")
|
||
Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
self.statusBar().showMessage(f"CSV exportado: {path}")
|
||
except Exception as exc:
|
||
logger.error("Error al exportar CSV: %s", exc)
|
||
from PySide6.QtWidgets import QMessageBox
|
||
QMessageBox.critical(self, "Error al exportar", str(exc))
|
||
|
||
# ─────────────────────────────────────────────────────────
|
||
# CURVA GZ — ESTABILIDAD
|
||
# ─────────────────────────────────────────────────────────
|
||
|
||
def _compute_and_show_gz(self) -> None:
|
||
"""Calcula la curva GZ wall-sided y actualiza el widget de estabilidad."""
|
||
if self._current_hull is None:
|
||
return
|
||
if self._gz_widget is None:
|
||
return
|
||
try:
|
||
hull = self._current_hull
|
||
kg = hull.depth * 0.55
|
||
self.statusBar().showMessage("Calculando curva GZ…")
|
||
QApplication.processEvents()
|
||
gz_curve = compute_gz_wall_sided(hull, hull.draft, kg=kg)
|
||
imo_result = check_imo_is2008(gz_curve)
|
||
self._gz_widget.set_curve(gz_curve, imo_result)
|
||
# Actualizar indicador IMO en la barra de hidrostáticos
|
||
self._hydro.set_imo_status(
|
||
imo_result.overall_passed,
|
||
"" if imo_result.overall_passed else "GZ",
|
||
)
|
||
self.statusBar().showMessage(
|
||
f"Curva GZ calculada — {hull.name} "
|
||
f"GM={gz_curve.gm:.3f}m GZmax={gz_curve.gz_max:.3f}m "
|
||
f"AVS={gz_curve.avs:.0f}° "
|
||
f"IMO={'CUMPLE' if imo_result.overall_passed else 'FALLA'}"
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("Error al calcular curva GZ: %s", exc)
|
||
|
||
def _on_show_stability(self) -> None:
|
||
"""Muestra el módulo de estabilidad GZ (calcula si hay casco disponible)."""
|
||
if self._current_hull is not None:
|
||
self._compute_and_show_gz()
|
||
self._module_area.activate(ModuleArea.MOD_STABILITY)
|
||
|
||
def _ask_save(self) -> bool:
|
||
reply = QMessageBox.question(
|
||
self, "Cambios sin guardar",
|
||
f"'{self._project.name}' tiene cambios sin guardar. ¿Guardar antes de continuar?",
|
||
QMessageBox.StandardButton.Save |
|
||
QMessageBox.StandardButton.Discard |
|
||
QMessageBox.StandardButton.Cancel,
|
||
)
|
||
if reply == QMessageBox.StandardButton.Save:
|
||
self._on_save_project()
|
||
return True
|
||
return reply == QMessageBox.StandardButton.Discard
|
||
|
||
# ─────────────────────────────────────────────────────────
|
||
# ACCIONES DE UI
|
||
# ─────────────────────────────────────────────────────────
|
||
|
||
def _on_preferences(self) -> None:
|
||
QMessageBox.information(self, "Preferencias", "Disponible en Sprint 1")
|
||
|
||
def _on_toggle_theme(self) -> None:
|
||
current = get_theme()
|
||
new_theme = "light" if current == "dark" else "dark"
|
||
set_theme(new_theme)
|
||
qss_path = Path(__file__).parent / "themes" / f"{new_theme}.qss"
|
||
try:
|
||
QApplication.instance().setStyleSheet(
|
||
qss_path.read_text(encoding="utf-8")
|
||
)
|
||
except Exception as e:
|
||
logger.warning("Tema %s no disponible: %s", new_theme, e)
|
||
|
||
def _on_about(self) -> None:
|
||
QMessageBox.about(
|
||
self, "Acerca de AR-ShipDesign",
|
||
f"<b>AR-ShipDesign</b> v{__version__}<br><br>"
|
||
"Software profesional de diseño naval.<br><br>"
|
||
"Motor geométrico: NURBS (geomdl)<br>"
|
||
"Visualización 3D: PyVista + VTK<br>"
|
||
"Estabilidad: IMO IS Code 2008 · A.749(18) · SOLAS 2009<br>"
|
||
"Resistencia: Holtrop & Mennen · Savitsky · DSYHS<br>"
|
||
"Escantillado: ISO 12215-5<br><br>"
|
||
f"{self._strings.get('about_copyright', 'Copyright © 2025 Álvaro Romero')}",
|
||
)
|
||
|
||
def _update_title(self) -> None:
|
||
if self._project:
|
||
self.setWindowTitle(f"AR-ShipDesign — {self._project.display_name}")
|
||
else:
|
||
self.setWindowTitle("AR-ShipDesign")
|
||
|
||
def _update_recent_menu(self) -> None:
|
||
self._recent_menu.clear()
|
||
recent = get_recent_files()
|
||
if not recent:
|
||
a = QAction("(sin archivos recientes)", self)
|
||
a.setEnabled(False)
|
||
self._recent_menu.addAction(a)
|
||
return
|
||
for path in recent:
|
||
a = QAction(Path(path).name, self)
|
||
a.setToolTip(path)
|
||
a.triggered.connect(lambda _=False, p=path: self._open_recent(p))
|
||
self._recent_menu.addAction(a)
|
||
|
||
def _open_recent(self, path: str) -> None:
|
||
if self._project and self._project.is_modified and not self._ask_save():
|
||
return
|
||
try:
|
||
self._project = Project.load(Path(path))
|
||
add_recent_file(path)
|
||
self._on_project_loaded()
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Error", str(e))
|
||
|
||
# ─────────────────────────────────────────────────────────
|
||
# GEOMETRÍA DE VENTANA
|
||
# ─────────────────────────────────────────────────────────
|
||
|
||
def _restore_geometry(self) -> None:
|
||
s = get_settings()
|
||
geom = s.value("ui/windowGeometry")
|
||
state = s.value("ui/windowState")
|
||
if geom:
|
||
self.restoreGeometry(geom)
|
||
else:
|
||
self.resize(1280, 800)
|
||
self.showMaximized()
|
||
if state:
|
||
self.restoreState(state)
|
||
|
||
def closeEvent(self, event) -> None:
|
||
if self._project and self._project.is_modified and not self._ask_save():
|
||
event.ignore()
|
||
return
|
||
s = get_settings()
|
||
s.setValue("ui/windowGeometry", self.saveGeometry())
|
||
s.setValue("ui/windowState", self.saveState())
|
||
event.accept()
|