feat: AR-Shipdesign initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+496
-150
@@ -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
|
||||
|
||||
@@ -370,7 +370,9 @@ class OffsetsEditor(QWidget):
|
||||
Path(path).write_text(buf.getvalue(), encoding="utf-8")
|
||||
logger.info("Offsets exportados a %s", path)
|
||||
except Exception as exc:
|
||||
QMessageBox.critical(self, "Error al exportar", str(exc))
|
||||
logger.error("Error al exportar offsets: %s", exc)
|
||||
QMessageBox.critical(self, "Error al exportar",
|
||||
"No se pudieron exportar los offsets. Consulte el log para más detalles.")
|
||||
|
||||
def _on_import_csv(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
@@ -424,7 +426,9 @@ class OffsetsEditor(QWidget):
|
||||
self.hull_changed.emit(new_hull)
|
||||
logger.info("Offsets importados desde %s", path)
|
||||
except Exception as exc:
|
||||
QMessageBox.critical(self, "Error al importar", str(exc))
|
||||
logger.error("Error al importar offsets: %s", exc)
|
||||
QMessageBox.critical(self, "Error al importar",
|
||||
"No se pudieron importar los offsets. Consulte el log para más detalles.")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Info label
|
||||
|
||||
@@ -18,7 +18,7 @@ from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
logger = logging.getLogger("ui.viewer_3d")
|
||||
|
||||
@@ -57,6 +57,9 @@ class Viewer3DWidget(QWidget):
|
||||
self._plotter: Optional["QtInteractor"] = None
|
||||
self._ready = False
|
||||
self._pending_hull = None # hull recibido antes de que el plotter esté listo
|
||||
self._hull_actor = None # actor VTK del casco — para toggle de aristas
|
||||
self._show_edges = False # mallas apagadas por defecto (como Delftship)
|
||||
self._edge_btn: Optional[QPushButton] = None
|
||||
self._build_ui()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -96,7 +99,26 @@ class Viewer3DWidget(QWidget):
|
||||
old.hide()
|
||||
old.deleteLater()
|
||||
|
||||
# Crear el interactor PyVista embebido
|
||||
# ── Barra de herramientas 3D ───────────────────────────────
|
||||
toolbar = QWidget()
|
||||
toolbar.setObjectName("viewer3dToolbar")
|
||||
tb_lo = QHBoxLayout(toolbar)
|
||||
tb_lo.setContentsMargins(4, 2, 4, 2)
|
||||
tb_lo.setSpacing(4)
|
||||
|
||||
self._edge_btn = QPushButton("⬡ Mallas")
|
||||
self._edge_btn.setCheckable(True)
|
||||
self._edge_btn.setChecked(self._show_edges)
|
||||
self._edge_btn.setFixedHeight(22)
|
||||
self._edge_btn.setToolTip(
|
||||
"Mostrar / ocultar aristas de la malla (como Delftship)"
|
||||
)
|
||||
self._edge_btn.toggled.connect(self._on_edge_toggled)
|
||||
tb_lo.addWidget(self._edge_btn)
|
||||
tb_lo.addStretch()
|
||||
lo.addWidget(toolbar)
|
||||
|
||||
# ── Interactor PyVista embebido ────────────────────────────
|
||||
self._plotter = QtInteractor(
|
||||
parent=self,
|
||||
auto_update=False, # sin polling continuo de GPU
|
||||
@@ -192,15 +214,22 @@ class Viewer3DWidget(QWidget):
|
||||
return
|
||||
self._plotter.clear()
|
||||
|
||||
# Casco principal — color acero naval
|
||||
self._plotter.add_mesh(
|
||||
# Casco principal — color sólido estilo DelftShip
|
||||
# smooth_shading=False → facetas planas, aspecto sólido sin blur
|
||||
# ambient alto → menos sombras duras, color uniforme
|
||||
# specular bajo → sin brillos que difuminen el color
|
||||
self._hull_actor = self._plotter.add_mesh(
|
||||
mesh,
|
||||
color="#3a6080",
|
||||
smooth_shading=True,
|
||||
show_edges=True,
|
||||
edge_color="#4da8ff",
|
||||
line_width=0.3,
|
||||
opacity=0.92,
|
||||
color="#4a8ab0", # azul acero más vivo
|
||||
smooth_shading=False, # facetado / sólido (no blur)
|
||||
show_edges=self._show_edges,
|
||||
edge_color="#90c8f0",
|
||||
line_width=0.6,
|
||||
opacity=1.0, # totalmente opaco
|
||||
ambient=0.40, # luz ambiente alta: sombras suaves
|
||||
diffuse=0.60, # difuso moderado
|
||||
specular=0.05, # casi sin especular (anti-blur)
|
||||
specular_power=5,
|
||||
name="hull",
|
||||
)
|
||||
|
||||
@@ -226,6 +255,17 @@ class Viewer3DWidget(QWidget):
|
||||
self._plotter.view_isometric()
|
||||
self._plotter.reset_camera()
|
||||
|
||||
def _on_edge_toggled(self, checked: bool) -> None:
|
||||
"""Activa / desactiva la malla de aristas sin re-renderizar el casco."""
|
||||
self._show_edges = checked
|
||||
if self._hull_actor is not None and self._plotter is not None:
|
||||
prop = self._hull_actor.GetProperty()
|
||||
if checked:
|
||||
prop.EdgeVisibilityOn()
|
||||
else:
|
||||
prop.EdgeVisibilityOff()
|
||||
self._plotter.render()
|
||||
|
||||
def closeEvent(self, event) -> None: # type: ignore[override]
|
||||
"""Libera el contexto PyVista al cerrar."""
|
||||
if self._plotter is not None:
|
||||
|
||||
+1562
-164
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user