002c00aff3
Generadores paramétricos (arshipdesign/parametric/):
- wizard_planing.py → V-fondo, chine dura, deadrise variable AP→FP
- wizard_cruiser.py → Carena redonda, plan form Lackenby, Cm ajustable
- wizard_workboat.py → Sección cajón, pantoque duro, fondo plano
- wizard_sailing_mono.py → Velero fin keel, sección fina, LCB a popa
- series60.py → Serie 60 / mercante full, Cm ~ 0.96
- __init__.py → API unificada generate_hull(family, lpp, beam, draft)
+ HullFamily enum con labels, rangos Cb, descripciones
Wizard UI (arshipdesign/ui/dialogs/wizards.py):
- NewShipWizard: QDialog 4 pasos con barra de progreso animada
- _StepDimensions: nombre, Lpp, B, puntal, calado + ratios L/B y B/T en vivo
- _StepFamily: 6 FamilyCard con HullThumbnail QPainter (sección maestra)
- _StepRefine: sliders Cb y LCB, spinboxes discretización
- _StepPreview: tabla hidrostáticos completa (V, D, Cb, LCB, KB, Awp...)
- Al aceptar → Hull cargado en visor 3D del viewport Perspectiva
MainWindow:
- _on_new_project() abre NewShipWizard (antes creaba proyecto vacío)
- Tras accept(): carga hull en Viewer3DWidget si disponible
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
876 lines
33 KiB
Python
876 lines
33 KiB
Python
"""
|
||
NewShipWizard — Wizard de "Nuevo Proyecto" estilo DELFTship.
|
||
|
||
Pasos:
|
||
1. Dimensiones principales (nombre, Lpp, manga, puntal, calado)
|
||
2. Familia de carena (cards visuales con thumbnail SVG)
|
||
3. Refinamiento (Cb slider, LCB, opciones avanzadas)
|
||
4. Preview (hidrostáticos + mini info)
|
||
|
||
Al aceptar, el wizard devuelve un objeto Hull listo para cargar
|
||
en el visor 3D y los 4 viewports.
|
||
|
||
Autor: Álvaro Romero | Sprint 2B — AR-ShipDesign
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Optional
|
||
|
||
import numpy as np
|
||
from PySide6.QtCore import Qt, QSize, Signal
|
||
from PySide6.QtCore import QPointF
|
||
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QBrush, QPolygonF
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QDialog,
|
||
QDialogButtonBox,
|
||
QDoubleSpinBox,
|
||
QFrame,
|
||
QGridLayout,
|
||
QGroupBox,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QLineEdit,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QSizePolicy,
|
||
QSlider,
|
||
QSpinBox,
|
||
QStackedWidget,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from arshipdesign.core.hull import Hull
|
||
from arshipdesign.parametric import HullFamily, generate_hull
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Colores del tema (igual que dark.qss)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
_BG = "#2c3042"
|
||
_PANEL = "#343848"
|
||
_ACCENT = "#4da8ff"
|
||
_TEXT = "#cdd6f4"
|
||
_MUTED = "#7a8ba8"
|
||
_BORDER = "#3e4255"
|
||
_GOLD = "#e8a020"
|
||
_GREEN = "#48a858"
|
||
_CARD_HL = "#1e2550"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Thumbnails de cada familia (dibujados en QPainter)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class HullThumbnail(QWidget):
|
||
"""Miniatura 2D de la sección maestra de cada familia."""
|
||
|
||
def __init__(
|
||
self,
|
||
family: HullFamily,
|
||
size: int = 80,
|
||
selected: bool = False,
|
||
parent: Optional[QWidget] = None,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.family = family
|
||
self.selected = selected
|
||
self.setFixedSize(size, size)
|
||
|
||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||
p = QPainter(self)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
w, h = self.width(), self.height()
|
||
|
||
# Fondo
|
||
bg = QColor(_CARD_HL if self.selected else _PANEL)
|
||
p.fillRect(0, 0, w, h, bg)
|
||
|
||
# Borde
|
||
border_col = QColor(_ACCENT if self.selected else _BORDER)
|
||
p.setPen(QPen(border_col, 2 if self.selected else 1))
|
||
p.drawRect(1, 1, w - 2, h - 2)
|
||
|
||
# Sección del casco
|
||
p.setPen(QPen(QColor(_ACCENT), 2))
|
||
p.setBrush(QBrush(QColor(30, 60, 100, 160)))
|
||
self._draw_section(p, w, h)
|
||
p.end()
|
||
|
||
def _draw_section(self, p: QPainter, w: int, h: int) -> None:
|
||
mx, my = w / 2, h * 0.72 # centro de referencia
|
||
scale = w * 0.38
|
||
|
||
fam = self.family
|
||
|
||
if fam == HullFamily.PLANING:
|
||
# V-fondo con chine dura
|
||
pts = [
|
||
(-1.0, 0.0), # chine estribor
|
||
(-0.30, -1.0), # quilla (keel)
|
||
( 0.30, -1.0), # quilla (keel)
|
||
( 1.0, 0.0), # chine babor
|
||
( 1.0, 0.35), # cubierta estribor
|
||
(-1.0, 0.35), # cubierta babor
|
||
]
|
||
elif fam in (HullFamily.DISPLACEMENT, HullFamily.SEMI_DISP):
|
||
# Carena redonda — arco
|
||
n = 16
|
||
angles = np.linspace(np.pi, 0, n)
|
||
pts_lower = [(np.cos(a), -np.sin(a) * 0.9) for a in angles]
|
||
pts = pts_lower + [(1.0, 0.30), (-1.0, 0.30)]
|
||
elif fam == HullFamily.WORKBOAT:
|
||
# Sección cajón — pantoque duro
|
||
r = 0.20 # radio relativo
|
||
pts = [
|
||
(-1.0, 0.30),
|
||
(-1.0, -r),
|
||
(-(1.0 - r), -1.0),
|
||
( (1.0 - r), -1.0),
|
||
( 1.0, -r),
|
||
( 1.0, 0.30),
|
||
]
|
||
elif fam == HullFamily.SAILING:
|
||
# Sección fina en V
|
||
pts = [
|
||
(-0.70, 0.30),
|
||
(-0.85, 0.0),
|
||
(-0.50, -0.50),
|
||
( 0.0, -1.0),
|
||
( 0.50, -0.50),
|
||
( 0.85, 0.0),
|
||
( 0.70, 0.30),
|
||
]
|
||
else: # MERCHANT
|
||
# Sección muy llena — fondo casi plano
|
||
r = 0.10
|
||
pts = [
|
||
(-1.0, 0.30),
|
||
(-1.0, -r),
|
||
(-(1.0 - r), -1.0),
|
||
( (1.0 - r), -1.0),
|
||
( 1.0, -r),
|
||
( 1.0, 0.30),
|
||
]
|
||
|
||
poly = QPolygonF([
|
||
QPointF(mx + px * scale, my + py * scale) for px, py in pts
|
||
])
|
||
p.drawPolygon(poly)
|
||
|
||
# Línea de agua
|
||
p.setPen(QPen(QColor(_GOLD), 1, Qt.PenStyle.DashLine))
|
||
p.drawLine(int(mx - scale * 1.05), int(my),
|
||
int(mx + scale * 1.05), int(my))
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Card de familia de carena (seleccionable)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class FamilyCard(QFrame):
|
||
selected = Signal(HullFamily)
|
||
|
||
def __init__(self, family: HullFamily, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self.family = family
|
||
self._selected = False
|
||
self.setFixedWidth(150)
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
lo = QVBoxLayout(self)
|
||
lo.setContentsMargins(8, 8, 8, 8)
|
||
lo.setSpacing(4)
|
||
lo.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||
|
||
self._thumb = HullThumbnail(self.family, size=80)
|
||
lo.addWidget(self._thumb, 0, Qt.AlignmentFlag.AlignHCenter)
|
||
|
||
lbl = QLabel(self.family.label_es)
|
||
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
lbl.setWordWrap(True)
|
||
lbl.setStyleSheet(f"color:{_TEXT}; font-weight:600; font-size:11px;")
|
||
lo.addWidget(lbl)
|
||
|
||
cb_lo, cb_hi = self.family.cb_range
|
||
rng = QLabel(f"Cb {cb_lo:.2f}–{cb_hi:.2f}")
|
||
rng.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
rng.setStyleSheet(f"color:{_MUTED}; font-size:10px;")
|
||
lo.addWidget(rng)
|
||
|
||
self._update_style()
|
||
|
||
def set_selected(self, sel: bool) -> None:
|
||
self._selected = sel
|
||
self._thumb.selected = sel
|
||
self._thumb.update()
|
||
self._update_style()
|
||
|
||
def _update_style(self) -> None:
|
||
if self._selected:
|
||
self.setStyleSheet(
|
||
f"FamilyCard{{background:{_CARD_HL}; border:2px solid {_ACCENT};"
|
||
f"border-radius:6px;}}"
|
||
)
|
||
else:
|
||
self.setStyleSheet(
|
||
f"FamilyCard{{background:{_PANEL}; border:1px solid {_BORDER};"
|
||
f"border-radius:6px;}}"
|
||
f"FamilyCard:hover{{border:1px solid {_ACCENT};}}"
|
||
)
|
||
|
||
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
||
self.selected.emit(self.family)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Pasos del wizard
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class _StepDimensions(QWidget):
|
||
"""Paso 1: Nombre + dimensiones principales."""
|
||
|
||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
lo = QVBoxLayout(self)
|
||
lo.setContentsMargins(20, 10, 20, 10)
|
||
lo.setSpacing(16)
|
||
|
||
title = QLabel("Dimensiones principales")
|
||
title.setStyleSheet(f"color:{_ACCENT}; font-size:16px; font-weight:700;")
|
||
lo.addWidget(title)
|
||
|
||
grid = QGridLayout()
|
||
grid.setSpacing(10)
|
||
|
||
def add_row(row, label, widget):
|
||
lbl = QLabel(label)
|
||
lbl.setStyleSheet(f"color:{_TEXT}; font-size:12px;")
|
||
grid.addWidget(lbl, row, 0)
|
||
grid.addWidget(widget, row, 1)
|
||
|
||
self._name = QLineEdit("Mi Embarcación")
|
||
self._name.setStyleSheet(
|
||
f"background:{_PANEL}; color:{_TEXT}; border:1px solid {_BORDER};"
|
||
f"border-radius:4px; padding:4px 8px; font-size:12px;"
|
||
)
|
||
add_row(0, "Nombre del proyecto:", self._name)
|
||
|
||
def spin(mini, maxi, val, dec=2, suffix=" m"):
|
||
s = QDoubleSpinBox()
|
||
s.setRange(mini, maxi)
|
||
s.setValue(val)
|
||
s.setDecimals(dec)
|
||
s.setSuffix(suffix)
|
||
s.setStyleSheet(
|
||
f"background:{_PANEL}; color:{_TEXT}; border:1px solid {_BORDER};"
|
||
f"border-radius:4px; padding:2px 6px; font-size:12px;"
|
||
)
|
||
return s
|
||
|
||
self._lpp = spin(2.0, 200.0, 15.0)
|
||
self._beam = spin(0.5, 40.0, 4.0)
|
||
self._depth = spin(0.3, 30.0, 2.30)
|
||
self._draft = spin(0.2, 25.0, 1.60)
|
||
|
||
add_row(1, "Eslora entre perp. (Lpp):", self._lpp)
|
||
add_row(2, "Manga máxima (B):", self._beam)
|
||
add_row(3, "Puntal de trazado (D):", self._depth)
|
||
add_row(4, "Calado de diseño (T):", self._draft)
|
||
|
||
# Validación dinámica
|
||
self._draft.valueChanged.connect(self._check_draft)
|
||
self._depth.valueChanged.connect(self._check_draft)
|
||
|
||
lo.addLayout(grid)
|
||
|
||
# Ratios de referencia
|
||
self._ratio_lbl = QLabel()
|
||
self._ratio_lbl.setStyleSheet(f"color:{_MUTED}; font-size:10px;")
|
||
lo.addWidget(self._ratio_lbl)
|
||
self._lpp.valueChanged.connect(self._update_ratios)
|
||
self._beam.valueChanged.connect(self._update_ratios)
|
||
self._draft.valueChanged.connect(self._update_ratios)
|
||
self._update_ratios()
|
||
|
||
lo.addStretch()
|
||
|
||
def _check_draft(self) -> None:
|
||
if self._draft.value() >= self._depth.value():
|
||
self._draft.setValue(self._depth.value() * 0.70)
|
||
|
||
def _update_ratios(self) -> None:
|
||
lpp = self._lpp.value()
|
||
b = self._beam.value()
|
||
t = self._draft.value()
|
||
lb = lpp / b if b > 0 else 0
|
||
bt = b / t if t > 0 else 0
|
||
self._ratio_lbl.setText(
|
||
f"Eslora/Manga = {lb:.1f} · Manga/Calado = {bt:.1f}"
|
||
)
|
||
|
||
# Acceso
|
||
def get_values(self) -> dict:
|
||
return {
|
||
"name": self._name.text().strip() or "Nueva Embarcación",
|
||
"lpp": self._lpp.value(),
|
||
"beam": self._beam.value(),
|
||
"depth": self._depth.value(),
|
||
"draft": self._draft.value(),
|
||
}
|
||
|
||
|
||
class _StepFamily(QWidget):
|
||
"""Paso 2: Selección de familia de carena."""
|
||
|
||
family_changed = Signal(HullFamily)
|
||
|
||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self._selected = HullFamily.DISPLACEMENT
|
||
self._cards: dict[HullFamily, FamilyCard] = {}
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
lo = QVBoxLayout(self)
|
||
lo.setContentsMargins(20, 10, 20, 10)
|
||
lo.setSpacing(14)
|
||
|
||
title = QLabel("Tipo de carena")
|
||
title.setStyleSheet(f"color:{_ACCENT}; font-size:16px; font-weight:700;")
|
||
lo.addWidget(title)
|
||
|
||
subtitle = QLabel("Selecciona la familia que mejor se adapta a tu proyecto.")
|
||
subtitle.setStyleSheet(f"color:{_MUTED}; font-size:11px;")
|
||
lo.addWidget(subtitle)
|
||
|
||
# Grid de cards
|
||
cards_widget = QWidget()
|
||
cards_lo = QHBoxLayout(cards_widget)
|
||
cards_lo.setSpacing(10)
|
||
cards_lo.setContentsMargins(0, 0, 0, 0)
|
||
|
||
for fam in HullFamily:
|
||
card = FamilyCard(fam)
|
||
card.selected.connect(self._on_select)
|
||
self._cards[fam] = card
|
||
cards_lo.addWidget(card)
|
||
|
||
cards_lo.addStretch()
|
||
lo.addWidget(cards_widget)
|
||
|
||
# Descripción
|
||
self._desc = QLabel()
|
||
self._desc.setWordWrap(True)
|
||
self._desc.setStyleSheet(
|
||
f"color:{_TEXT}; font-size:11px; background:{_PANEL};"
|
||
f"border:1px solid {_BORDER}; border-radius:4px; padding:8px;"
|
||
)
|
||
lo.addWidget(self._desc)
|
||
lo.addStretch()
|
||
|
||
self._on_select(HullFamily.DISPLACEMENT)
|
||
|
||
def _on_select(self, fam: HullFamily) -> None:
|
||
for f, c in self._cards.items():
|
||
c.set_selected(f == fam)
|
||
self._selected = fam
|
||
self._desc.setText(fam.description_es)
|
||
self.family_changed.emit(fam)
|
||
|
||
def get_family(self) -> HullFamily:
|
||
return self._selected
|
||
|
||
|
||
class _StepRefine(QWidget):
|
||
"""Paso 3: Refinamiento de parámetros (Cb, LCB, opciones)."""
|
||
|
||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self._family = HullFamily.DISPLACEMENT
|
||
self._cb_min = 0.45
|
||
self._cb_max = 0.65
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
lo = QVBoxLayout(self)
|
||
lo.setContentsMargins(20, 10, 20, 10)
|
||
lo.setSpacing(16)
|
||
|
||
title = QLabel("Refinamiento")
|
||
title.setStyleSheet(f"color:{_ACCENT}; font-size:16px; font-weight:700;")
|
||
lo.addWidget(title)
|
||
|
||
# ── Cb ────────────────────────────────────────────────────────
|
||
grp_cb = QGroupBox("Coeficiente de bloque (Cb)")
|
||
grp_cb.setStyleSheet(
|
||
f"QGroupBox{{color:{_TEXT}; border:1px solid {_BORDER};"
|
||
f"border-radius:4px; margin-top:8px; font-size:11px;}}"
|
||
f"QGroupBox::title{{subcontrol-origin:margin; left:8px; padding:0 4px;}}"
|
||
)
|
||
cb_lo = QVBoxLayout(grp_cb)
|
||
|
||
self._cb_slider = QSlider(Qt.Orientation.Horizontal)
|
||
self._cb_slider.setRange(30, 82) # × 0.01
|
||
self._cb_slider.setValue(55)
|
||
self._cb_slider.setStyleSheet(
|
||
f"QSlider::groove:horizontal{{height:4px; background:{_BORDER}; border-radius:2px;}}"
|
||
f"QSlider::handle:horizontal{{width:14px; height:14px; margin:-5px 0;"
|
||
f"border-radius:7px; background:{_ACCENT};}}"
|
||
f"QSlider::sub-page:horizontal{{background:{_ACCENT}; border-radius:2px;}}"
|
||
)
|
||
self._cb_val_lbl = QLabel("Cb = 0.55")
|
||
self._cb_val_lbl.setStyleSheet(f"color:{_ACCENT}; font-size:13px; font-weight:700;")
|
||
self._cb_slider.valueChanged.connect(self._cb_changed)
|
||
|
||
h = QHBoxLayout()
|
||
self._cb_min_lbl = QLabel("0.45")
|
||
self._cb_max_lbl = QLabel("0.65")
|
||
self._cb_min_lbl.setStyleSheet(f"color:{_MUTED}; font-size:10px;")
|
||
self._cb_max_lbl.setStyleSheet(f"color:{_MUTED}; font-size:10px;")
|
||
h.addWidget(self._cb_min_lbl)
|
||
h.addWidget(self._cb_slider, 1)
|
||
h.addWidget(self._cb_max_lbl)
|
||
|
||
cb_lo.addLayout(h)
|
||
cb_lo.addWidget(self._cb_val_lbl, 0, Qt.AlignmentFlag.AlignHCenter)
|
||
lo.addWidget(grp_cb)
|
||
|
||
# ── LCB ───────────────────────────────────────────────────────
|
||
grp_lcb = QGroupBox("Centro longitudinal de carena (LCB)")
|
||
grp_lcb.setStyleSheet(grp_cb.styleSheet())
|
||
lcb_lo = QVBoxLayout(grp_lcb)
|
||
|
||
self._lcb_slider = QSlider(Qt.Orientation.Horizontal)
|
||
self._lcb_slider.setRange(44, 58) # % de Lpp desde AP
|
||
self._lcb_slider.setValue(52)
|
||
self._lcb_slider.setStyleSheet(self._cb_slider.styleSheet())
|
||
self._lcb_val_lbl = QLabel("LCB = 52.0 % Lpp desde AP")
|
||
self._lcb_val_lbl.setStyleSheet(f"color:{_ACCENT}; font-size:12px;")
|
||
self._lcb_slider.valueChanged.connect(self._lcb_changed)
|
||
|
||
h2 = QHBoxLayout()
|
||
for txt in ("44%", " ", "58%"):
|
||
l = QLabel(txt)
|
||
l.setStyleSheet(f"color:{_MUTED}; font-size:10px;")
|
||
if txt == " ":
|
||
h2.addWidget(self._lcb_slider, 1)
|
||
else:
|
||
h2.addWidget(l)
|
||
|
||
lcb_lo.addLayout(h2)
|
||
lcb_lo.addWidget(self._lcb_val_lbl, 0, Qt.AlignmentFlag.AlignHCenter)
|
||
lo.addWidget(grp_lcb)
|
||
|
||
# ── Discretización ─────────────────────────────────────────────
|
||
grp_disc = QGroupBox("Discretización")
|
||
grp_disc.setStyleSheet(grp_cb.styleSheet())
|
||
disc_lo = QGridLayout(grp_disc)
|
||
|
||
disc_lo.addWidget(QLabel("Estaciones:"), 0, 0)
|
||
self._n_sta = QSpinBox()
|
||
self._n_sta.setRange(7, 81)
|
||
self._n_sta.setValue(21)
|
||
self._n_sta.setSingleStep(2)
|
||
self._n_sta.setStyleSheet(
|
||
f"background:{_PANEL}; color:{_TEXT}; border:1px solid {_BORDER};"
|
||
f"border-radius:4px; padding:2px 6px;"
|
||
)
|
||
disc_lo.addWidget(self._n_sta, 0, 1)
|
||
|
||
disc_lo.addWidget(QLabel("Líneas de agua:"), 1, 0)
|
||
self._n_wl = QSpinBox()
|
||
self._n_wl.setRange(5, 31)
|
||
self._n_wl.setValue(11)
|
||
self._n_wl.setSingleStep(2)
|
||
self._n_wl.setStyleSheet(self._n_sta.styleSheet())
|
||
disc_lo.addWidget(self._n_wl, 1, 1)
|
||
|
||
for lbl in grp_disc.findChildren(QLabel):
|
||
lbl.setStyleSheet(f"color:{_TEXT}; font-size:11px;")
|
||
|
||
lo.addWidget(grp_disc)
|
||
lo.addStretch()
|
||
|
||
def _cb_changed(self, val: int) -> None:
|
||
cb = val / 100.0
|
||
self._cb_val_lbl.setText(f"Cb = {cb:.2f}")
|
||
|
||
def _lcb_changed(self, val: int) -> None:
|
||
self._lcb_val_lbl.setText(f"LCB = {val:.1f} % Lpp desde AP")
|
||
|
||
def update_for_family(self, fam: HullFamily) -> None:
|
||
"""Actualiza límites del slider según la familia seleccionada."""
|
||
self._family = fam
|
||
lo, hi = fam.cb_range
|
||
self._cb_min = lo
|
||
self._cb_max = hi
|
||
self._cb_min_lbl.setText(f"{lo:.2f}")
|
||
self._cb_max_lbl.setText(f"{hi:.2f}")
|
||
# Escalar slider: rango 30–82 → lo–hi
|
||
slider_lo = int(lo * 100)
|
||
slider_hi = int(hi * 100)
|
||
self._cb_slider.setRange(slider_lo, slider_hi)
|
||
mid = (slider_lo + slider_hi) // 2
|
||
self._cb_slider.setValue(mid)
|
||
|
||
def get_values(self) -> dict:
|
||
return {
|
||
"cb": self._cb_slider.value() / 100.0,
|
||
"lcb_frac": self._lcb_slider.value() / 100.0,
|
||
"n_stations": self._n_sta.value(),
|
||
"n_waterlines": self._n_wl.value(),
|
||
}
|
||
|
||
|
||
class _StepPreview(QWidget):
|
||
"""Paso 4: Resumen de hidrostáticos calculados."""
|
||
|
||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self._hull: Optional[Hull] = None
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
lo = QVBoxLayout(self)
|
||
lo.setContentsMargins(20, 10, 20, 10)
|
||
lo.setSpacing(10)
|
||
|
||
title = QLabel("Resumen del proyecto")
|
||
title.setStyleSheet(f"color:{_ACCENT}; font-size:16px; font-weight:700;")
|
||
lo.addWidget(title)
|
||
|
||
self._info_lbl = QLabel("Calculando…")
|
||
self._info_lbl.setStyleSheet(f"color:{_MUTED}; font-size:11px;")
|
||
lo.addWidget(self._info_lbl)
|
||
|
||
# Tabla de hidrostáticos
|
||
self._hydro_frame = QFrame()
|
||
self._hydro_frame.setStyleSheet(
|
||
f"background:{_PANEL}; border:1px solid {_BORDER}; border-radius:4px;"
|
||
)
|
||
self._hydro_lo = QGridLayout(self._hydro_frame)
|
||
self._hydro_lo.setSpacing(6)
|
||
self._hydro_lo.setContentsMargins(14, 10, 14, 10)
|
||
lo.addWidget(self._hydro_frame)
|
||
|
||
# Thumbnail del body plan
|
||
self._body_thumb = HullThumbnail(HullFamily.DISPLACEMENT, size=120)
|
||
lo.addWidget(self._body_thumb, 0, Qt.AlignmentFlag.AlignHCenter)
|
||
lo.addStretch()
|
||
|
||
def _clear_hydro(self) -> None:
|
||
while self._hydro_lo.count():
|
||
item = self._hydro_lo.takeAt(0)
|
||
if item.widget():
|
||
item.widget().deleteLater()
|
||
|
||
def update_hull(self, hull: Optional[Hull], family: HullFamily) -> None:
|
||
self._clear_hydro()
|
||
self._body_thumb.family = family
|
||
self._body_thumb.selected = True
|
||
self._body_thumb.update()
|
||
|
||
if hull is None:
|
||
self._info_lbl.setText("Sin embarcación generada.")
|
||
return
|
||
|
||
self._hull = hull
|
||
self._info_lbl.setText(
|
||
f"<b>{hull.name}</b> — {family.label_es}"
|
||
)
|
||
|
||
rows = [
|
||
("Lpp", f"{hull.lpp:.2f} m", "Eslora entre perpendiculares"),
|
||
("Manga (B)", f"{hull.beam:.2f} m", "Manga máxima"),
|
||
("Calado (T)", f"{hull.draft:.2f} m", "Calado de diseño"),
|
||
("Puntal (D)", f"{hull.depth:.2f} m", "Puntal de trazado"),
|
||
("Volumen (V)", f"{hull.volume_of_displacement():.2f} m³", "Volumen de carena"),
|
||
("Desplazamiento",f"{hull.displacement_tonnes():.2f} t", "Agua salada (ρ=1025)"),
|
||
("Cb", f"{hull.block_coefficient():.3f}", "Coeficiente de bloque"),
|
||
("LCB", f"{hull.lcb():.2f} m ({hull.lcb()/hull.lpp*100:.1f}% Lpp)",
|
||
"Centro long. de carena desde AP"),
|
||
("KB", f"{hull.vcb():.3f} m", "Centro vertical de carena"),
|
||
("Awp", f"{hull.waterplane_area():.2f} m²", "Área plano de flotación"),
|
||
]
|
||
|
||
for row_idx, (param, value, tooltip) in enumerate(rows):
|
||
p_lbl = QLabel(param)
|
||
v_lbl = QLabel(value)
|
||
p_lbl.setStyleSheet(f"color:{_MUTED}; font-size:11px;")
|
||
v_lbl.setStyleSheet(f"color:{_TEXT}; font-size:11px; font-weight:600;")
|
||
p_lbl.setToolTip(tooltip)
|
||
v_lbl.setToolTip(tooltip)
|
||
self._hydro_lo.addWidget(p_lbl, row_idx, 0)
|
||
self._hydro_lo.addWidget(v_lbl, row_idx, 1)
|
||
|
||
def get_hull(self) -> Optional[Hull]:
|
||
return self._hull
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Wizard principal
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class NewShipWizard(QDialog):
|
||
"""Wizard de nuevo proyecto — 4 pasos.
|
||
|
||
Uso:
|
||
----
|
||
>>> w = NewShipWizard(parent)
|
||
>>> if w.exec() == QDialog.DialogCode.Accepted:
|
||
... hull = w.result_hull()
|
||
"""
|
||
|
||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Nuevo Proyecto — AR-ShipDesign")
|
||
self.setMinimumSize(780, 560)
|
||
self.setModal(True)
|
||
self.setStyleSheet(f"""
|
||
QDialog {{
|
||
background: {_BG};
|
||
color: {_TEXT};
|
||
}}
|
||
QLabel {{
|
||
color: {_TEXT};
|
||
background: transparent;
|
||
}}
|
||
""")
|
||
|
||
self._step = 0
|
||
self._n_steps = 4
|
||
self._result: Optional[Hull] = None
|
||
self._build()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Construcción
|
||
# ------------------------------------------------------------------
|
||
|
||
def _build(self) -> None:
|
||
root = QVBoxLayout(self)
|
||
root.setContentsMargins(0, 0, 0, 0)
|
||
root.setSpacing(0)
|
||
|
||
# ── Header ───────────────────────────────────────────────────
|
||
header = QWidget()
|
||
header.setFixedHeight(56)
|
||
header.setStyleSheet(
|
||
f"background: {_PANEL}; border-bottom: 2px solid {_ACCENT};"
|
||
)
|
||
h_lo = QHBoxLayout(header)
|
||
h_lo.setContentsMargins(20, 0, 20, 0)
|
||
|
||
title_lbl = QLabel("Nueva Embarcación")
|
||
title_lbl.setStyleSheet(
|
||
f"color:{_ACCENT}; font-size:18px; font-weight:700; background:transparent;"
|
||
)
|
||
h_lo.addWidget(title_lbl)
|
||
h_lo.addStretch()
|
||
|
||
self._step_lbl = QLabel("Paso 1 de 4")
|
||
self._step_lbl.setStyleSheet(f"color:{_MUTED}; font-size:11px; background:transparent;")
|
||
h_lo.addWidget(self._step_lbl)
|
||
root.addWidget(header)
|
||
|
||
# ── Indicador de pasos ────────────────────────────────────────
|
||
self._step_bar = _StepBar(
|
||
["Dimensiones", "Carena", "Refinamiento", "Resumen"],
|
||
parent=self,
|
||
)
|
||
root.addWidget(self._step_bar)
|
||
|
||
# ── Contenido (stackedWidget) ─────────────────────────────────
|
||
self._stack = QStackedWidget()
|
||
self._s1 = _StepDimensions()
|
||
self._s2 = _StepFamily()
|
||
self._s3 = _StepRefine()
|
||
self._s4 = _StepPreview()
|
||
|
||
self._stack.addWidget(self._s1)
|
||
self._stack.addWidget(self._s2)
|
||
self._stack.addWidget(self._s3)
|
||
self._stack.addWidget(self._s4)
|
||
root.addWidget(self._stack, 1)
|
||
|
||
# Conectar familia → actualizar refinamiento
|
||
self._s2.family_changed.connect(self._s3.update_for_family)
|
||
|
||
# ── Botones ───────────────────────────────────────────────────
|
||
btn_bar = QWidget()
|
||
btn_bar.setStyleSheet(
|
||
f"background:{_PANEL}; border-top:1px solid {_BORDER};"
|
||
)
|
||
btn_lo = QHBoxLayout(btn_bar)
|
||
btn_lo.setContentsMargins(20, 10, 20, 10)
|
||
|
||
self._btn_cancel = QPushButton("Cancelar")
|
||
self._btn_back = QPushButton("← Atrás")
|
||
self._btn_next = QPushButton("Siguiente →")
|
||
self._btn_create = QPushButton("✔ Crear Embarcación")
|
||
|
||
for btn in (self._btn_cancel, self._btn_back, self._btn_next):
|
||
btn.setStyleSheet(
|
||
f"QPushButton{{background:{_BORDER}; color:{_TEXT}; border:none;"
|
||
f"border-radius:4px; padding:7px 18px; font-size:12px;}}"
|
||
f"QPushButton:hover{{background:{_CARD_HL};}}"
|
||
)
|
||
self._btn_create.setStyleSheet(
|
||
f"QPushButton{{background:{_ACCENT}; color:#fff; border:none;"
|
||
f"border-radius:4px; padding:7px 22px; font-size:12px; font-weight:700;}}"
|
||
f"QPushButton:hover{{background:#5ab8ff;}}"
|
||
)
|
||
|
||
btn_lo.addWidget(self._btn_cancel)
|
||
btn_lo.addStretch()
|
||
btn_lo.addWidget(self._btn_back)
|
||
btn_lo.addWidget(self._btn_next)
|
||
btn_lo.addWidget(self._btn_create)
|
||
root.addWidget(btn_bar)
|
||
|
||
# Conexiones
|
||
self._btn_cancel.clicked.connect(self.reject)
|
||
self._btn_back.clicked.connect(self._go_back)
|
||
self._btn_next.clicked.connect(self._go_next)
|
||
self._btn_create.clicked.connect(self._create)
|
||
|
||
self._refresh_buttons()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Navegación
|
||
# ------------------------------------------------------------------
|
||
|
||
def _go_next(self) -> None:
|
||
if self._step < self._n_steps - 1:
|
||
self._step += 1
|
||
self._stack.setCurrentIndex(self._step)
|
||
if self._step == 3:
|
||
self._build_preview()
|
||
self._refresh_buttons()
|
||
|
||
def _go_back(self) -> None:
|
||
if self._step > 0:
|
||
self._step -= 1
|
||
self._stack.setCurrentIndex(self._step)
|
||
self._refresh_buttons()
|
||
|
||
def _refresh_buttons(self) -> None:
|
||
s = self._step
|
||
self._step_lbl.setText(f"Paso {s+1} de {self._n_steps}")
|
||
self._step_bar.set_active(s)
|
||
self._btn_back.setVisible(s > 0)
|
||
self._btn_next.setVisible(s < self._n_steps - 1)
|
||
self._btn_create.setVisible(s == self._n_steps - 1)
|
||
|
||
def _build_preview(self) -> None:
|
||
"""Genera el Hull y actualiza el paso 4."""
|
||
try:
|
||
dims = self._s1.get_values()
|
||
fam = self._s2.get_family()
|
||
ref = self._s3.get_values()
|
||
|
||
hull = generate_hull(
|
||
family=fam,
|
||
lpp=dims["lpp"],
|
||
beam=dims["beam"],
|
||
draft=dims["draft"],
|
||
depth=dims["depth"],
|
||
name=dims["name"],
|
||
cb=ref["cb"],
|
||
lcb_frac=ref["lcb_frac"],
|
||
n_stations=ref["n_stations"],
|
||
n_waterlines=ref["n_waterlines"],
|
||
)
|
||
self._result = hull
|
||
self._s4.update_hull(hull, fam)
|
||
except Exception as exc:
|
||
self._s4.update_hull(None, self._s2.get_family())
|
||
self._s4._info_lbl.setText(f"Error al generar: {exc}")
|
||
|
||
def _create(self) -> None:
|
||
if self._result is None:
|
||
self._build_preview()
|
||
if self._result is not None:
|
||
self.accept()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Acceso externo
|
||
# ------------------------------------------------------------------
|
||
|
||
def result_hull(self) -> Optional[Hull]:
|
||
"""Retorna el Hull generado (sólo válido tras accept())."""
|
||
return self._result
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Barra visual de pasos
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class _StepBar(QWidget):
|
||
def __init__(self, labels: list[str], parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self._labels = labels
|
||
self._active = 0
|
||
self.setFixedHeight(40)
|
||
|
||
def set_active(self, idx: int) -> None:
|
||
self._active = idx
|
||
self.update()
|
||
|
||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||
p = QPainter(self)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
n = len(self._labels)
|
||
w, h = self.width(), self.height()
|
||
step_w = w / n
|
||
|
||
for i, lbl in enumerate(self._labels):
|
||
cx = int(step_w * i + step_w / 2)
|
||
cy = h // 2
|
||
|
||
# Línea de conexión
|
||
if i > 0:
|
||
prev_cx = int(step_w * (i - 1) + step_w / 2)
|
||
color = _ACCENT if i <= self._active else _BORDER
|
||
p.setPen(QPen(QColor(color), 2))
|
||
p.drawLine(prev_cx + 10, cy, cx - 10, cy)
|
||
|
||
# Círculo
|
||
r = 10
|
||
if i < self._active:
|
||
# Completado
|
||
p.setBrush(QBrush(QColor(_ACCENT)))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
|
||
p.setPen(QPen(QColor("#fff"), 2))
|
||
p.drawText(cx - r, cy - r, r * 2, r * 2,
|
||
Qt.AlignmentFlag.AlignCenter, "✓")
|
||
elif i == self._active:
|
||
# Activo
|
||
p.setBrush(QBrush(QColor(_ACCENT)))
|
||
p.setPen(QPen(QColor("#fff"), 2))
|
||
p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
|
||
p.drawText(cx - r, cy - r, r * 2, r * 2,
|
||
Qt.AlignmentFlag.AlignCenter, str(i + 1))
|
||
else:
|
||
# Pendiente
|
||
p.setBrush(QBrush(QColor(_PANEL)))
|
||
p.setPen(QPen(QColor(_BORDER), 1))
|
||
p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
|
||
p.setPen(QPen(QColor(_MUTED), 1))
|
||
p.drawText(cx - r, cy - r, r * 2, r * 2,
|
||
Qt.AlignmentFlag.AlignCenter, str(i + 1))
|
||
|
||
# Texto del paso
|
||
p.setPen(QPen(QColor(_ACCENT if i == self._active else _MUTED)))
|
||
p.drawText(cx - 50, cy + r + 2, 100, 14,
|
||
Qt.AlignmentFlag.AlignCenter, lbl)
|
||
|
||
p.end()
|