Files
AR-Shipdesign/arshipdesign/ui/main_window.py
T
alro65 0f85935fc8 feat(stability): Módulo 3 — Curva GZ + criterios IMO IS Code 2008
- 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>
2026-05-27 13:59:32 -04:00

1623 lines
74 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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> &nbsp; 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 &amp; 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()