Files
AR-Shipdesign/arshipdesign/ui/dialogs/wizards.py
T
alro65 588735ea64 Wizard: reduce default control mesh to 7x5 (DELFTship philosophy)
Prior defaults (21 stations x 11 waterlines = 231 nodes) made it
impossible to edit a hull fairly — too many degrees of freedom with
no locality.

DELFTship principle: start with the minimum viable mesh so each node
acts as a true Bezier handle with global influence, then refine only
where needed.

New defaults: 7 stations x 5 waterlines = 35 nodes
  - 7 stations: AP + 5 intermediate + FP (clear midship at index 3)
  - 5 waterlines: keel + 25% + 50% + 75% + design WL
  - 5 points per section = cubic B-spline ≈ one Bezier handle per quadrant

Range changes:
  - Stations: 7-81 -> 4-30 (step 1, was 2)
  - Waterlines: 5-31 -> 3-12 (step 1, was 2)

New UI elements:
  - Group renamed "Malla de control (puntos Bézier)"
  - Italic hint explaining the fewer-is-better philosophy
  - Live counter "Total nodos: N (manejable / moderado / difícil)"
    with color feedback: green ≤50, amber ≤120, red >120

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:08:07 -04:00

935 lines
36 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)
# ── Malla de control ────────────────────────────────────────────
grp_disc = QGroupBox("Malla de control (puntos Bézier)")
grp_disc.setStyleSheet(grp_cb.styleSheet())
disc_lo = QGridLayout(grp_disc)
disc_lo.setSpacing(8)
# Nota filosófica: menos puntos = curvas más fáciles de afinar
hint = QLabel(
"Menos puntos → curvas naturalmente suaves y fáciles de editar.\n"
"Añade más sólo cuando necesites detalles locales."
)
hint.setWordWrap(True)
hint.setStyleSheet(f"color:{_MUTED}; font-size:10px; font-style:italic;")
disc_lo.addWidget(hint, 0, 0, 1, 2)
disc_lo.addWidget(QLabel("Estaciones (long.):"), 1, 0)
self._n_sta = QSpinBox()
self._n_sta.setRange(4, 30) # mínimo 4 para cúbica, máximo razonable
self._n_sta.setValue(7) # 7 = como DELFTship en plantilla nueva
self._n_sta.setSingleStep(1)
self._n_sta.setToolTip(
"Número de secciones transversales del polígono de control.\n"
"7 es suficiente para la mayoría de cascos de eslora media."
)
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, 1, 1)
disc_lo.addWidget(QLabel("Líneas de agua (vert.):"), 2, 0)
self._n_wl = QSpinBox()
self._n_wl.setRange(3, 12) # 3 mínimo (quilla, media, flotación)
self._n_wl.setValue(5) # 5 = quilla + 3 intermedias + flotación
self._n_wl.setSingleStep(1)
self._n_wl.setToolTip(
"Número de líneas de agua del polígono de control.\n"
"5 da una cúbica por sección con un punto a cada 25 % del calado."
)
self._n_wl.setStyleSheet(self._n_sta.styleSheet())
disc_lo.addWidget(self._n_wl, 2, 1)
# Indicador en vivo del total de nodos
self._nodes_lbl = QLabel()
self._nodes_lbl.setStyleSheet(f"color:{_ACCENT}; font-size:11px; font-weight:600;")
disc_lo.addWidget(self._nodes_lbl, 3, 0, 1, 2)
self._n_sta.valueChanged.connect(self._update_nodes_lbl)
self._n_wl.valueChanged.connect(self._update_nodes_lbl)
self._update_nodes_lbl()
for lbl in grp_disc.findChildren(QLabel):
if lbl is not hint and lbl is not self._nodes_lbl:
lbl.setStyleSheet(f"color:{_TEXT}; font-size:11px;")
lo.addWidget(grp_disc)
lo.addStretch()
def _update_nodes_lbl(self) -> None:
n = self._n_sta.value()
wl = self._n_wl.value()
total = n * wl
# Feedback visual: color según cantidad (verde = manejable, rojo = muchos)
if total <= 50:
color = _GREEN
advice = "✓ manejable"
elif total <= 120:
color = _GOLD
advice = "⚠ moderado"
else:
color = "#d04040"
advice = "✗ difícil de afinar"
self._nodes_lbl.setText(
f"Total nodos: {total} ({advice})"
)
self._nodes_lbl.setStyleSheet(
f"color:{color}; font-size:11px; font-weight:600;"
)
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()