v0.1-sprint0: Esqueleto completo AR-ShipDesign
- Estructura completa de carpetas (236 módulos stub + implementados) - pyproject.toml, requirements, .gitignore, LICENSE (propietario) - core/project.py: serialización .arsd (ZIP con JSON) - core/units.py: conversiones SI <-> imperial completas - ui/main_window.py: layout DELFTship-style con todos los paneles - Árbol de proyecto (dock izquierda) - Tabs de módulos (centro) - Panel de propiedades (dock derecha) - Panel hidrostáticos en vivo (inferior, fijo) - ui/i18n: español e inglés - ui/themes: tema claro y oscuro - utils/logger.py, settings.py, validation.py - data/liquids.json: 15 líquidos navales - data/stability_criteria.json: IMO IS Code 2008, A.749(18), USCG - tests/test_startup.py: 12 tests, todos PASSED - Módulo scantling/ ISO 12215 (stubs Sprint 2.5) - Módulo fabrication/molds/ para moldes FRP (stubs Sprint 13B) - Módulo fabrication/ para CNC plasma/router/laser (stubs Sprint 13)
This commit is contained in:
@@ -0,0 +1,802 @@
|
||||
"""
|
||||
Ventana principal de AR-ShipDesign.
|
||||
|
||||
Layout inspirado en DELFTship:
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Menú | Toolbar │
|
||||
├──────────┬──────────────────────────────┬───────────────┤
|
||||
│ │ │ │
|
||||
│ Árbol │ Vista central (tabs) │ Propiedades │
|
||||
│ Proyecto│ 3D / Líneas / Análisis │ │
|
||||
│ │ │ │
|
||||
├──────────┴──────────────────────────────┴───────────────┤
|
||||
│ PANEL HIDROSTÁTICOS EN VIVO (siempre visible) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Barra de tabs de módulos │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QAction, QFont, QIcon, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDockWidget,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QStatusBar,
|
||||
QTabWidget,
|
||||
QToolBar,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
# Carga de strings de i18n
|
||||
def _load_i18n(lang: str = "es") -> dict:
|
||||
i18n_path = Path(__file__).parent / "i18n" / f"{lang}.json"
|
||||
if not i18n_path.exists():
|
||||
i18n_path = Path(__file__).parent / "i18n" / "es.json"
|
||||
try:
|
||||
return json.loads(i18n_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class HydrostaticsPanel(QFrame):
|
||||
"""
|
||||
Panel de hidrostáticos en vivo — siempre visible en la parte inferior.
|
||||
|
||||
En Sprint 2 se conectará al motor de cálculo.
|
||||
Por ahora muestra valores placeholder.
|
||||
"""
|
||||
|
||||
def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.strings = strings
|
||||
self.setObjectName("hydrostaticsPanel")
|
||||
self.setFixedHeight(62)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 4, 10, 4)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Título
|
||||
title = QLabel(" HIDROSTÁTICOS ")
|
||||
title.setFont(QFont("Segoe UI", 9, QFont.Weight.Bold))
|
||||
title.setProperty("label", True)
|
||||
layout.addWidget(title)
|
||||
|
||||
sep = self._make_sep()
|
||||
layout.addWidget(sep)
|
||||
|
||||
# Campos hidrostáticos
|
||||
self._fields: dict[str, QLabel] = {}
|
||||
hydro_items = [
|
||||
("T", "3.20 m", "Calado [m]"),
|
||||
("Δ", "2 845 t", "Desplazamiento [t]"),
|
||||
("LCB", "12.30 m", "Centro Long. Carena [m desde AP]"),
|
||||
("KB", "1.85 m", "Centro Vert. Carena [m]"),
|
||||
("KMT", "4.20 m", "Altura Metacéntrica Transv. [m]"),
|
||||
("GMT", "1.05 m", "Altura Metacéntrica Corregida [m]"),
|
||||
("TPC", "8.2 t/cm", "Toneladas por cm de Inmersión"),
|
||||
("MCT", "42.5 t·m/cm", "Momento para Cambiar Asiento 1 cm"),
|
||||
("Cb", "0.682", "Coeficiente de Bloque"),
|
||||
("Cw", "0.821", "Coeficiente Plano Flotación"),
|
||||
("Cm", "0.985", "Coeficiente Cuaderna Maestra"),
|
||||
]
|
||||
|
||||
for key, default_val, tooltip in hydro_items:
|
||||
lbl_key = QLabel(f" {key} ")
|
||||
lbl_key.setProperty("label", True)
|
||||
lbl_key.setToolTip(tooltip)
|
||||
|
||||
lbl_val = QLabel(default_val)
|
||||
lbl_val.setProperty("value", True)
|
||||
lbl_val.setToolTip(tooltip)
|
||||
lbl_val.setMinimumWidth(72)
|
||||
|
||||
self._fields[key] = lbl_val
|
||||
layout.addWidget(lbl_key)
|
||||
layout.addWidget(lbl_val)
|
||||
|
||||
sep = self._make_sep()
|
||||
layout.addWidget(sep)
|
||||
|
||||
# Indicador IMO
|
||||
self._imo_label = QLabel(" ⚠ IMO — ")
|
||||
self._imo_label.setProperty("label", True)
|
||||
self._imo_status = QLabel("SIN DATOS")
|
||||
self._imo_status.setToolTip("Cumplimiento IMO IS Code 2008. Activo cuando haya un caso de carga calculado.")
|
||||
layout.addWidget(self._imo_label)
|
||||
layout.addWidget(self._imo_status)
|
||||
layout.addStretch()
|
||||
|
||||
@staticmethod
|
||||
def _make_sep() -> QFrame:
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.Shape.VLine)
|
||||
sep.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
sep.setFixedWidth(1)
|
||||
sep.setStyleSheet("QFrame { color: #3a3f4b; margin: 6px 4px; }")
|
||||
return sep
|
||||
|
||||
def update_values(self, values: dict[str, str]) -> None:
|
||||
"""Actualiza los valores del panel. Llamar desde el motor de cálculo."""
|
||||
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_status.setText("✅ CUMPLE")
|
||||
self._imo_status.setProperty("imo_ok", True)
|
||||
self._imo_status.setProperty("imo_fail", False)
|
||||
else:
|
||||
self._imo_status.setText(f"❌ FALLA {detail}")
|
||||
self._imo_status.setProperty("imo_ok", False)
|
||||
self._imo_status.setProperty("imo_fail", True)
|
||||
self._imo_status.style().polish(self._imo_status)
|
||||
|
||||
|
||||
class ProjectTreePanel(QWidget):
|
||||
"""Panel árbol de proyecto (izquierda)."""
|
||||
|
||||
item_selected = Signal(str) # nombre del ítem seleccionado
|
||||
|
||||
def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.strings = strings
|
||||
self.setObjectName("projectTree")
|
||||
self.setMinimumWidth(180)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
title = QLabel(f" {self.strings.get('panel_project', 'Proyecto')}")
|
||||
title.setFixedHeight(28)
|
||||
title.setStyleSheet("background: #252830; color: #90caf9; font-weight: bold; padding-left: 8px;")
|
||||
layout.addWidget(title)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setIndentation(16)
|
||||
self.tree.setAnimated(True)
|
||||
layout.addWidget(self.tree)
|
||||
|
||||
self._populate_default()
|
||||
self.tree.itemClicked.connect(self._on_item_clicked)
|
||||
|
||||
def _populate_default(self) -> None:
|
||||
self.tree.clear()
|
||||
root_items = [
|
||||
("🚢 Buque", [
|
||||
("📐 Casco", ["Superficie 1"]),
|
||||
("⚓ Apéndices", ["Quilla", "Timón"]),
|
||||
("🏗 Superestructura", []),
|
||||
]),
|
||||
("⛽ Tanques", ["FO 1 BR", "FO 1 ER", "FW 1", "Lastre AP"]),
|
||||
("📦 Bodegas", []),
|
||||
("📊 Casos de Carga", ["Lightship", "Salida Lleno", "Llegada Lleno", "Lastre"]),
|
||||
("⛵ Aparejo", ["Mástil Principal", "Mayor", "Génova"]),
|
||||
("⚙ Motor", ["Motor Principal", "Hélice"]),
|
||||
("🔌 Sistemas", [
|
||||
"Eléctrico", "Combustible", "Agua Dulce",
|
||||
"Achique", "Lastre", "C. Incendios", "HVAC"
|
||||
]),
|
||||
("🏭 Fabricación CNC", []),
|
||||
("🧴 Moldes FRP", []),
|
||||
]
|
||||
|
||||
for name, children in root_items:
|
||||
parent = QTreeWidgetItem(self.tree, [name])
|
||||
parent.setExpanded(False)
|
||||
self._add_children(parent, children)
|
||||
|
||||
self.tree.expandToDepth(0)
|
||||
|
||||
def _add_children(self, parent: QTreeWidgetItem, children: list) -> None:
|
||||
for child in children:
|
||||
if isinstance(child, tuple):
|
||||
child_name, grandchildren = child
|
||||
child_item = QTreeWidgetItem(parent, [child_name])
|
||||
self._add_children(child_item, grandchildren)
|
||||
else:
|
||||
QTreeWidgetItem(parent, [child])
|
||||
|
||||
def _on_item_clicked(self, item: QTreeWidgetItem, _col: int) -> None:
|
||||
self.item_selected.emit(item.text(0))
|
||||
|
||||
def set_project(self, project: Project) -> None:
|
||||
"""Actualiza el árbol con los datos del proyecto. Sprint 1."""
|
||||
pass # Se implementará en Sprint 1
|
||||
|
||||
|
||||
class PropertiesPanel(QWidget):
|
||||
"""Panel de propiedades del ítem seleccionado (derecha)."""
|
||||
|
||||
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)
|
||||
layout.setSpacing(0)
|
||||
|
||||
title = QLabel(f" {self.strings.get('panel_properties', 'Propiedades')}")
|
||||
title.setFixedHeight(28)
|
||||
title.setStyleSheet("background: #252830; color: #90caf9; font-weight: bold; padding-left: 8px;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Placeholder con dimensiones principales
|
||||
content = QWidget()
|
||||
content_layout = QVBoxLayout(content)
|
||||
content_layout.setContentsMargins(10, 10, 10, 10)
|
||||
content_layout.setSpacing(6)
|
||||
|
||||
props = [
|
||||
("LOA", "— m"),
|
||||
("LPP", "— m"),
|
||||
("B", "— m"),
|
||||
("T", "— m"),
|
||||
("D", "— m"),
|
||||
("Δ", "— t"),
|
||||
("GMT", "— m"),
|
||||
]
|
||||
|
||||
mono_font = QFont("Consolas", 11)
|
||||
for label, value in props:
|
||||
row = QHBoxLayout()
|
||||
lbl = QLabel(label)
|
||||
lbl.setProperty("muted", True)
|
||||
lbl.setFixedWidth(50)
|
||||
val = QLabel(value)
|
||||
val.setFont(mono_font)
|
||||
row.addWidget(lbl)
|
||||
row.addWidget(val)
|
||||
row.addStretch()
|
||||
content_layout.addLayout(row)
|
||||
|
||||
content_layout.addStretch()
|
||||
layout.addWidget(content)
|
||||
|
||||
|
||||
class CentralTabsWidget(QWidget):
|
||||
"""
|
||||
Widget central con tabs de vistas.
|
||||
|
||||
En Sprint 0 muestra placeholders.
|
||||
Los viewers reales (3D, líneas, etc.) se implementan en Sprint 1.
|
||||
"""
|
||||
|
||||
def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.strings = strings
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setTabPosition(QTabWidget.TabPosition.North)
|
||||
|
||||
tab_names = [
|
||||
("tab_3d", "3D"),
|
||||
("tab_lines", "Líneas"),
|
||||
("tab_offsets", "Offsets"),
|
||||
("tab_curves", "Curvas Hidrost."),
|
||||
("tab_tanks", "Tanques"),
|
||||
("tab_capacity", "Capacidad"),
|
||||
("tab_stability", "Estabilidad GZ"),
|
||||
("tab_resistance", "Resistencia"),
|
||||
("tab_propulsion", "Propulsión"),
|
||||
("tab_vpp", "VPP Velero"),
|
||||
("tab_seakeeping", "Movimientos"),
|
||||
("tab_electrical", "Eléctrico"),
|
||||
("tab_fuel", "Combustible"),
|
||||
("tab_freshwater", "Agua Dulce"),
|
||||
("tab_bilge", "Achique"),
|
||||
("tab_firefighting", "C. Incendios"),
|
||||
("tab_hvac", "HVAC"),
|
||||
("tab_scantling", "Escantillado"),
|
||||
("tab_fabrication", "Fabricación CNC"),
|
||||
("tab_molds", "Moldes FRP"),
|
||||
("tab_report", "Reporte"),
|
||||
]
|
||||
|
||||
for key, default_name in tab_names:
|
||||
name = self.strings.get(key, default_name)
|
||||
placeholder = self._make_placeholder(name)
|
||||
self.tabs.addTab(placeholder, name)
|
||||
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
@staticmethod
|
||||
def _make_placeholder(tab_name: str) -> QWidget:
|
||||
w = QWidget()
|
||||
layout = QVBoxLayout(w)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
icon_label = QLabel("🔜")
|
||||
icon_label.setFont(QFont("Segoe UI Emoji", 48))
|
||||
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
msg = QLabel(f"Módulo: {tab_name}\nSe implementará en el sprint correspondiente.")
|
||||
msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
msg.setProperty("muted", True)
|
||||
|
||||
layout.addWidget(icon_label)
|
||||
layout.addWidget(msg)
|
||||
return w
|
||||
|
||||
|
||||
class NewProjectDialog(QMessageBox):
|
||||
"""Dialog simple de nuevo proyecto para Sprint 0."""
|
||||
pass
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""
|
||||
Ventana principal de AR-ShipDesign.
|
||||
|
||||
Implementa el layout DELFTship-style con:
|
||||
- Menú bar completo
|
||||
- Toolbar principal
|
||||
- Panel árbol de proyecto (izquierda, dock)
|
||||
- Vista central con tabs de módulos
|
||||
- Panel de propiedades (derecha, dock)
|
||||
- Panel de hidrostáticos en vivo (inferior, fijo)
|
||||
- Barra de estado
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._project: Optional[Project] = None
|
||||
self._lang = get_language()
|
||||
self._strings = _load_i18n(self._lang)
|
||||
self._setup_ui()
|
||||
self._setup_menu()
|
||||
self._setup_toolbar()
|
||||
self._setup_status_bar()
|
||||
self._restore_geometry()
|
||||
self._update_title()
|
||||
logger.info("MainWindow inicializada")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# SETUP UI
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
self.setMinimumSize(1100, 700)
|
||||
|
||||
# Panel árbol proyecto (dock izquierda)
|
||||
self._project_tree = ProjectTreePanel(self._strings)
|
||||
dock_tree = QDockWidget(self._strings.get("panel_project", "Proyecto"), self)
|
||||
dock_tree.setObjectName("dockProjectTree")
|
||||
dock_tree.setWidget(self._project_tree)
|
||||
dock_tree.setFeatures(
|
||||
QDockWidget.DockWidgetFeature.DockWidgetMovable |
|
||||
QDockWidget.DockWidgetFeature.DockWidgetFloatable
|
||||
)
|
||||
dock_tree.setMinimumWidth(200)
|
||||
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock_tree)
|
||||
|
||||
# Panel propiedades (dock derecha)
|
||||
self._properties_panel = PropertiesPanel(self._strings)
|
||||
dock_props = QDockWidget(self._strings.get("panel_properties", "Propiedades"), self)
|
||||
dock_props.setObjectName("dockProperties")
|
||||
dock_props.setWidget(self._properties_panel)
|
||||
dock_props.setFeatures(
|
||||
QDockWidget.DockWidgetFeature.DockWidgetMovable |
|
||||
QDockWidget.DockWidgetFeature.DockWidgetFloatable
|
||||
)
|
||||
dock_props.setMinimumWidth(220)
|
||||
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock_props)
|
||||
|
||||
# Widget central con tabs
|
||||
self._central_tabs = CentralTabsWidget(self._strings)
|
||||
self.setCentralWidget(self._central_tabs)
|
||||
|
||||
# Panel hidrostáticos (dock inferior, no ocultable)
|
||||
self._hydro_panel = HydrostaticsPanel(self._strings)
|
||||
dock_hydro = QDockWidget(self._strings.get("panel_hydrostatics", "Hidrostáticos"), self)
|
||||
dock_hydro.setObjectName("dockHydrostatics")
|
||||
dock_hydro.setWidget(self._hydro_panel)
|
||||
dock_hydro.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)
|
||||
dock_hydro.setTitleBarWidget(QWidget()) # Oculta la barra de título del dock
|
||||
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, dock_hydro)
|
||||
|
||||
# Conectar señales
|
||||
self._project_tree.item_selected.connect(self._on_tree_item_selected)
|
||||
|
||||
def _setup_menu(self) -> None:
|
||||
s = self._strings
|
||||
menubar = self.menuBar()
|
||||
|
||||
# ── ARCHIVO ──
|
||||
menu_file = menubar.addMenu(s.get("menu_file", "Archivo"))
|
||||
|
||||
act_new = QAction(s.get("file_new", "Nuevo proyecto"), self)
|
||||
act_new.setShortcut(QKeySequence.StandardKey.New)
|
||||
act_new.triggered.connect(self._on_new_project)
|
||||
menu_file.addAction(act_new)
|
||||
|
||||
act_open = QAction(s.get("file_open", "Abrir..."), self)
|
||||
act_open.setShortcut(QKeySequence.StandardKey.Open)
|
||||
act_open.triggered.connect(self._on_open_project)
|
||||
menu_file.addAction(act_open)
|
||||
|
||||
menu_file.addSeparator()
|
||||
|
||||
act_save = QAction(s.get("file_save", "Guardar"), self)
|
||||
act_save.setShortcut(QKeySequence.StandardKey.Save)
|
||||
act_save.triggered.connect(self._on_save_project)
|
||||
menu_file.addAction(act_save)
|
||||
|
||||
act_save_as = QAction(s.get("file_save_as", "Guardar como..."), self)
|
||||
act_save_as.setShortcut(QKeySequence("Ctrl+Shift+S"))
|
||||
act_save_as.triggered.connect(self._on_save_as_project)
|
||||
menu_file.addAction(act_save_as)
|
||||
|
||||
menu_file.addSeparator()
|
||||
|
||||
# Recientes
|
||||
self._recent_menu = menu_file.addMenu(s.get("file_recent", "Recientes"))
|
||||
self._update_recent_menu()
|
||||
|
||||
menu_file.addSeparator()
|
||||
|
||||
act_exit = QAction(s.get("file_exit", "Salir"), self)
|
||||
act_exit.setShortcut(QKeySequence("Alt+F4"))
|
||||
act_exit.triggered.connect(self.close)
|
||||
menu_file.addAction(act_exit)
|
||||
|
||||
# ── EDITAR ──
|
||||
menu_edit = menubar.addMenu(s.get("menu_edit", "Editar"))
|
||||
|
||||
act_undo = QAction(s.get("edit_undo", "Deshacer"), self)
|
||||
act_undo.setShortcut(QKeySequence.StandardKey.Undo)
|
||||
act_undo.setEnabled(False) # Sprint 1
|
||||
menu_edit.addAction(act_undo)
|
||||
|
||||
act_redo = QAction(s.get("edit_redo", "Rehacer"), self)
|
||||
act_redo.setShortcut(QKeySequence.StandardKey.Redo)
|
||||
act_redo.setEnabled(False) # Sprint 1
|
||||
menu_edit.addAction(act_redo)
|
||||
|
||||
menu_edit.addSeparator()
|
||||
|
||||
act_prefs = QAction(s.get("edit_preferences", "Preferencias..."), self)
|
||||
act_prefs.triggered.connect(self._on_preferences)
|
||||
menu_edit.addAction(act_prefs)
|
||||
|
||||
# ── VER ──
|
||||
menu_view = menubar.addMenu(s.get("menu_view", "Ver"))
|
||||
act_theme = QAction("Cambiar tema (claro/oscuro)", self)
|
||||
act_theme.triggered.connect(self._on_toggle_theme)
|
||||
menu_view.addAction(act_theme)
|
||||
|
||||
# ── MODELO ──
|
||||
menu_model = menubar.addMenu(s.get("menu_model", "Modelo"))
|
||||
menu_model.addAction("Nuevo casco... (Sprint 1)")
|
||||
menu_model.addAction("Wizard de embarcación... (Sprint 1)")
|
||||
menu_model.addAction("Importar offsets... (Sprint 1)")
|
||||
menu_model.addAction("Importar DXF... (Sprint 1)")
|
||||
|
||||
# ── ANÁLISIS ──
|
||||
menu_analysis = menubar.addMenu(s.get("menu_analysis", "Análisis"))
|
||||
menu_analysis.addAction("Hidrostáticos (Sprint 2)")
|
||||
menu_analysis.addAction("Estabilidad GZ (Sprint 3)")
|
||||
menu_analysis.addAction("Escantillado ISO 12215 (Sprint 2.5)")
|
||||
menu_analysis.addAction("Resistencia y Propulsión (Sprint 5)")
|
||||
menu_analysis.addAction("VPP Velero (Sprint 6)")
|
||||
menu_analysis.addAction("Movimientos / Seakeeping (Sprint 9)")
|
||||
|
||||
# ── SISTEMAS ──
|
||||
menu_systems = menubar.addMenu(s.get("menu_systems", "Sistemas"))
|
||||
for sys_name in ["Eléctrico (Sprint 7)", "Combustible (Sprint 7)",
|
||||
"Agua Dulce (Sprint 7)", "Achique (Sprint 7)",
|
||||
"Lastre (Sprint 7)", "C. Incendios (Sprint 8)",
|
||||
"HVAC (Sprint 8)", "Gobierno (Sprint 8)"]:
|
||||
menu_systems.addAction(sys_name)
|
||||
|
||||
# ── FABRICACIÓN ──
|
||||
menu_fab = menubar.addMenu(s.get("menu_fabrication", "Fabricación"))
|
||||
menu_fab.addAction("Estimación de material (Sprint 13)")
|
||||
menu_fab.addAction("Nesting / Optimización de cortes (Sprint 13)")
|
||||
menu_fab.addAction("Generar G-code CNC (Sprint 13)")
|
||||
menu_fab.addSeparator()
|
||||
menu_fab.addAction("Moldes FRP — Lofting (Sprint 13B)")
|
||||
menu_fab.addAction("Moldes FRP — Schedule laminado (Sprint 13B)")
|
||||
menu_fab.addAction("Moldes FRP — BOM materiales (Sprint 13B)")
|
||||
|
||||
# ── REPORTES ──
|
||||
menu_reports = menubar.addMenu(s.get("menu_reports", "Reportes"))
|
||||
menu_reports.addAction("Reporte Hidrostático (Sprint 10)")
|
||||
menu_reports.addAction("Cuaderno de Estabilidad (Sprint 10)")
|
||||
menu_reports.addAction("Plano de Líneas (Sprint 10)")
|
||||
menu_reports.addAction("Reporte Escantillado (Sprint 10)")
|
||||
menu_reports.addAction("Balance Eléctrico (Sprint 10)")
|
||||
|
||||
# ── AYUDA ──
|
||||
menu_help = menubar.addMenu(s.get("menu_help", "Ayuda"))
|
||||
act_about = QAction(s.get("about_title", "Acerca de..."), self)
|
||||
act_about.triggered.connect(self._on_about)
|
||||
menu_help.addAction(act_about)
|
||||
|
||||
def _setup_toolbar(self) -> None:
|
||||
tb = QToolBar("Principal", self)
|
||||
tb.setObjectName("mainToolbar")
|
||||
tb.setMovable(False)
|
||||
self.addToolBar(tb)
|
||||
|
||||
buttons = [
|
||||
("🗎", "Nuevo proyecto (Ctrl+N)", self._on_new_project),
|
||||
("📂", "Abrir proyecto (Ctrl+O)", self._on_open_project),
|
||||
("💾", "Guardar (Ctrl+S)", self._on_save_project),
|
||||
]
|
||||
for icon_text, tip, slot in buttons:
|
||||
btn = tb.addAction(icon_text)
|
||||
btn.setToolTip(tip)
|
||||
btn.triggered.connect(slot)
|
||||
|
||||
tb.addSeparator()
|
||||
btn_undo = tb.addAction("↩")
|
||||
btn_undo.setToolTip("Deshacer (Ctrl+Z)")
|
||||
btn_undo.setEnabled(False)
|
||||
|
||||
btn_redo = tb.addAction("↪")
|
||||
btn_redo.setToolTip("Rehacer (Ctrl+Y)")
|
||||
btn_redo.setEnabled(False)
|
||||
|
||||
tb.addSeparator()
|
||||
btn_wizard = tb.addAction("🚢 Wizard")
|
||||
btn_wizard.setToolTip("Wizard de nueva embarcación (Sprint 1)")
|
||||
btn_wizard.setEnabled(False)
|
||||
|
||||
tb.addSeparator()
|
||||
btn_theme = tb.addAction("☀/🌙")
|
||||
btn_theme.setToolTip("Cambiar tema claro/oscuro")
|
||||
btn_theme.triggered.connect(self._on_toggle_theme)
|
||||
|
||||
# Separador flexible
|
||||
spacer = QWidget()
|
||||
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
tb.addWidget(spacer)
|
||||
|
||||
# Selector de unidades
|
||||
tb.addWidget(QLabel(" Unidades: "))
|
||||
self._units_label = QLabel("SI")
|
||||
self._units_label.setStyleSheet("color: #90caf9; font-weight: bold; margin-right: 8px;")
|
||||
tb.addWidget(self._units_label)
|
||||
|
||||
# Idioma
|
||||
tb.addWidget(QLabel(" 🌍 "))
|
||||
self._lang_label = QLabel(self._lang.upper())
|
||||
self._lang_label.setStyleSheet("color: #90caf9; font-weight: bold; margin-right: 8px;")
|
||||
tb.addWidget(self._lang_label)
|
||||
|
||||
def _setup_status_bar(self) -> None:
|
||||
sb = self.statusBar()
|
||||
sb.showMessage(self._strings.get("status_ready", "Listo"))
|
||||
|
||||
self._status_version = QLabel(f" AR-ShipDesign v{__version__} ")
|
||||
sb.addPermanentWidget(self._status_version)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ACCIONES DE PROYECTO
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _on_new_project(self) -> None:
|
||||
if self._project and self._project.is_modified:
|
||||
if not self._ask_save_changes():
|
||||
return
|
||||
self._project = Project.new("Proyecto sin título")
|
||||
self._on_project_loaded()
|
||||
self.statusBar().showMessage("Nuevo proyecto creado")
|
||||
|
||||
def _on_open_project(self) -> None:
|
||||
if self._project and self._project.is_modified:
|
||||
if not self._ask_save_changes():
|
||||
return
|
||||
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Abrir proyecto AR-ShipDesign",
|
||||
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"Proyecto abierto: {path}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error al abrir", f"No se pudo abrir el proyecto:\n{e}")
|
||||
logger.error("Error abriendo proyecto: %s", e)
|
||||
|
||||
def _on_save_project(self) -> None:
|
||||
if self._project is None:
|
||||
return
|
||||
if self._project.path is None:
|
||||
self._on_save_as_project()
|
||||
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_project(self) -> None:
|
||||
if self._project is None:
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Guardar proyecto 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:
|
||||
"""Callback cuando se carga o crea un proyecto."""
|
||||
self._update_title()
|
||||
self._project_tree.set_project(self._project)
|
||||
|
||||
def _ask_save_changes(self) -> bool:
|
||||
"""Pregunta si guardar antes de cerrar/nuevo. Retorna True si se puede continuar."""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Cambios sin guardar",
|
||||
f"El proyecto '{self._project.name}' tiene cambios sin guardar.\n¿Desea guardar antes de continuar?",
|
||||
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Save:
|
||||
self._on_save_project()
|
||||
return True
|
||||
elif reply == QMessageBox.StandardButton.Discard:
|
||||
return True
|
||||
return False # Cancel
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ACCIONES DE UI
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _on_tree_item_selected(self, name: str) -> None:
|
||||
self.statusBar().showMessage(f"Seleccionado: {name}")
|
||||
|
||||
def _on_preferences(self) -> None:
|
||||
QMessageBox.information(self, "Preferencias", "Diálogo de preferencias — Sprint 1")
|
||||
|
||||
def _on_toggle_theme(self) -> None:
|
||||
current = get_theme()
|
||||
new_theme = "light" if current == "dark" else "dark"
|
||||
set_theme(new_theme)
|
||||
self._apply_theme(new_theme)
|
||||
|
||||
def _apply_theme(self, theme: str) -> None:
|
||||
qss_path = Path(__file__).parent / "themes" / f"{theme}.qss"
|
||||
try:
|
||||
qss = qss_path.read_text(encoding="utf-8")
|
||||
QApplication.instance().setStyleSheet(qss)
|
||||
except Exception as e:
|
||||
logger.warning("No se pudo aplicar el tema %s: %s", theme, e)
|
||||
|
||||
def _on_about(self) -> None:
|
||||
QMessageBox.about(
|
||||
self,
|
||||
self._strings.get("about_title", "Acerca de AR-ShipDesign"),
|
||||
f"""<b>AR-ShipDesign</b> v{__version__}<br>
|
||||
Software profesional de diseño naval.<br><br>
|
||||
Motor geométrico: NURBS (geomdl)<br>
|
||||
Visualización 3D: PyVista + VTK<br>
|
||||
Estándares: ISO 12215, IMO IS Code 2008<br><br>
|
||||
{self._strings.get("about_copyright", "Copyright © 2025 Álvaro Rodríguez")}""",
|
||||
)
|
||||
|
||||
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:
|
||||
act = QAction("(sin archivos recientes)", self)
|
||||
act.setEnabled(False)
|
||||
self._recent_menu.addAction(act)
|
||||
return
|
||||
for path in recent:
|
||||
act = QAction(Path(path).name, self)
|
||||
act.setToolTip(path)
|
||||
act.triggered.connect(lambda checked=False, p=path: self._open_recent(p))
|
||||
self._recent_menu.addAction(act)
|
||||
|
||||
def _open_recent(self, path: str) -> None:
|
||||
if self._project and self._project.is_modified:
|
||||
if not self._ask_save_changes():
|
||||
return
|
||||
try:
|
||||
self._project = Project.load(Path(path))
|
||||
add_recent_file(path)
|
||||
self._on_project_loaded()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"No se pudo abrir:\n{e}")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# GEOMETRÍA Y ESTADO
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
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:
|
||||
if not self._ask_save_changes():
|
||||
event.ignore()
|
||||
return
|
||||
s = get_settings()
|
||||
s.setValue("ui/windowGeometry", self.saveGeometry())
|
||||
s.setValue("ui/windowState", self.saveState())
|
||||
event.accept()
|
||||
Reference in New Issue
Block a user