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:
|
def _on_new_project(self) -> None:
|
||||||
if self._project and self._project.is_modified and not self._ask_save():
|
if self._project and self._project.is_modified and not self._ask_save():
|
||||||
return
|
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._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:
|
def _on_open_project(self) -> None:
|
||||||
if self._project and self._project.is_modified and not self._ask_save():
|
if self._project and self._project.is_modified and not self._ask_save():
|
||||||
|
|||||||
Reference in New Issue
Block a user