bdfd5ac4ca
- viewer_lines.py: BodyPlanViewer, ProfileViewer, PlanViewer (QPainter, zoom/paneo, tema dark navy); conectados a los tres viewports 2D del layout 4-viewport (bodyplan / profile / plan). - hull.py: añadidos waterplane_coefficient (Cw), it_waterplane (IT), il_waterplane (IL), bm_transverse (BMT), bm_longitudinal (BML), km_transverse (KMT), tpc, mct1cm — todos verificados analíticamente contra el casco Wigley (IACS Rec.34 §4.3). - main_window.py: _load_hull_viewers() conecta los 4 visores y el panel hidrostáticos al crear un nuevo proyecto; _update_hydrostatics() puebla los 11 campos de la barra inferior en vivo. - test_module1_hydrostatics.py: 35 tests nuevos (IT analítico exacto, consistencia BMT=IT/V, KMT=KB+BMT, TPC=Awp·ρ/1e5, visores headless). Suite total: 86 tests — 86 passed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1421 lines
64 KiB
Python
1421 lines
64 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.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 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._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)
|
|
|
|
# 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 hidrostáticos", enabled=False)
|
|
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curvas", "Curvas hidrostáticas",
|
|
lambda: self._module_area.activate(M.MOD_CURVES), False)
|
|
|
|
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad")
|
|
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curva GZ", "Curva GZ estática",
|
|
lambda: self._module_area.activate(M.MOD_STABILITY), False)
|
|
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", enabled=False)
|
|
self._add_action(sm, "Curvas hidrostáticas", slot=lambda: self._module_area.activate(M.MOD_CURVES), enabled=False)
|
|
|
|
sm = m.addMenu("Estabilidad")
|
|
self._add_action(sm, "Curva GZ — Estabilidad estática", slot=lambda: self._module_area.activate(M.MOD_STABILITY), enabled=False)
|
|
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._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)
|
|
|
|
def _load_hull_viewers(self, hull) -> None:
|
|
"""Carga el casco en los cuatro visores y actualiza el panel de hidrostáticos.
|
|
|
|
Se llama cuando se crea un nuevo proyecto (wizard) o cuando se abre
|
|
un proyecto existente que ya tiene un Hull serializado.
|
|
"""
|
|
# ── Visores 2D ────────────────────────────────────────────
|
|
self._viewer_bodyplan.set_hull(hull)
|
|
self._viewer_profile.set_hull(hull)
|
|
self._viewer_plan.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)
|
|
|
|
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)
|
|
|
|
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()
|