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:
@@ -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.20–0.35 — cruceros, pesqueros, ferrys.",
|
||||
"semi_disp":
|
||||
"Compromiso entre planeo y desplazamiento.\n"
|
||||
"Fn 0.35–0.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}")
|
||||
|
||||
@@ -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.60–0.80).
|
||||
cm : float
|
||||
Coeficiente de cuaderna maestra (0.93–0.98).
|
||||
bilge_radius_frac : float
|
||||
Radio del pantoque / calado (0.06–0.14).
|
||||
flat_bottom_frac : float
|
||||
Ancho del fondo plano / manga (0.85–0.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))
|
||||
|
||||
@@ -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.82–0.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.45–0.65).
|
||||
lcb_frac : float
|
||||
Posición del LCB como fracción de Lpp desde AP (0.50–0.55).
|
||||
cm : float
|
||||
Coeficiente de cuaderna maestra (0.82–0.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))
|
||||
|
||||
@@ -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 (0–1).
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.92–0.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.08–0.18).
|
||||
flat_bottom_frac : float
|
||||
Anchura del fondo plano como fracción de la manga (0.80–0.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))
|
||||
|
||||
@@ -1,2 +1,875 @@
|
||||
"""Wizards embarcaciones. Stub — Sprint 1."""
|
||||
raise NotImplementedError("wizards — Sprint 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 30–82 → lo–hi
|
||||
slider_lo = int(lo * 100)
|
||||
slider_hi = int(hi * 100)
|
||||
self._cb_slider.setRange(slider_lo, slider_hi)
|
||||
mid = (slider_lo + slider_hi) // 2
|
||||
self._cb_slider.setValue(mid)
|
||||
|
||||
def get_values(self) -> dict:
|
||||
return {
|
||||
"cb": self._cb_slider.value() / 100.0,
|
||||
"lcb_frac": self._lcb_slider.value() / 100.0,
|
||||
"n_stations": self._n_sta.value(),
|
||||
"n_waterlines": self._n_wl.value(),
|
||||
}
|
||||
|
||||
|
||||
class _StepPreview(QWidget):
|
||||
"""Paso 4: Resumen de hidrostáticos calculados."""
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._hull: Optional[Hull] = None
|
||||
self._build()
|
||||
|
||||
def _build(self) -> None:
|
||||
lo = QVBoxLayout(self)
|
||||
lo.setContentsMargins(20, 10, 20, 10)
|
||||
lo.setSpacing(10)
|
||||
|
||||
title = QLabel("Resumen del proyecto")
|
||||
title.setStyleSheet(f"color:{_ACCENT}; font-size:16px; font-weight:700;")
|
||||
lo.addWidget(title)
|
||||
|
||||
self._info_lbl = QLabel("Calculando…")
|
||||
self._info_lbl.setStyleSheet(f"color:{_MUTED}; font-size:11px;")
|
||||
lo.addWidget(self._info_lbl)
|
||||
|
||||
# Tabla de hidrostáticos
|
||||
self._hydro_frame = QFrame()
|
||||
self._hydro_frame.setStyleSheet(
|
||||
f"background:{_PANEL}; border:1px solid {_BORDER}; border-radius:4px;"
|
||||
)
|
||||
self._hydro_lo = QGridLayout(self._hydro_frame)
|
||||
self._hydro_lo.setSpacing(6)
|
||||
self._hydro_lo.setContentsMargins(14, 10, 14, 10)
|
||||
lo.addWidget(self._hydro_frame)
|
||||
|
||||
# Thumbnail del body plan
|
||||
self._body_thumb = HullThumbnail(HullFamily.DISPLACEMENT, size=120)
|
||||
lo.addWidget(self._body_thumb, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
lo.addStretch()
|
||||
|
||||
def _clear_hydro(self) -> None:
|
||||
while self._hydro_lo.count():
|
||||
item = self._hydro_lo.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
def update_hull(self, hull: Optional[Hull], family: HullFamily) -> None:
|
||||
self._clear_hydro()
|
||||
self._body_thumb.family = family
|
||||
self._body_thumb.selected = True
|
||||
self._body_thumb.update()
|
||||
|
||||
if hull is None:
|
||||
self._info_lbl.setText("Sin embarcación generada.")
|
||||
return
|
||||
|
||||
self._hull = hull
|
||||
self._info_lbl.setText(
|
||||
f"<b>{hull.name}</b> — {family.label_es}"
|
||||
)
|
||||
|
||||
rows = [
|
||||
("Lpp", f"{hull.lpp:.2f} m", "Eslora entre perpendiculares"),
|
||||
("Manga (B)", f"{hull.beam:.2f} m", "Manga máxima"),
|
||||
("Calado (T)", f"{hull.draft:.2f} m", "Calado de diseño"),
|
||||
("Puntal (D)", f"{hull.depth:.2f} m", "Puntal de trazado"),
|
||||
("Volumen (V)", f"{hull.volume_of_displacement():.2f} m³", "Volumen de carena"),
|
||||
("Desplazamiento",f"{hull.displacement_tonnes():.2f} t", "Agua salada (ρ=1025)"),
|
||||
("Cb", f"{hull.block_coefficient():.3f}", "Coeficiente de bloque"),
|
||||
("LCB", f"{hull.lcb():.2f} m ({hull.lcb()/hull.lpp*100:.1f}% Lpp)",
|
||||
"Centro long. de carena desde AP"),
|
||||
("KB", f"{hull.vcb():.3f} m", "Centro vertical de carena"),
|
||||
("Awp", f"{hull.waterplane_area():.2f} m²", "Área plano de flotación"),
|
||||
]
|
||||
|
||||
for row_idx, (param, value, tooltip) in enumerate(rows):
|
||||
p_lbl = QLabel(param)
|
||||
v_lbl = QLabel(value)
|
||||
p_lbl.setStyleSheet(f"color:{_MUTED}; font-size:11px;")
|
||||
v_lbl.setStyleSheet(f"color:{_TEXT}; font-size:11px; font-weight:600;")
|
||||
p_lbl.setToolTip(tooltip)
|
||||
v_lbl.setToolTip(tooltip)
|
||||
self._hydro_lo.addWidget(p_lbl, row_idx, 0)
|
||||
self._hydro_lo.addWidget(v_lbl, row_idx, 1)
|
||||
|
||||
def get_hull(self) -> Optional[Hull]:
|
||||
return self._hull
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Wizard principal
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class NewShipWizard(QDialog):
|
||||
"""Wizard de nuevo proyecto — 4 pasos.
|
||||
|
||||
Uso:
|
||||
----
|
||||
>>> w = NewShipWizard(parent)
|
||||
>>> if w.exec() == QDialog.DialogCode.Accepted:
|
||||
... hull = w.result_hull()
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Nuevo Proyecto — AR-ShipDesign")
|
||||
self.setMinimumSize(780, 560)
|
||||
self.setModal(True)
|
||||
self.setStyleSheet(f"""
|
||||
QDialog {{
|
||||
background: {_BG};
|
||||
color: {_TEXT};
|
||||
}}
|
||||
QLabel {{
|
||||
color: {_TEXT};
|
||||
background: transparent;
|
||||
}}
|
||||
""")
|
||||
|
||||
self._step = 0
|
||||
self._n_steps = 4
|
||||
self._result: Optional[Hull] = None
|
||||
self._build()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construcción
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build(self) -> None:
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
# ── Header ───────────────────────────────────────────────────
|
||||
header = QWidget()
|
||||
header.setFixedHeight(56)
|
||||
header.setStyleSheet(
|
||||
f"background: {_PANEL}; border-bottom: 2px solid {_ACCENT};"
|
||||
)
|
||||
h_lo = QHBoxLayout(header)
|
||||
h_lo.setContentsMargins(20, 0, 20, 0)
|
||||
|
||||
title_lbl = QLabel("Nueva Embarcación")
|
||||
title_lbl.setStyleSheet(
|
||||
f"color:{_ACCENT}; font-size:18px; font-weight:700; background:transparent;"
|
||||
)
|
||||
h_lo.addWidget(title_lbl)
|
||||
h_lo.addStretch()
|
||||
|
||||
self._step_lbl = QLabel("Paso 1 de 4")
|
||||
self._step_lbl.setStyleSheet(f"color:{_MUTED}; font-size:11px; background:transparent;")
|
||||
h_lo.addWidget(self._step_lbl)
|
||||
root.addWidget(header)
|
||||
|
||||
# ── Indicador de pasos ────────────────────────────────────────
|
||||
self._step_bar = _StepBar(
|
||||
["Dimensiones", "Carena", "Refinamiento", "Resumen"],
|
||||
parent=self,
|
||||
)
|
||||
root.addWidget(self._step_bar)
|
||||
|
||||
# ── Contenido (stackedWidget) ─────────────────────────────────
|
||||
self._stack = QStackedWidget()
|
||||
self._s1 = _StepDimensions()
|
||||
self._s2 = _StepFamily()
|
||||
self._s3 = _StepRefine()
|
||||
self._s4 = _StepPreview()
|
||||
|
||||
self._stack.addWidget(self._s1)
|
||||
self._stack.addWidget(self._s2)
|
||||
self._stack.addWidget(self._s3)
|
||||
self._stack.addWidget(self._s4)
|
||||
root.addWidget(self._stack, 1)
|
||||
|
||||
# Conectar familia → actualizar refinamiento
|
||||
self._s2.family_changed.connect(self._s3.update_for_family)
|
||||
|
||||
# ── Botones ───────────────────────────────────────────────────
|
||||
btn_bar = QWidget()
|
||||
btn_bar.setStyleSheet(
|
||||
f"background:{_PANEL}; border-top:1px solid {_BORDER};"
|
||||
)
|
||||
btn_lo = QHBoxLayout(btn_bar)
|
||||
btn_lo.setContentsMargins(20, 10, 20, 10)
|
||||
|
||||
self._btn_cancel = QPushButton("Cancelar")
|
||||
self._btn_back = QPushButton("← Atrás")
|
||||
self._btn_next = QPushButton("Siguiente →")
|
||||
self._btn_create = QPushButton("✔ Crear Embarcación")
|
||||
|
||||
for btn in (self._btn_cancel, self._btn_back, self._btn_next):
|
||||
btn.setStyleSheet(
|
||||
f"QPushButton{{background:{_BORDER}; color:{_TEXT}; border:none;"
|
||||
f"border-radius:4px; padding:7px 18px; font-size:12px;}}"
|
||||
f"QPushButton:hover{{background:{_CARD_HL};}}"
|
||||
)
|
||||
self._btn_create.setStyleSheet(
|
||||
f"QPushButton{{background:{_ACCENT}; color:#fff; border:none;"
|
||||
f"border-radius:4px; padding:7px 22px; font-size:12px; font-weight:700;}}"
|
||||
f"QPushButton:hover{{background:#5ab8ff;}}"
|
||||
)
|
||||
|
||||
btn_lo.addWidget(self._btn_cancel)
|
||||
btn_lo.addStretch()
|
||||
btn_lo.addWidget(self._btn_back)
|
||||
btn_lo.addWidget(self._btn_next)
|
||||
btn_lo.addWidget(self._btn_create)
|
||||
root.addWidget(btn_bar)
|
||||
|
||||
# Conexiones
|
||||
self._btn_cancel.clicked.connect(self.reject)
|
||||
self._btn_back.clicked.connect(self._go_back)
|
||||
self._btn_next.clicked.connect(self._go_next)
|
||||
self._btn_create.clicked.connect(self._create)
|
||||
|
||||
self._refresh_buttons()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Navegación
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _go_next(self) -> None:
|
||||
if self._step < self._n_steps - 1:
|
||||
self._step += 1
|
||||
self._stack.setCurrentIndex(self._step)
|
||||
if self._step == 3:
|
||||
self._build_preview()
|
||||
self._refresh_buttons()
|
||||
|
||||
def _go_back(self) -> None:
|
||||
if self._step > 0:
|
||||
self._step -= 1
|
||||
self._stack.setCurrentIndex(self._step)
|
||||
self._refresh_buttons()
|
||||
|
||||
def _refresh_buttons(self) -> None:
|
||||
s = self._step
|
||||
self._step_lbl.setText(f"Paso {s+1} de {self._n_steps}")
|
||||
self._step_bar.set_active(s)
|
||||
self._btn_back.setVisible(s > 0)
|
||||
self._btn_next.setVisible(s < self._n_steps - 1)
|
||||
self._btn_create.setVisible(s == self._n_steps - 1)
|
||||
|
||||
def _build_preview(self) -> None:
|
||||
"""Genera el Hull y actualiza el paso 4."""
|
||||
try:
|
||||
dims = self._s1.get_values()
|
||||
fam = self._s2.get_family()
|
||||
ref = self._s3.get_values()
|
||||
|
||||
hull = generate_hull(
|
||||
family=fam,
|
||||
lpp=dims["lpp"],
|
||||
beam=dims["beam"],
|
||||
draft=dims["draft"],
|
||||
depth=dims["depth"],
|
||||
name=dims["name"],
|
||||
cb=ref["cb"],
|
||||
lcb_frac=ref["lcb_frac"],
|
||||
n_stations=ref["n_stations"],
|
||||
n_waterlines=ref["n_waterlines"],
|
||||
)
|
||||
self._result = hull
|
||||
self._s4.update_hull(hull, fam)
|
||||
except Exception as exc:
|
||||
self._s4.update_hull(None, self._s2.get_family())
|
||||
self._s4._info_lbl.setText(f"Error al generar: {exc}")
|
||||
|
||||
def _create(self) -> None:
|
||||
if self._result is None:
|
||||
self._build_preview()
|
||||
if self._result is not None:
|
||||
self.accept()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Acceso externo
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def result_hull(self) -> Optional[Hull]:
|
||||
"""Retorna el Hull generado (sólo válido tras accept())."""
|
||||
return self._result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Barra visual de pasos
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _StepBar(QWidget):
|
||||
def __init__(self, labels: list[str], parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._labels = labels
|
||||
self._active = 0
|
||||
self.setFixedHeight(40)
|
||||
|
||||
def set_active(self, idx: int) -> None:
|
||||
self._active = idx
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
n = len(self._labels)
|
||||
w, h = self.width(), self.height()
|
||||
step_w = w / n
|
||||
|
||||
for i, lbl in enumerate(self._labels):
|
||||
cx = int(step_w * i + step_w / 2)
|
||||
cy = h // 2
|
||||
|
||||
# Línea de conexión
|
||||
if i > 0:
|
||||
prev_cx = int(step_w * (i - 1) + step_w / 2)
|
||||
color = _ACCENT if i <= self._active else _BORDER
|
||||
p.setPen(QPen(QColor(color), 2))
|
||||
p.drawLine(prev_cx + 10, cy, cx - 10, cy)
|
||||
|
||||
# Círculo
|
||||
r = 10
|
||||
if i < self._active:
|
||||
# Completado
|
||||
p.setBrush(QBrush(QColor(_ACCENT)))
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
|
||||
p.setPen(QPen(QColor("#fff"), 2))
|
||||
p.drawText(cx - r, cy - r, r * 2, r * 2,
|
||||
Qt.AlignmentFlag.AlignCenter, "✓")
|
||||
elif i == self._active:
|
||||
# Activo
|
||||
p.setBrush(QBrush(QColor(_ACCENT)))
|
||||
p.setPen(QPen(QColor("#fff"), 2))
|
||||
p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
|
||||
p.drawText(cx - r, cy - r, r * 2, r * 2,
|
||||
Qt.AlignmentFlag.AlignCenter, str(i + 1))
|
||||
else:
|
||||
# Pendiente
|
||||
p.setBrush(QBrush(QColor(_PANEL)))
|
||||
p.setPen(QPen(QColor(_BORDER), 1))
|
||||
p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
|
||||
p.setPen(QPen(QColor(_MUTED), 1))
|
||||
p.drawText(cx - r, cy - r, r * 2, r * 2,
|
||||
Qt.AlignmentFlag.AlignCenter, str(i + 1))
|
||||
|
||||
# Texto del paso
|
||||
p.setPen(QPen(QColor(_ACCENT if i == self._active else _MUTED)))
|
||||
p.drawText(cx - 50, cy + r + 2, 100, 14,
|
||||
Qt.AlignmentFlag.AlignCenter, lbl)
|
||||
|
||||
p.end()
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user