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:
2026-05-26 22:10:18 -04:00
commit 0dbc2a4518
266 changed files with 4249 additions and 0 deletions
+802
View File
@@ -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()