diff --git a/arshipdesign/parametric/__init__.py b/arshipdesign/parametric/__init__.py index 1a760cc..fcd3c40 100644 --- a/arshipdesign/parametric/__init__.py +++ b/arshipdesign/parametric/__init__.py @@ -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}") diff --git a/arshipdesign/parametric/series60.py b/arshipdesign/parametric/series60.py index d10bf3a..b2e6586 100644 --- a/arshipdesign/parametric/series60.py +++ b/arshipdesign/parametric/series60.py @@ -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)) diff --git a/arshipdesign/parametric/wizard_cruiser.py b/arshipdesign/parametric/wizard_cruiser.py index 5298ade..f71695c 100644 --- a/arshipdesign/parametric/wizard_cruiser.py +++ b/arshipdesign/parametric/wizard_cruiser.py @@ -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)) diff --git a/arshipdesign/parametric/wizard_planing.py b/arshipdesign/parametric/wizard_planing.py index 4c500cb..a6ee229 100644 --- a/arshipdesign/parametric/wizard_planing.py +++ b/arshipdesign/parametric/wizard_planing.py @@ -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 diff --git a/arshipdesign/parametric/wizard_sailing_mono.py b/arshipdesign/parametric/wizard_sailing_mono.py index de8a6dc..f27b1af 100644 --- a/arshipdesign/parametric/wizard_sailing_mono.py +++ b/arshipdesign/parametric/wizard_sailing_mono.py @@ -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)) diff --git a/arshipdesign/parametric/wizard_workboat.py b/arshipdesign/parametric/wizard_workboat.py index 3486020..7fb9603 100644 --- a/arshipdesign/parametric/wizard_workboat.py +++ b/arshipdesign/parametric/wizard_workboat.py @@ -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)) diff --git a/arshipdesign/ui/dialogs/wizards.py b/arshipdesign/ui/dialogs/wizards.py index 99aa997..b872469 100644 --- a/arshipdesign/ui/dialogs/wizards.py +++ b/arshipdesign/ui/dialogs/wizards.py @@ -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"{hull.name} — {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() diff --git a/arshipdesign/ui/main_window.py b/arshipdesign/ui/main_window.py index dcd1fa0..b8be5cd 100644 --- a/arshipdesign/ui/main_window.py +++ b/arshipdesign/ui/main_window.py @@ -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():