Files
AR-Shipdesign/arshipdesign/ui/dialogs/wizards.py
T
alro65 652fdca358 Fix wizard: step bar text clipped + black background in content area
_StepBar:
- setFixedHeight(40→54) — label text (cy+r+4=34, h=16) ya no se recorta
- cy fijado a 20 en lugar de h//2 para dejar margen inferior al texto
- drawText rect ampliado a 112×16 para labels más largos (esp. "Refinamiento")
- setStyleSheet background:_PANEL para que coincida con el header

NewShipWizard content area:
- QStackedWidget: stylesheet "background:_BG" para el widget contenedor
- Cada página de paso: autoFillBackground=True + palette Window→_BG
  (palette en vez de setStyleSheet evita que el fondo cascadee a los
  hijos QDoubleSpinBox/QLineEdit que tienen sus propios estilos inline)
- QPalette añadido al import de PySide6.QtGui

282 tests, 0 fallos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:18:33 -04:00

887 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, QPalette, 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 3082 → lohi
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._stack.setStyleSheet(f"QStackedWidget {{ background: {_BG}; }}")
self._s1 = _StepDimensions()
self._s2 = _StepFamily()
self._s3 = _StepRefine()
self._s4 = _StepPreview()
# autoFillBackground + palette → rellena el fondo sin cascadear a hijos
_bg_color = QColor(_BG)
for page in (self._s1, self._s2, self._s3, self._s4):
page.setAutoFillBackground(True)
_pal = QPalette(page.palette())
_pal.setColor(QPalette.ColorRole.Window, _bg_color)
page.setPalette(_pal)
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(54)
self.setStyleSheet(f"background:{_PANEL};")
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 = 20 # círculo en la franja superior; deja ~22px debajo para el texto
# 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 - 56, cy + r + 4, 112, 16,
Qt.AlignmentFlag.AlignCenter, lbl)
p.end()