feat: AR-Shipdesign initial commit

This commit is contained in:
2026-07-03 12:23:25 -04:00
parent 588735ea64
commit 9a08526361
16 changed files with 4431 additions and 394 deletions
+496 -150
View File
@@ -21,7 +21,7 @@ import json
from pathlib import Path
from typing import Optional
from PySide6.QtCore import Qt, QSize, Signal
from PySide6.QtCore import Qt, QSize, Signal, QThread, QObject
from PySide6.QtGui import QAction, QFont, QKeySequence, QIcon
from PySide6.QtWidgets import (
QApplication,
@@ -37,6 +37,7 @@ from PySide6.QtWidgets import (
QSplitter,
QStackedWidget,
QToolBar,
QPushButton,
QToolButton,
QVBoxLayout,
QWidget,
@@ -46,6 +47,7 @@ from PySide6.QtWidgets import (
from arshipdesign import __version__
from arshipdesign.core.project import Project
from arshipdesign.ui.icons import icon as _ico
from arshipdesign.utils.logger import get_logger
from arshipdesign.stability import compute_gz_wall_sided, GZCurve, check_imo_is2008
from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget
@@ -184,12 +186,15 @@ _VIEW_LABELS: dict[str, str] = {
class ViewportFrame(QFrame):
"""Un viewport individual con barra de título."""
"""Un viewport individual con barra de título y botón de maximizar."""
maximize_requested = Signal(str) # emite view_type al pedir maximizar/restaurar
def __init__(self, view_type: str, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.view_type = view_type
self.setObjectName("viewportFrame")
self._maximized = False
self._build_ui()
def _build_ui(self) -> None:
@@ -197,19 +202,30 @@ class ViewportFrame(QFrame):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── Barra de título (objectName único por vista) ──────────
# ── Barra de título ───────────────────────────────────────
title_bar = QWidget()
# p.ej. "viewportTitleBar_perspective", "viewportTitleBar_profile"…
title_bar.setObjectName(f"viewportTitleBar_{self.view_type}")
title_bar.setFixedHeight(24)
title_bar.setCursor(Qt.CursorShape.PointingHandCursor)
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)
self._title_lbl = QLabel(_VIEW_LABELS.get(self.view_type, self.view_type).upper())
self._title_lbl.setObjectName(f"viewportTitle_{self.view_type}")
tbl.addWidget(self._title_lbl)
tbl.addStretch()
# Botón maximizar / restaurar
self._max_btn = QPushButton("")
self._max_btn.setObjectName("viewportMaxBtn")
self._max_btn.setFixedSize(20, 20)
self._max_btn.setFlat(True)
self._max_btn.setToolTip("Maximizar / Restaurar (doble clic en la barra)")
self._max_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._max_btn.clicked.connect(lambda: self.maximize_requested.emit(self.view_type))
tbl.addWidget(self._max_btn)
layout.addWidget(title_bar)
# ── Área de dibujo (placeholder Sprint 0) ────────────────
@@ -224,6 +240,14 @@ class ViewportFrame(QFrame):
cl.addWidget(ph)
layout.addWidget(self._canvas, 1)
# Doble clic en la barra también maximiza
title_bar.mouseDoubleClickEvent = lambda _e: self.maximize_requested.emit(self.view_type)
def set_maximized_icon(self, maximized: bool) -> None:
"""Actualiza el icono del botón según el estado."""
self._max_btn.setText("" if maximized else "")
self._max_btn.setToolTip("Restaurar (doble clic)" if maximized else "Maximizar (doble clic)")
def set_canvas(self, widget: QWidget) -> None:
"""Sprint 1: sustituye el placeholder por el widget 3D / 2D real."""
lo = self.layout()
@@ -249,6 +273,7 @@ class FourViewport(QWidget):
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setObjectName("fourViewport")
self._maximized_view: Optional[str] = None
self._build_ui()
def _build_ui(self) -> None:
@@ -256,34 +281,39 @@ class FourViewport(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
v_split = QSplitter(Qt.Orientation.Vertical)
v_split.setObjectName("viewportSplitter")
v_split.setHandleWidth(5)
self._v_split = QSplitter(Qt.Orientation.Vertical)
self._v_split.setObjectName("viewportSplitter")
self._v_split.setHandleWidth(5)
top_split = QSplitter(Qt.Orientation.Horizontal)
top_split.setObjectName("viewportSplitter")
top_split.setHandleWidth(5)
self._top_split = QSplitter(Qt.Orientation.Horizontal)
self._top_split.setObjectName("viewportSplitter")
self._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])
self._top_split.addWidget(self._vp_perspective)
self._top_split.addWidget(self._vp_profile)
self._top_split.setSizes([600, 600])
bot_split = QSplitter(Qt.Orientation.Horizontal)
bot_split.setObjectName("viewportSplitter")
bot_split.setHandleWidth(5)
self._bot_split = QSplitter(Qt.Orientation.Horizontal)
self._bot_split.setObjectName("viewportSplitter")
self._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])
self._bot_split.addWidget(self._vp_bodyplan)
self._bot_split.addWidget(self._vp_plan)
self._bot_split.setSizes([600, 600])
v_split.addWidget(top_split)
v_split.addWidget(bot_split)
v_split.setSizes([400, 400])
layout.addWidget(v_split)
self._v_split.addWidget(self._top_split)
self._v_split.addWidget(self._bot_split)
self._v_split.setSizes([400, 400])
layout.addWidget(self._v_split)
def viewport(self, view_type: str) -> Optional[ViewportFrame]:
# Conectar señal de maximizar de cada frame
for vp in (self._vp_perspective, self._vp_profile,
self._vp_bodyplan, self._vp_plan):
vp.maximize_requested.connect(self.toggle_maximize)
def viewport(self, view_type: str) -> Optional["ViewportFrame"]:
return {
"perspective": self._vp_perspective,
"profile": self._vp_profile,
@@ -291,6 +321,59 @@ class FourViewport(QWidget):
"plan": self._vp_plan,
}.get(view_type)
def toggle_maximize(self, view_type: str) -> None:
"""Maximiza el viewport indicado, o restaura el layout de 4 paneles."""
if self._maximized_view == view_type:
self._restore_layout()
else:
self._maximize(view_type)
def _maximize(self, view_type: str) -> None:
"""Colapsa los otros tres paneles y expande el solicitado."""
companion_map = {
"perspective": self._vp_profile,
"profile": self._vp_perspective,
"bodyplan": self._vp_plan,
"plan": self._vp_bodyplan,
}
# Ocultar el otro viewport de la misma fila
companion_map[view_type].hide()
# Ocultar la fila opuesta completa
if view_type in ("perspective", "profile"):
self._bot_split.hide()
else:
self._top_split.hide()
self._maximized_view = view_type
all_vps = {
"perspective": self._vp_perspective,
"profile": self._vp_profile,
"bodyplan": self._vp_bodyplan,
"plan": self._vp_plan,
}
for name, vp in all_vps.items():
vp.set_maximized_icon(name == view_type)
def _restore_layout(self) -> None:
"""Restaura el layout normal de 4 paneles iguales."""
for vp in (self._vp_perspective, self._vp_profile,
self._vp_bodyplan, self._vp_plan):
vp.show()
self._top_split.show()
self._bot_split.show()
# Restaurar proporciones iguales
w = max(self.width(), 100)
h = max(self.height(), 100)
self._v_split.setSizes([h // 2, h // 2])
self._top_split.setSizes([w // 2, w // 2])
self._bot_split.setSizes([w // 2, w // 2])
self._maximized_view = None
for vp in (self._vp_perspective, self._vp_profile,
self._vp_bodyplan, self._vp_plan):
vp.set_maximized_icon(False)
# ─────────────────────────────────────────────────────────────────────────────
# PANEL DE CAPAS (estilo DELFTship)
@@ -792,6 +875,54 @@ class RibbonBar(QWidget):
return group
# ─────────────────────────────────────────────────────────────────────────────
# WORKER: cálculo GZ en hilo separado (evita freeze del UI thread)
# ─────────────────────────────────────────────────────────────────────────────
class _HydroWorker(QObject):
"""Calcula HydrostaticCurves en un hilo secundario."""
finished = Signal(object) # HydrostaticCurves
error = Signal(str)
def __init__(self, hull, n_points: int = 30, rho: float = 1025.0) -> None:
super().__init__()
self._hull = hull
self._n_points = n_points
self._rho = rho
def run(self) -> None:
try:
from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves
curves = HydrostaticCurves.compute(
self._hull, n_points=self._n_points, rho=self._rho
)
self.finished.emit(curves)
except Exception as exc:
self.error.emit(str(exc))
class _GZWorker(QObject):
"""Ejecuta compute_gz_wall_sided en un QThread para no bloquear la UI."""
finished = Signal(object, object) # (GZCurve, ImoResult)
error = Signal(str)
def __init__(self, hull, draft: float, kg: float) -> None:
super().__init__()
self._hull = hull
self._draft = draft
self._kg = kg
def run(self) -> None:
try:
from arshipdesign.stability import compute_gz_wall_sided, check_imo_is2008
gz_curve = compute_gz_wall_sided(self._hull, self._draft, kg=self._kg)
imo_result = check_imo_is2008(gz_curve)
self.finished.emit(gz_curve, imo_result)
except Exception as exc:
self.error.emit(str(exc))
# ─────────────────────────────────────────────────────────────────────────────
# VENTANA PRINCIPAL
# ─────────────────────────────────────────────────────────────────────────────
@@ -816,6 +947,15 @@ class MainWindow(QMainWindow):
self._project: Optional[Project] = None
self._current_hull = None # Hull activo en todos los visores
self._gz_widget: Optional[GZCurveWidget] = None
self._gz_thread: Optional[QThread] = None
self._gz_worker = None
self._hydro_thread: Optional[QThread] = None
self._hydro_worker = None
# ── Historial de deshacer / rehacer ───────────────────────────
self._undo_stack: list[dict] = [] # estados anteriores (más viejo → índice 0)
self._redo_stack: list[dict] = [] # estados rehechos disponibles
self._last_hull_state: Optional[dict] = None # snapshot ANTES del último edit
self._MAX_UNDO = 50
self._lang = get_language()
self._strings = _load_i18n(self._lang)
self._setup_ui()
@@ -866,10 +1006,17 @@ class MainWindow(QMainWindow):
# Edición live durante drag → actualizar vistas cruzadas sin resetear zoom
self._viewer_bodyplan.offsets_dragging.connect(self._on_offsets_dragging)
self._viewer_profile.offsets_dragging.connect(self._on_offsets_dragging)
self._viewer_plan.offsets_dragging.connect(self._on_offsets_dragging)
# Fin del drag → persistir + actualizar 3D + hidrostáticos
self._viewer_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
self._viewer_profile.offsets_edited.connect(self._on_offsets_edited_from_viewer)
self._viewer_plan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
# Selección cruzada de nodo: resaltar en las otras dos vistas
self._viewer_bodyplan.node_selected.connect(self._on_node_selected_in_viewer)
self._viewer_profile.node_selected.connect(self._on_node_selected_in_viewer)
self._viewer_plan.node_selected.connect(self._on_node_selected_in_viewer)
# Zoom independiente por visor — sin sincronización de escala
# Editor interactivo de offsets (sustituye el placeholder MOD_OFFSETS)
from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor
@@ -950,117 +1097,131 @@ class MainWindow(QMainWindow):
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",
g.add_button(_ico("4views"), "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",
g.add_button(_ico("lines_plan"), "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",
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.add_button(_ico("wizard"), "Asistente", "Asistente de embarcación", enabled=False)
g.add_button(_ico("hull_nurbs"), "Casco NURBS", "Nuevo casco NURBS", enabled=False)
g.add_button(_ico("appendage"), "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.add_button(_ico("ctrl_pts"), "Pts. Ctrl.", "Editar puntos de control", enabled=False)
g.add_button(_ico("extrude"), "Extruir", "Extruir sección", enabled=False)
g.add_button(_ico("mirror"), "Espejo", "Espejo por eje de crujía", enabled=False)
g.add_button(_ico("lackenby"), "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.add_button(_ico("import_offsets"), "Offsets", "Importar tabla de offsets (.txt / .csv)", enabled=False)
g.add_button(_ico("import_dxf"), "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)
g.add_button(_ico("export_iges"), "IGES", "Exportar IGES", enabled=False)
g.add_button(_ico("export_step"), "STEP", "Exportar STEP", enabled=False)
g.add_button(_ico("export_dxf"), "DXF", "Exportar DXF", enabled=False)
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Suavizado")
g.add_button(_ico("smooth"),
"Suavizar",
"Un paso de suavizado Laplaciano global — presiona varias veces",
self._fair_hull_step)
g.add_button(_ico("combs"),
"Peines",
"[C] Mostrar/ocultar peines de curvatura — selecciona una curva con Shift+clic primero",
self._toggle_curvature_display)
g.add_button(_ico("fairness"),
"Equidad",
"[F] Mostrar/ocultar coloreo de equidad (verde=suave, rojo=quiebre)",
self._toggle_fairness_display)
# ── Tab ANÁLISIS ──────────────────────────────────────────
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Hidrostática")
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calcular", "Calcular curvas hidrostáticas",
g.add_button(_ico("hydro_calc"), "Calcular", "Calcular curvas hidrostáticas",
self._on_compute_hydrostatics)
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curvas", "Curvas hidrostáticas",
g.add_button(_ico("hydro_curves"), "Curvas", "Curvas hidrostáticas",
self._on_show_hydrostatics)
g.add_button(_spi(sp.SP_DialogSaveButton), "Exp. CSV", "Exportar curvas como CSV",
g.add_button(_ico("export_csv"), "Exp. CSV", "Exportar curvas como CSV",
self._on_export_hydrostatics_csv)
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad")
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curva GZ", "Curva GZ estática",
g.add_button(_ico("gz_curve"), "Curva GZ", "Curva GZ estática",
self._on_show_stability)
g.add_button(_spi(sp.SP_FileDialogDetailedView), "IMO IS2008", "Criterios IMO IS Code 2008", enabled=False)
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Avería", "Estabilidad en avería", enabled=False)
g.add_button(_ico("imo"), "IMO IS2008", "Criterios IMO IS Code 2008", enabled=False)
g.add_button(_ico("damage"), "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",
g.add_button(_ico("holtrop"), "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",
g.add_button(_ico("savitsky"), "Savitsky", "Savitsky (planeo)", enabled=False)
g.add_button(_ico("vpp"), "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)",
g.add_button(_ico("stf"), "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.add_button(_ico("spectrum"), "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",
g.add_button(_ico("iso12215"), "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",
g.add_button(_ico("new_tank"), "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.add_button(_ico("model_tank"), "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",
g.add_button(_ico("load_case"), "Nuevo caso", "Definir caso de carga", enabled=False)
g.add_button(_ico("sounding"), "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)
g.add_button(_ico("calc_kg"), "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)",
g.add_button(_ico("epla"), "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",
g.add_button(_ico("fuel"), "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",
g.add_button(_ico("freshwater"), "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",
g.add_button(_ico("bilge"), "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",
g.add_button(_ico("firefight"), "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",
g.add_button(_ico("pipes"), "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",
g.add_button(_ico("cables"), "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",
g.add_button(_ico("hvac"), "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)
g.add_button(_ico("steering"), "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)",
g.add_button(_ico("materials"), "Materiales", "Estimación de materiales", enabled=False)
g.add_button(_ico("nesting"), "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.add_button(_ico("gcode"), "G-code", "Generar G-code", enabled=False)
g.add_button(_ico("postproc"), "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",
g.add_button(_ico("lofting"), "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)
g.add_button(_ico("laminate"), "Laminado", "Schedule de laminado", enabled=False)
g.add_button(_ico("resin"), "Resina", "Calculadora de resina", enabled=False)
g.add_button(_ico("bom"), "BOM", "BOM de materiales", enabled=False)
def _setup_menu(self) -> None:
mb = self.menuBar()
@@ -1082,8 +1243,10 @@ class MainWindow(QMainWindow):
# ── 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)
self._act_undo = self._add_action(m, "Deshacer", QKeySequence.StandardKey.Undo,
slot=self._undo, enabled=False)
self._act_redo = self._add_action(m, "Rehacer", QKeySequence.StandardKey.Redo,
slot=self._redo, enabled=False)
m.addSeparator()
self._add_action(m, "Preferencias...", slot=self._on_preferences)
@@ -1250,9 +1413,11 @@ class MainWindow(QMainWindow):
self._project = Project.new(hull.name if hull else "Proyecto sin título")
self._on_project_loaded()
if hull is not None:
hull.snap_boundary_nodes_to_contours()
self._current_hull = hull
self._project.set_hull(hull) # persistir en ship_data
self._load_hull_viewers(hull)
self._reset_undo_history(hull)
self.statusBar().showMessage(
f"Nuevo proyecto: {self._project.name}"
)
@@ -1273,7 +1438,9 @@ class MainWindow(QMainWindow):
self._on_project_loaded()
self.statusBar().showMessage(f"Abierto: {path}")
except Exception as e:
QMessageBox.critical(self, "Error al abrir", str(e))
logger.error("Error al abrir proyecto '%s': %s", path, e)
QMessageBox.critical(self, "Error al abrir",
"No se pudo abrir el archivo. Consulte el log para más detalles.")
def _on_save_project(self) -> None:
if not self._project:
@@ -1286,7 +1453,9 @@ class MainWindow(QMainWindow):
self._update_title()
self.statusBar().showMessage(f"Guardado: {self._project.path}")
except Exception as e:
QMessageBox.critical(self, "Error al guardar", str(e))
logger.error("Error al guardar proyecto: %s", e)
QMessageBox.critical(self, "Error al guardar",
"No se pudo guardar el archivo. Consulte el log para más detalles.")
def _on_save_as(self) -> None:
if not self._project:
@@ -1305,7 +1474,9 @@ class MainWindow(QMainWindow):
self._update_title()
self.statusBar().showMessage(f"Guardado como: {path}")
except Exception as e:
QMessageBox.critical(self, "Error al guardar", str(e))
logger.error("Error al guardar proyecto como '%s': %s", path, e)
QMessageBox.critical(self, "Error al guardar",
"No se pudo guardar el archivo. Consulte el log para más detalles.")
def _on_project_loaded(self) -> None:
self._update_title()
@@ -1315,6 +1486,7 @@ class MainWindow(QMainWindow):
if hull is not None:
self._current_hull = hull
self._load_hull_viewers(hull)
self._reset_undo_history(hull)
logger.info("Hull '%s' restaurado desde proyecto", hull.name)
def _load_hull_viewers(self, hull, *, _skip_offsets_editor: bool = False) -> None:
@@ -1323,10 +1495,11 @@ class MainWindow(QMainWindow):
``_skip_offsets_editor=True`` evita el bucle de retroalimentacion cuando
la llamada proviene del propio editor de offsets.
"""
# ── Visores 2D ────────────────────────────────────────────
# ── Visores 2D — cargar y unificar escala px/m ────────────
self._viewer_bodyplan.set_hull(hull)
self._viewer_profile.set_hull(hull)
self._viewer_plan.set_hull(hull)
# Cada visor ajusta su propio zoom con _fit_to_view al recibir set_hull.
# ── Editor de offsets ─────────────────────────────────────
if not _skip_offsets_editor:
self._offsets_editor.set_hull(hull)
@@ -1376,7 +1549,14 @@ class MainWindow(QMainWindow):
hull = self._current_hull
if hull is None:
return
# hull.offsets ya fue modificado in-place durante el drag.
# ── Capturar estado ANTERIOR para Deshacer (Ctrl+Z) ──────────
if self._last_hull_state is not None:
self._undo_stack.append(self._last_hull_state)
if len(self._undo_stack) > self._MAX_UNDO:
self._undo_stack.pop(0)
self._redo_stack.clear() # nueva rama invalida el redo
self._act_undo.setEnabled(True)
self._act_redo.setEnabled(False)
# Invalidar caché NURBS para que to_mesh() reconstruya desde los
# offsets editados y no devuelva la geometría anterior.
hull.invalidate()
@@ -1396,38 +1576,170 @@ class MainWindow(QMainWindow):
logger.warning("Error al actualizar visor 3D: %s", exc)
# Barra de hidrostáticos
self._update_hydrostatics(hull)
# Guardar estado actual como referencia para el próximo Deshacer
self._last_hull_state = hull.to_dict()
self.statusBar().showMessage(f"Geometría editada — {hull.name}")
# ── Deshacer / Rehacer ────────────────────────────────────────────────────
def _reset_undo_history(self, hull) -> None:
"""Limpia ambos stacks y captura el estado inicial del casco."""
self._undo_stack.clear()
self._redo_stack.clear()
self._last_hull_state = hull.to_dict()
self._act_undo.setEnabled(False)
self._act_redo.setEnabled(False)
def _undo(self) -> None:
"""Ctrl+Z — restaura el estado anterior al último drag/edición."""
if not self._undo_stack or self._current_hull is None:
return
from arshipdesign.core.hull import Hull
# Guardar estado actual en redo antes de revertir
self._redo_stack.append(self._current_hull.to_dict())
# Restaurar estado anterior
state = self._undo_stack.pop()
hull = Hull.from_dict(state)
self._current_hull = hull
if self._project is not None:
self._project.set_hull(hull)
self._load_hull_viewers(hull)
self._last_hull_state = hull.to_dict()
# Actualizar acciones
self._act_undo.setEnabled(bool(self._undo_stack))
self._act_redo.setEnabled(True)
self.statusBar().showMessage(
f"Deshacer — {len(self._undo_stack)} paso(s) disponibles"
)
def _redo(self) -> None:
"""Ctrl+Y — rehace el último cambio deshecho."""
if not self._redo_stack or self._current_hull is None:
return
from arshipdesign.core.hull import Hull
# Guardar estado actual en undo
self._undo_stack.append(self._current_hull.to_dict())
# Restaurar estado rehechos
state = self._redo_stack.pop()
hull = Hull.from_dict(state)
self._current_hull = hull
if self._project is not None:
self._project.set_hull(hull)
self._load_hull_viewers(hull)
self._last_hull_state = hull.to_dict()
# Actualizar acciones
self._act_undo.setEnabled(True)
self._act_redo.setEnabled(bool(self._redo_stack))
self.statusBar().showMessage(
f"Rehacer — {len(self._redo_stack)} paso(s) por rehacer"
)
def _on_node_selected_in_viewer(self, idx) -> None:
"""Propaga la selección de nodo a las otras dos vistas como anillo cian."""
sender = self.sender()
for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan):
if v is not sender:
v.set_peer_selection(idx)
def _on_viewer_scale_changed(self, scale: float) -> None:
"""Sincroniza el zoom: aplica la misma escala px/m a los otros dos visores."""
sender = self.sender()
for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan):
if v is not sender:
v.center_at_scale(scale)
def _fair_hull_step(self) -> None:
"""Un paso de suavizado Laplaciano sobre los offsets del casco activo.
Aplica el filtro y[i] = 0.5·y[i] + 0.25·(y[i-1] + y[i+1]) a cada
línea de agua, preservando las estaciones de borde (AP, FP).
Presionar el botón "Suavizar" varias veces para suavizar gradualmente.
"""
import numpy as np # local — numpy no es importado en el módulo
hull = self._current_hull
if hull is None:
return
ot = hull.offsets
if ot.n_stations < 3 or ot.n_waterlines < 3:
return
data = ot.data.copy()
# Laplaciano 1-D sobre el eje longitudinal (estaciones), bordes fijos
for j in range(ot.n_waterlines):
for i in range(1, ot.n_stations - 1):
data[i, j] = (0.5 * data[i, j]
+ 0.25 * (data[i - 1, j] + data[i + 1, j]))
ot.data[:] = np.maximum(0.0, data)
hull.invalidate()
if self._project is not None:
self._project.set_hull(hull)
self._viewer_bodyplan.update_offsets(hull)
self._viewer_profile.update_offsets(hull)
self._viewer_plan.update_offsets(hull)
self._offsets_editor.set_hull(hull)
if self._viewer_3d is not None:
try:
self._viewer_3d.load_hull(hull)
except Exception as exc:
logger.warning("Error al actualizar visor 3D tras suavizado: %s", exc)
self._update_hydrostatics(hull)
self.statusBar().showMessage("Suavizado — un paso Laplaciano aplicado")
def _toggle_curvature_display(self) -> None:
"""Activa/desactiva los peines de curvatura en los tres visores 2D.
Los pelos son perpendiculares a la curva — su longitud es proporcional
a la curvatura local (normalizada). Usar Shift+clic sobre una curva
para ver los pelos solo en esa curva.
"""
for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan):
v._show_curvature = not v._show_curvature
v.update()
any_on = self._viewer_bodyplan._show_curvature
self.statusBar().showMessage(
"Peines ON — Shift+clic sobre una curva para enfocar [C] para apagar"
if any_on else "Peines OFF"
)
def _toggle_fairness_display(self) -> None:
"""Activa/desactiva el coloreo de equidad en los tres visores 2D.
Verde = nodo suave (segunda derivada baja).
Rojo = quiebre brusco de curvatura — candidato para suavizar con [S].
"""
for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan):
v._show_fairness = not v._show_fairness
v.update()
any_on = self._viewer_bodyplan._show_fairness
self.statusBar().showMessage(
"Equidad ON — verde=suave, rojo=quiebre [S]=suavizar nodo seleccionado"
if any_on else "Equidad OFF"
)
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.
Usa compute_upright en una sola pasada (una sola integración) en lugar
de llamar a cada método del Hull por separado (que haría 9× to_sections).
"""
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)
from arshipdesign.hydrostatics.upright import compute_upright
T = hull.draft
h = compute_upright(hull, 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}",
"Δ": f"{h.displacement:.1f} t",
"LCB": f"{h.lcb:.2f}",
"KB": f"{h.kb:.2f}",
"KMT": f"{h.kmt:.2f}",
"GMT": "",
"TPC": f"{h.tpc:.3f}",
"MCT": f"{h.mct:.2f}",
"Cb": f"{h.cb:.3f}",
"Cw": f"{h.cw:.3f}",
"Cm": f"{h.cm:.3f}",
})
except Exception as exc:
logger.warning("Error al calcular hidrostáticos: %s", exc)
@@ -1437,31 +1749,43 @@ class MainWindow(QMainWindow):
# ─────────────────────────────────────────────────────────
def _on_compute_hydrostatics(self) -> None:
"""Calcula las curvas hidrostáticas y muestra el módulo."""
"""Lanza el cálculo de curvas hidrostáticas en un hilo secundario."""
if self._current_hull is None:
from PySide6.QtWidgets import QMessageBox
QMessageBox.information(
self, "Sin casco", "Crea o abre un proyecto con un casco definido."
)
return
try:
from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves
self.statusBar().showMessage("Calculando curvas hidrostáticas…")
QApplication.processEvents()
curves = HydrostaticCurves.compute(
self._current_hull, n_points=30, rho=1025.0
)
self._hydro_chart.set_curves(curves)
self._module_area.activate(ModuleArea.MOD_CURVES)
self.statusBar().showMessage(
f"Curvas hidrostáticas calculadas — {curves.hull_name} "
f"({len(curves.points)} puntos, T: "
f"{curves.drafts[0]:.2f}{curves.drafts[-1]:.2f} m)"
)
except Exception as exc:
logger.error("Error al calcular curvas: %s", exc)
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error al calcular", str(exc))
# Evitar dos cálculos simultáneos
if hasattr(self, "_hydro_thread") and self._hydro_thread is not None:
if self._hydro_thread.isRunning():
return
self.statusBar().showMessage("Calculando curvas hidrostáticas…")
worker = _HydroWorker(self._current_hull, n_points=30, rho=1025.0)
thread = QThread(self)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._on_hydro_done)
worker.error.connect(lambda msg: (
logger.error("Error curvas hidro: %s", msg),
self.statusBar().showMessage(f"Error: {msg}"),
))
worker.finished.connect(thread.quit)
worker.error.connect(thread.quit)
self._hydro_thread = thread
self._hydro_worker = worker
thread.start()
def _on_hydro_done(self, curves) -> None:
"""Callback en hilo principal cuando el cálculo hidrostático termina."""
self._hydro_chart.set_curves(curves)
self._module_area.activate(ModuleArea.MOD_CURVES)
self.statusBar().showMessage(
f"Curvas hidrostáticas calculadas — {curves.hull_name} "
f"({len(curves.points)} puntos, T: "
f"{curves.drafts[0]:.2f}{curves.drafts[-1]:.2f} m)"
)
def _on_show_hydrostatics(self) -> None:
"""Muestra el módulo de curvas (sin recalcular si ya hay datos)."""
@@ -1494,39 +1818,59 @@ class MainWindow(QMainWindow):
except Exception as exc:
logger.error("Error al exportar CSV: %s", exc)
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error al exportar", str(exc))
QMessageBox.critical(self, "Error al exportar",
"No se pudo exportar el CSV. Consulte el log para más detalles.")
# ─────────────────────────────────────────────────────────
# CURVA GZ — ESTABILIDAD
# ─────────────────────────────────────────────────────────
def _compute_and_show_gz(self) -> None:
"""Calcula la curva GZ wall-sided y actualiza el widget de estabilidad."""
if self._current_hull is None:
"""Lanza el cálculo GZ en un hilo separado para no bloquear la UI."""
if self._current_hull is None or self._gz_widget is None:
return
# Evitar lanzar dos cálculos simultáneos
if hasattr(self, "_gz_thread") and self._gz_thread is not None:
if self._gz_thread.isRunning():
return
hull = self._current_hull
kg = hull.depth * 0.55
self.statusBar().showMessage("Calculando curva GZ…")
self._gz_worker = _GZWorker(hull, hull.draft, kg)
self._gz_thread = QThread(self)
self._gz_worker.moveToThread(self._gz_thread)
self._gz_thread.started.connect(self._gz_worker.run)
self._gz_worker.finished.connect(self._on_gz_done)
self._gz_worker.error.connect(
lambda msg: (
logger.warning("Error GZ: %s", msg),
self.statusBar().showMessage(f"Error GZ: {msg}"),
)
)
self._gz_worker.finished.connect(self._gz_thread.quit)
self._gz_worker.error.connect(self._gz_thread.quit)
self._gz_thread.start()
def _on_gz_done(self, gz_curve, imo_result) -> None:
"""Callback en hilo principal cuando el cálculo GZ termina."""
if self._gz_widget is None:
return
try:
hull = self._current_hull
kg = hull.depth * 0.55
self.statusBar().showMessage("Calculando curva GZ…")
QApplication.processEvents()
gz_curve = compute_gz_wall_sided(hull, hull.draft, kg=kg)
imo_result = check_imo_is2008(gz_curve)
self._gz_widget.set_curve(gz_curve, imo_result)
# Actualizar indicador IMO en la barra de hidrostáticos
self._hydro.set_imo_status(
imo_result.overall_passed,
"" if imo_result.overall_passed else "GZ",
)
self.statusBar().showMessage(
f"Curva GZ calculada — {hull.name} "
f"GM={gz_curve.gm:.3f}m GZmax={gz_curve.gz_max:.3f}m "
f"AVS={gz_curve.avs:.0f}° "
f"IMO={'CUMPLE' if imo_result.overall_passed else 'FALLA'}"
)
except Exception as exc:
logger.warning("Error al calcular curva GZ: %s", exc)
self._gz_widget.set_curve(gz_curve, imo_result)
self._hydro.set_imo_status(
imo_result.overall_passed,
"" if imo_result.overall_passed else "GZ",
)
hull = self._current_hull
name = hull.name if hull else ""
self.statusBar().showMessage(
f"Curva GZ calculada — {name} "
f"GM={gz_curve.gm:.3f}m GZmax={gz_curve.gz_max:.3f}m "
f"AVS={gz_curve.avs:.0f}° "
f"IMO={'CUMPLE' if imo_result.overall_passed else 'FALLA'}"
)
def _on_show_stability(self) -> None:
"""Muestra el módulo de estabilidad GZ (calcula si hay casco disponible)."""
@@ -1607,7 +1951,9 @@ class MainWindow(QMainWindow):
add_recent_file(path)
self._on_project_loaded()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
logger.error("Error al abrir archivo reciente '%s': %s", path, e)
QMessageBox.critical(self, "Error",
"No se pudo abrir el archivo reciente. Consulte el log para más detalles.")
# ─────────────────────────────────────────────────────────
# GEOMETRÍA DE VENTANA