feat(sprint2): wizard 'Nuevo Proyecto' + 5 generadores paramétricos de casco

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>
This commit is contained in:
2026-05-27 07:52:46 -04:00
parent 503e00bfc9
commit 002c00aff3
8 changed files with 1685 additions and 15 deletions
+169 -1
View File
@@ -1 +1,169 @@
# arshipdesign/parametric
"""
arshipdesign.parametric — Generadores paramétricos de cascos.
Exporta la función unificada ``generate_hull`` y los constantes
de familia usados por el wizard de nuevo proyecto.
Uso rápido
----------
>>> from arshipdesign.parametric import generate_hull, HullFamily
>>> hull = generate_hull(
... family=HullFamily.DISPLACEMENT,
... lpp=15.0, beam=4.0, draft=1.60, depth=2.30,
... cb=0.55,
... )
"""
from __future__ import annotations
from enum import Enum, auto
from typing import Any
from arshipdesign.core.hull import Hull
class HullFamily(str, Enum):
"""Familia de carena disponible en el wizard."""
PLANING = "planing" # Planeador — V-fondo, chine dura
DISPLACEMENT = "displacement" # Desplazamiento — carena redonda
SEMI_DISP = "semi_disp" # Semi-desplazamiento
WORKBOAT = "workboat" # Workboat / Supply / Remolcador
SAILING = "sailing" # Velero monocasco fin keel
MERCHANT = "merchant" # Buque mercante / Serie 60
@property
def label_es(self) -> str:
return {
"planing": "Planeo",
"displacement":"Desplazamiento",
"semi_disp": "Semi-desplazamiento",
"workboat": "Workboat / Supply",
"sailing": "Velero",
"merchant": "Mercante / Supply full",
}[self.value]
@property
def cb_default(self) -> float:
return {
"planing": 0.43,
"displacement":0.55,
"semi_disp": 0.50,
"workboat": 0.67,
"sailing": 0.40,
"merchant": 0.70,
}[self.value]
@property
def cb_range(self) -> tuple[float, float]:
return {
"planing": (0.38, 0.48),
"displacement":(0.45, 0.65),
"semi_disp": (0.46, 0.58),
"workboat": (0.60, 0.75),
"sailing": (0.35, 0.46),
"merchant": (0.60, 0.82),
}[self.value]
@property
def description_es(self) -> str:
return {
"planing":
"Embarcación rápida con fondo en V y chine dura.\n"
"Fn > 0.50 — lanchas, patrulleras, RIBs.",
"displacement":
"Carena redondeada de velocidad moderada.\n"
"Fn 0.200.35 — cruceros, pesqueros, ferrys.",
"semi_disp":
"Compromiso entre planeo y desplazamiento.\n"
"Fn 0.350.55 — yates de motor, patrulleras.",
"workboat":
"Sección cajón con pantoque duro.\n"
"Fn < 0.22 — remolcadores, supply, barcazas.",
"sailing":
"Cuerpo fino con quilla de aleta.\n"
"Veleros de recreo y regata.",
"merchant":
"Formas llenas tipo Serie 60.\n"
"Fn < 0.20 — carga, RORO, buques de trabajo.",
}[self.value]
# ---------------------------------------------------------------------------
# Función unificada
# ---------------------------------------------------------------------------
def generate_hull(
family: HullFamily | str,
lpp: float,
beam: float,
draft: float,
depth: float | None = None,
name: str = "",
n_stations: int = 21,
n_waterlines: int = 11,
**kwargs: Any,
) -> Hull:
"""Genera un Hull paramétrico de la familia indicada.
Parámetros
----------
family : HullFamily
Tipo de carena (ver HullFamily).
lpp : float
Eslora entre perpendiculares [m].
beam : float
Manga máxima [m].
draft : float
Calado de diseño [m].
depth : float, optional
Puntal de trazado [m]. Si es None usa draft * 1.45.
name : str
Nombre del proyecto/casco.
n_stations : int
Número de estaciones transversales (≥ 7, default 21).
n_waterlines : int
Número de líneas de agua (≥ 5, default 11).
**kwargs
Parámetros adicionales específicos de cada familia
(p.ej. deadrise_mid para planing, cb para displacement, etc.)
Retorna
-------
Hull
"""
fam = HullFamily(family) if isinstance(family, str) else family
if depth is None:
depth = draft * 1.45
if not name:
name = f"{fam.label_es} {lpp:.0f}m"
common = dict(
name=name, lpp=lpp, beam=beam, draft=draft, depth=depth,
n_stations=n_stations, n_waterlines=n_waterlines,
)
common.update(kwargs)
if fam == HullFamily.PLANING:
from arshipdesign.parametric.wizard_planing import make_planing_hull
return make_planing_hull(**common)
elif fam in (HullFamily.DISPLACEMENT, HullFamily.SEMI_DISP):
from arshipdesign.parametric.wizard_cruiser import make_displacement_hull
if fam == HullFamily.SEMI_DISP and "cb" not in kwargs:
common.setdefault("cb", 0.50)
return make_displacement_hull(**common)
elif fam == HullFamily.WORKBOAT:
from arshipdesign.parametric.wizard_workboat import make_workboat_hull
return make_workboat_hull(**common)
elif fam == HullFamily.SAILING:
from arshipdesign.parametric.wizard_sailing_mono import make_sailing_hull
return make_sailing_hull(**common)
elif fam == HullFamily.MERCHANT:
from arshipdesign.parametric.series60 import make_merchant_hull
return make_merchant_hull(**common)
else:
raise ValueError(f"Familia de carena desconocida: {fam!r}")
+110 -2
View File
@@ -1,2 +1,110 @@
"""Serie 60. Stub — Sprint 11."""
raise NotImplementedError("series60 — Sprint 11")
"""
Generador paramétrico — Buque mercante / Supply full (inspirado en Serie 60).
Genera cascos para buques de carga, supply vessels y embarcaciones de trabajo
con coeficientes de bloque altos:
- Fondo casi plano en la zona central
- Cuadernas rectangulares con pantoque redondeado
- Proa de bulbo (simplificada) o proa recta
- Popa transom o popa de crucero
Parámetros típicos:
Cb: 0.60 0.80
Froude: 0.12 0.22
Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
"""
from __future__ import annotations
import numpy as np
from arshipdesign.core.hull import Hull
from arshipdesign.core.offsets import OffsetsTable
def make_merchant_hull(
name: str = "Buque Mercante / Supply",
lpp: float = 20.0,
beam: float = 6.0,
draft: float = 2.40,
depth: float = 3.20,
cb: float = 0.70,
lcb_frac: float = 0.50,
cm: float = 0.96, # sección maestra casi rectangular
bilge_radius_frac: float = 0.08,
flat_bottom_frac: float = 0.90,
n_stations: int = 21,
n_waterlines: int = 11,
) -> Hull:
"""Genera un casco tipo Serie 60 / buque mercante.
Parámetros
----------
cb : float
Coeficiente de bloque objetivo (0.600.80).
cm : float
Coeficiente de cuaderna maestra (0.930.98).
bilge_radius_frac : float
Radio del pantoque / calado (0.060.14).
flat_bottom_frac : float
Ancho del fondo plano / manga (0.850.94).
"""
x_sta = np.linspace(0.0, lpp, n_stations)
z_wl = np.linspace(0.0, draft, n_waterlines)
xi = (x_sta / lpp - 0.5) * 2.0
lcb_shift = 2.0 * (lcb_frac - 0.5)
f_plan = _merchant_plan_form(xi, cb, lcb_shift)
r_bilge = draft * bilge_radius_frac
y_flat_b = (beam / 2.0) * flat_bottom_frac
data = np.zeros((n_stations, n_waterlines))
for i in range(n_stations):
y_wl = (beam / 2.0) * f_plan[i]
scale = f_plan[i]
y_flat_i = y_flat_b * scale
r_bilge_i = r_bilge * max(scale, 0.25)
for j, z in enumerate(z_wl):
if z <= r_bilge_i:
dz = r_bilge_i - z
inner = max(0.0, r_bilge_i**2 - dz**2)
y = y_flat_i - r_bilge_i + np.sqrt(inner)
else:
# Costados casi verticales (Cm alto)
t = (z - r_bilge_i) / max(draft - r_bilge_i, 1e-6)
# Cm ajusta plenitud de la zona de costados
flare_side = (1.0 - cm) * 0.15
y = y_flat_i + (y_wl - y_flat_i) * (t + flare_side * t * (1 - t))
y = min(y, y_wl)
data[i, j] = max(0.0, y)
offsets = OffsetsTable(
x_stations=x_sta,
z_waterlines=z_wl,
data=data,
station_labels=[f"S{i}" for i in range(n_stations)],
lpp=lpp, beam=beam, draft=draft,
)
return Hull(
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets
)
def _merchant_plan_form(
xi: np.ndarray, cb: float, lcb_shift: float
) -> np.ndarray:
"""Plan form mercante: central muy llena, extremos redondeados."""
n = _merchant_plan_exponent(cb)
xi_s = np.clip(xi - lcb_shift, -1.0, 1.0)
return np.maximum(0.0, 1.0 - np.abs(xi_s) ** n)
def _merchant_plan_exponent(cb: float) -> float:
cb_vals = [0.58, 0.65, 0.70, 0.75, 0.82]
n_vals = [3.0, 4.0, 5.0, 6.0, 8.0]
return float(np.interp(cb, cb_vals, n_vals))
+130 -2
View File
@@ -1,2 +1,130 @@
"""Wizard crucero. Stub — Sprint 11."""
raise NotImplementedError("wizard_cruiser — Sprint 11")
"""
Generador paramétrico — Casco de Desplazamiento / Crucero (carena redonda).
Genera una tabla de offsets para embarcaciones de desplazamiento:
- Carena redondeada (round bilge)
- Sección maestra llena (Cm 0.820.90)
- Proa fina, popa de espejo o crucero
- LCB ajustable
Parámetros típicos:
Cb: 0.45 0.65
Velocidad/Froude: Fn 0.20 0.35
Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
"""
from __future__ import annotations
import numpy as np
from arshipdesign.core.hull import Hull
from arshipdesign.core.offsets import OffsetsTable
def make_displacement_hull(
name: str = "Crucero de Desplazamiento",
lpp: float = 12.0,
beam: float = 3.80,
draft: float = 1.40,
depth: float = 2.20,
cb: float = 0.55,
lcb_frac: float = 0.52, # fracción de Lpp desde AP
cm: float = 0.86, # coeficiente de cuaderna maestra
n_stations: int = 21,
n_waterlines: int = 11,
) -> Hull:
"""Genera un casco de desplazamiento de carena redonda.
Parámetros
----------
cb : float
Coeficiente de bloque objetivo (0.450.65).
lcb_frac : float
Posición del LCB como fracción de Lpp desde AP (0.500.55).
cm : float
Coeficiente de cuaderna maestra (0.820.92).
"""
x_sta = np.linspace(0.0, lpp, n_stations)
z_wl = np.linspace(0.0, draft, n_waterlines)
xi = (x_sta / lpp - 0.5) * 2.0 # ∈ [1, 1], 0=midship
# ── Plan form (semi-manga en flotación) ────────────────────────────
# LCB desplazado del midship
lcb_shift = 2.0 * (lcb_frac - 0.5) # ∈ [1, 1]
f_plan = _displacement_plan_form(xi, cb, lcb_shift)
# ── Exponente de forma de sección ──────────────────────────────────
# alpha controla la plenitud de la sección transversal
# alpha pequeño → sección llena (Cm alto)
# alpha = 2*(1 - Cm) según aproximación de Munro-Smith
alpha_mid = max(0.25, 2.0 * (1.0 - cm)) # ≈ 0.28 para Cm=0.86
data = np.zeros((n_stations, n_waterlines))
for i in range(n_stations):
y_wl = (beam / 2.0) * f_plan[i]
# El exponente de sección varía: más fino en proa/popa
local_fullness = f_plan[i]
# En extremos (local_fullness→0) el exponente sube → sección más en V
alpha = alpha_mid + (1.0 - alpha_mid) * (1.0 - local_fullness ** 0.5)
alpha = np.clip(alpha, alpha_mid, 0.80)
for j, z in enumerate(z_wl):
v = z / draft # ∈ [0, 1]
data[i, j] = y_wl * (v ** alpha)
data = np.clip(data, 0.0, None)
offsets = OffsetsTable(
x_stations=x_sta,
z_waterlines=z_wl,
data=data,
station_labels=[f"S{i}" for i in range(n_stations)],
lpp=lpp, beam=beam, draft=draft,
)
return Hull(
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets
)
def _displacement_plan_form(
xi: np.ndarray, cb: float, lcb_shift: float
) -> np.ndarray:
"""
Plan form normalizada para carena de desplazamiento.
Usa una distribución tipo seno modificado con el apex desplazado
según la posición del LCB.
xi ∈ [1, 1], 1=AP, +1=FP.
Retorna f ∈ [0, 1].
"""
# Exponent de la plan form: más alto = más llena = Cb mayor
n = _plan_exponent(cb)
xi_shifted = xi - lcb_shift
xi_shifted = np.clip(xi_shifted, -1.0, 1.0)
# Plan form base: bell curve asimétrica
# Parte de entrada (FP): más suave
# Parte de salida (AP): ligeramente más llena
f = np.zeros_like(xi)
for k, x in enumerate(xi_shifted):
if x <= 0:
# Run (desde midship hacia AP): ligeramente más llena
t = -x
f[k] = max(0.0, 1.0 - (t ** (n * 0.90)))
else:
# Entry (desde midship hacia FP): estándar
t = x
f[k] = max(0.0, 1.0 - (t ** n))
return f
def _plan_exponent(cb: float) -> float:
"""Mapa Cb → exponente de plan form."""
cb_vals = [0.35, 0.45, 0.55, 0.65, 0.75]
n_vals = [1.2, 1.6, 2.0, 2.7, 3.5]
return float(np.interp(cb, cb_vals, n_vals))
+158 -2
View File
@@ -1,2 +1,158 @@
"""Wizard planeador. Stub — Sprint 11."""
raise NotImplementedError("wizard_planing — Sprint 11")
"""
Generador paramétrico — Casco Planeador (V-fondo, chine dura).
Genera una tabla de offsets realista para embarcaciones de planeo:
- Fondo en V con ángulo de astilla muerta variable
- Chine dura recorriendo toda la eslora
- Popa de espejo plana
- Proa aguda con alto ángulo de astilla muerta
- Sección transversal: dos regiones (bajo/sobre chine)
Parámetros típicos:
Cb: 0.40 0.48
Deadrise midship: 12° 22°
Deadrise bow: 35° 55°
Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
"""
from __future__ import annotations
import numpy as np
from arshipdesign.core.hull import Hull
from arshipdesign.core.offsets import OffsetsTable
# ---------------------------------------------------------------------------
# API pública
# ---------------------------------------------------------------------------
def make_planing_hull(
name: str = "Planeador",
lpp: float = 10.0,
beam: float = 3.0,
draft: float = 0.70,
depth: float = 1.20,
deadrise_mid: float = 18.0, # grados en cuaderna maestra
deadrise_bow: float = 45.0, # grados en proa
deadrise_stern: float = 8.0, # grados en popa
chine_frac: float = 0.55, # z_chine / draft
flare: float = 0.10, # ensanchamiento por encima del chine [fracción]
n_stations: int = 21,
n_waterlines: int = 11,
) -> Hull:
"""Genera un casco planeador con forma V y chine dura.
Parámetros
----------
deadrise_mid : float
Ángulo de astilla muerta [°] en la cuaderna maestra (midship).
Valores típicos: 12°–22°.
deadrise_bow : float
Ángulo de astilla muerta [°] en proa.
Valores típicos: 35°–55°.
chine_frac : float
Altura del chine como fracción del calado (01).
flare : float
Fracción de ensanchamiento por encima del chine (0 = sin ensanche).
"""
x_sta = np.linspace(0.0, lpp, n_stations)
z_wl = np.linspace(0.0, draft, n_waterlines)
xi = (x_sta / lpp - 0.5) * 2.0 # normalizado ∈ [1, 1], 0=midship
# ── Plan form: ancho en línea de agua por estación ─────────────────
# El planeador tiene popa muy ancha y proa más estrecha
# f_stern > f_bow para que la popa sea la sección más llena
f_plan = _planing_plan_form(xi) # ∈ [0, 1]
# ── Ángulo de astilla muerta varía a lo largo de la eslora ─────────
dr_arr = _deadrise_distribution(xi, deadrise_mid, deadrise_bow, deadrise_stern)
data = np.zeros((n_stations, n_waterlines))
for i in range(n_stations):
y_max = (beam / 2.0) * f_plan[i]
z_c = draft * chine_frac # altura del chine
dr = np.radians(dr_arr[i]) # ángulo de astilla en esta estación
for j, z in enumerate(z_wl):
if z <= z_c:
# ── Zona V-fondo (bajo el chine) ──────────────────────
# y_chine se calcula desde el ángulo de astilla muerta
# tan(dr) = z_c / y_chine → y_chine = z_c / tan(dr)
tan_dr = max(np.tan(dr), 0.01)
y_chine_dr = z_c / tan_dr
y_chine = min(y_chine_dr, y_max)
y = y_chine * (z / z_c)
else:
# ── Zona sobre el chine (costado) ─────────────────────
tan_dr = max(np.tan(dr), 0.01)
y_chine_dr = z_c / tan_dr
y_chine = min(y_chine_dr, y_max)
# Ligero ensanchamiento por encima del chine
y = y_chine + (y_max - y_chine) * flare * (z - z_c) / (draft - z_c + 1e-9)
y = min(y, y_max)
data[i, j] = max(0.0, y)
offsets = OffsetsTable(
x_stations=x_sta,
z_waterlines=z_wl,
data=data,
station_labels=[f"S{i}" for i in range(n_stations)],
lpp=lpp, beam=beam, draft=draft,
)
return Hull(
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets
)
# ---------------------------------------------------------------------------
# Helpers internos
# ---------------------------------------------------------------------------
def _planing_plan_form(xi: np.ndarray) -> np.ndarray:
"""
Plan form del planeador: sección de popa llena, proa estrecha.
xi ∈ [1, 1], xi=1 AP (popa), xi=+1 FP (proa).
El planeador tiene la sección maestra desplazada hacia popa (~35% Lpp desde FP).
"""
# Apex en xi ≈ 0.3 (30% desde popa) → máximo ancho allí
apex = -0.30
# Forma asimétrica: run suave hacia popa, proa afilada
f = np.zeros_like(xi)
for k, x in enumerate(xi):
if x >= apex:
# Desde apex hacia proa: caída rápida (proa aguda)
t = (x - apex) / (1.0 - apex)
f[k] = (1.0 - t ** 1.6)
else:
# Desde apex hacia popa: espejo plano, caída suave
t = (apex - x) / (1.0 + apex)
f[k] = (1.0 - t ** 2.8)
return np.maximum(f, 0.0)
def _deadrise_distribution(
xi: np.ndarray,
dr_mid: float,
dr_bow: float,
dr_stern: float,
) -> np.ndarray:
"""Distribución del ángulo de astilla muerta a lo largo de la eslora.
Varía suavemente de dr_stern (popa) → dr_mid (midship) → dr_bow (proa).
xi ∈ [1, 1], 1=AP, +1=FP.
"""
dr = np.zeros_like(xi)
for k, x in enumerate(xi):
if x >= 0.0:
# midship → proa
t = x
dr[k] = dr_mid + (dr_bow - dr_mid) * t ** 1.5
else:
# midship → popa
t = -x
dr[k] = dr_mid + (dr_stern - dr_mid) * t ** 2.0
return dr
+116 -2
View File
@@ -1,2 +1,116 @@
"""Wizard velero mono. Stub — Sprint 11."""
raise NotImplementedError("wizard_sailing_mono — Sprint 11")
"""
Generador paramétrico — Velero Monocasco (fin keel).
Genera cascos para veleros de quilla de aleta:
- Cuerpo del casco: fino, secciones en V
- Quilla de aleta: apéndice separado con perfil NACA simplificado
- Proa fina (ángulo de entrada 10°–16°)
- Popa de espejo amplia
Parámetros típicos:
Cb (cuerpo): 0.35 0.44
L/B: 3.0 3.8
Froude de diseño: 0.30 0.45
Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
"""
from __future__ import annotations
import numpy as np
from arshipdesign.core.hull import Hull
from arshipdesign.core.offsets import OffsetsTable
def make_sailing_hull(
name: str = "Velero Monocasco",
lpp: float = 10.0,
beam: float = 3.20,
draft: float = 0.55, # calado del cuerpo (sin quilla)
depth: float = 1.80,
draft_with_keel: float = 1.80, # calado total con quilla
cb: float = 0.40,
lcb_frac: float = 0.53, # ligeramente a popa del midship
cm: float = 0.75,
deadrise_mid: float = 18.0,
n_stations: int = 21,
n_waterlines: int = 9,
) -> Hull:
"""Genera un casco de velero de quilla de aleta.
El Hull resultante representa el cuerpo del casco sin la quilla.
La quilla de aleta se añade como apéndice en el módulo de geometría.
Parámetros
----------
draft : float
Calado del cuerpo del casco (sin quilla) [m].
draft_with_keel : float
Calado total incluyendo la quilla [m].
deadrise_mid : float
Ángulo de astilla muerta en cuaderna maestra [°].
"""
x_sta = np.linspace(0.0, lpp, n_stations)
z_wl = np.linspace(0.0, draft, n_waterlines)
xi = (x_sta / lpp - 0.5) * 2.0
lcb_shift = 2.0 * (lcb_frac - 0.5)
# Plan form: fina en proa, moderadamente llena a popa
f_plan = _sailing_plan_form(xi, cb, lcb_shift)
# Exponente de sección
alpha_mid = max(0.30, 2.0 * (1.0 - cm)) # ≈ 0.50 para Cm=0.75
data = np.zeros((n_stations, n_waterlines))
dr_mid_rad = np.radians(deadrise_mid)
for i in range(n_stations):
y_wl = (beam / 2.0) * f_plan[i]
# Interpolar entre sección en V (extremos) y sección redonda (midship)
local_f = f_plan[i]
alpha = alpha_mid + (0.75 - alpha_mid) * (1.0 - local_f ** 0.7)
alpha = np.clip(alpha, alpha_mid, 0.80)
for j, z in enumerate(z_wl):
v = z / draft
y = y_wl * (v ** alpha)
data[i, j] = max(0.0, y)
offsets = OffsetsTable(
x_stations=x_sta,
z_waterlines=z_wl,
data=data,
station_labels=[f"S{i}" for i in range(n_stations)],
lpp=lpp, beam=beam, draft=draft,
)
return Hull(
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets
)
def _sailing_plan_form(
xi: np.ndarray, cb: float, lcb_shift: float
) -> np.ndarray:
"""Plan form para velero: fina en proa, moderada en popa."""
n = _sailing_plan_exponent(cb)
xi_s = np.clip(xi - lcb_shift, -1.0, 1.0)
f = np.zeros_like(xi)
for k, x in enumerate(xi_s):
if x <= 0:
# Run (AP): más llena que la entry
t = -x
f[k] = max(0.0, 1.0 - t ** (n * 0.85))
else:
# Entry (FP): fina, típico de velero
t = x
f[k] = max(0.0, 1.0 - t ** n)
return f
def _sailing_plan_exponent(cb: float) -> float:
cb_vals = [0.30, 0.36, 0.42, 0.48, 0.54]
n_vals = [1.0, 1.3, 1.6, 2.0, 2.4]
return float(np.interp(cb, cb_vals, n_vals))
+112 -2
View File
@@ -1,2 +1,112 @@
"""Wizard workboat. Stub — Sprint 11."""
raise NotImplementedError("wizard_workboat Sprint 11")
"""
Generador paramétrico — Workboat / Supply / Remolcador (sección cajón).
Genera cascos de trabajo con:
- Sección transversal rectangular-redondeada
- Fondo casi plano (quilla de barra)
- Radio de pantoque duro
- Cuadernas muy llenas (Cm 0.920.97)
- Proa de bulbo cilíndrico o proa plana
Parámetros típicos:
Cb: 0.60 0.75
Velocidad/Froude: Fn 0.12 0.22
Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
"""
from __future__ import annotations
import numpy as np
from arshipdesign.core.hull import Hull
from arshipdesign.core.offsets import OffsetsTable
def make_workboat_hull(
name: str = "Workboat / Supply",
lpp: float = 15.0,
beam: float = 5.0,
draft: float = 1.80,
depth: float = 2.60,
cb: float = 0.67,
lcb_frac: float = 0.50,
bilge_radius_frac: float = 0.12, # radio de pantoque / calado
flat_bottom_frac: float = 0.85, # ancho fondo plano / manga
n_stations: int = 21,
n_waterlines: int = 11,
) -> Hull:
"""Genera un casco tipo workboat con sección cajón.
Parámetros
----------
bilge_radius_frac : float
Radio del pantoque como fracción del calado (0.080.18).
flat_bottom_frac : float
Anchura del fondo plano como fracción de la manga (0.800.92).
"""
x_sta = np.linspace(0.0, lpp, n_stations)
z_wl = np.linspace(0.0, draft, n_waterlines)
xi = (x_sta / lpp - 0.5) * 2.0
lcb_shift = 2.0 * (lcb_frac - 0.5)
# Plan form: muy llena, extremos redondeados pero no agudos
f_plan = _workboat_plan_form(xi, cb, lcb_shift)
# Geometría del pantoque
r_bilge = draft * bilge_radius_frac # radio en metros
y_flat = (beam / 2.0) * flat_bottom_frac # semi-manga del fondo plano
data = np.zeros((n_stations, n_waterlines))
for i in range(n_stations):
y_wl = (beam / 2.0) * f_plan[i]
# Escalar fondo plano y radio de pantoque con el ancho de la estación
scale = f_plan[i]
y_flat_i = y_flat * scale
r_bilge_i = r_bilge * max(scale, 0.3)
for j, z in enumerate(z_wl):
if z <= r_bilge_i:
# ── Zona de pantoque redondeado ──────────────────────
# Arco de círculo: y² + (z - r)² = r² → y = sqrt(r² - (r-z)²)
dz = r_bilge_i - z
inner = max(0.0, r_bilge_i**2 - dz**2)
y = y_flat_i - r_bilge_i + np.sqrt(inner)
else:
# ── Zona de costado (cuasi-vertical) ─────────────────
# Ligero flare hacia afuera
flare = 0.04
y = y_flat_i + (y_wl - y_flat_i) * (z - r_bilge_i) / (draft - r_bilge_i + 1e-9)
y = min(y, y_wl)
data[i, j] = max(0.0, y)
offsets = OffsetsTable(
x_stations=x_sta,
z_waterlines=z_wl,
data=data,
station_labels=[f"S{i}" for i in range(n_stations)],
lpp=lpp, beam=beam, draft=draft,
)
return Hull(
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets
)
def _workboat_plan_form(
xi: np.ndarray, cb: float, lcb_shift: float
) -> np.ndarray:
"""Plan form para workboat: extremos redondeados pero llenos."""
n = _workboat_plan_exponent(cb)
xi_s = np.clip(xi - lcb_shift, -1.0, 1.0)
f = np.maximum(0.0, 1.0 - np.abs(xi_s) ** n)
return f
def _workboat_plan_exponent(cb: float) -> float:
"""Cb → exponente plan form (workboat más llena que displacement)."""
cb_vals = [0.55, 0.62, 0.68, 0.73, 0.78]
n_vals = [2.5, 3.0, 3.8, 4.5, 5.5]
return float(np.interp(cb, cb_vals, n_vals))
+875 -2
View File
@@ -1,2 +1,875 @@
"""Wizards embarcaciones. Stub — Sprint 1."""
raise NotImplementedError("wizardsSprint 1")
"""
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 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}", "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}", "Á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()
+15 -2
View File
@@ -1181,9 +1181,22 @@ class MainWindow(QMainWindow):
def _on_new_project(self) -> None:
if self._project and self._project.is_modified and not self._ask_save():
return
self._project = Project.new("Proyecto sin título")
from arshipdesign.ui.dialogs.wizards import NewShipWizard
wiz = NewShipWizard(self)
if wiz.exec() != wiz.DialogCode.Accepted:
return
hull = wiz.result_hull()
self._project = Project.new(hull.name if hull else "Proyecto sin título")
self._on_project_loaded()
self.statusBar().showMessage("Nuevo proyecto creado")
# Cargar geometría en el visor 3D
if hull is not None and self._viewer_3d is not None:
try:
self._viewer_3d.load_hull(hull)
except Exception as exc:
logger.warning("No se pudo cargar hull en visor 3D: %s", exc)
self.statusBar().showMessage(
f"Nuevo proyecto: {self._project.name}"
)
def _on_open_project(self) -> None:
if self._project and self._project.is_modified and not self._ask_save():