Módulo 1 fixes + Módulo 2 motor hidrostático (Tasks 13–13b)

Fixes Module 1 UI:
- wizard_cruiser/sailing/planing: perfiles sin^n calibrados por Cm, V-bottom
  con ángulo de astilla, corrección zona sobre chine planeador
- viewer_3d: buffer hull pendiente para eliminar race condition 500ms
- viewer_lines: reescritura completa — waterlines visibles, control points
  interactivos (drag DelftShip-style), señal offsets_edited
- main_window: conecta offsets_edited → slot _on_offsets_edited_from_viewer
  que propaga cambios a todos los visores, editor, 3D y barra hidrostática

Módulo 2 — motor HydrostaticCurves (Task 13):
- integrator.py: integrate() (Simpson+trapz), waterplane_strips(), section_areas()
- upright.py: UprightHydrostatics (19 campos), compute_upright() single-pass
- curves_of_form.py: HydrostaticCurves.compute(), at_draft(), to_csv_lines(), to_dict()
- tests/test_module2_hydrostatics.py: 83 tests — Wigley V&V, monotonicidad,
  CSV export, IACS Rec.34 §4.3–4.5; todos los 224 tests pasan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 09:11:58 -04:00
parent 274b3b3f53
commit 98ff57ed08
17 changed files with 1687 additions and 199 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
"""
AR-ShipDesign — Software profesional de diseño naval.
Copyright (c) 2025 Álvaro Rodríguez. Todos los derechos reservados.
Copyright (c) 2025 Álvaro Romero. Todos los derechos reservados.
"""
__version__ = "0.1.0"
__author__ = "Álvaro Rodríguez"
__author__ = "Álvaro Romero"
__license__ = "Propietario"
+30 -1
View File
@@ -1 +1,30 @@
# arshipdesign/hydrostatics
"""
arshipdesign.hydrostatics — motor de hidrostáticos navales.
Módulos
-------
integrator Primitivas numéricas (Simpson/trapecios).
upright UprightHydrostatics (calado único) + compute_upright().
curves_of_form HydrostaticCurves (barrido de calados).
Uso rápido
----------
>>> from arshipdesign.hydrostatics import HydrostaticCurves
>>> curves = HydrostaticCurves.compute(hull, n_points=20)
>>> print(curves.displacements)
"""
from arshipdesign.hydrostatics.upright import (
UprightHydrostatics,
compute_upright,
)
from arshipdesign.hydrostatics.curves_of_form import (
HydrostaticCurves,
CSV_HEADERS,
)
__all__ = [
"UprightHydrostatics",
"compute_upright",
"HydrostaticCurves",
"CSV_HEADERS",
]
+342 -2
View File
@@ -1,2 +1,342 @@
"""Curvas hidrostáticas. Stub — Sprint 2."""
raise NotImplementedError("curves_of_form — Sprint 2")
"""
HydrostaticCurves — curvas de formas hidrostáticas.
Evalúa UprightHydrostatics en *n_points* calados equidistantes entre
el 10 % del calado de diseño y el propio calado de diseño, produciendo
las curvas clásicas usadas en arquitectura naval.
Conforme con IACS Rec.34 §4 — verificación y §6 — trazabilidad.
Autor: Álvaro Romero
Módulo 2 — AR-ShipDesign
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterator
import numpy as np
from arshipdesign.hydrostatics.upright import UprightHydrostatics, compute_upright
# Encabezados de exportación CSV (orden canónico)
CSV_HEADERS: tuple[str, ...] = (
"T[m]", "V[m3]", "Delta[t]", "Awp[m2]",
"LCB[m]", "LCF[m]", "KB[m]",
"IT[m4]", "IL[m4]",
"BMT[m]", "BML[m]", "KMT[m]", "KML[m]",
"TPC[t/cm]", "MCT[tm/cm]",
"Cb", "Cw", "Cm", "Cp",
)
# Mapa campo → atributo de UprightHydrostatics (mismo orden que CSV_HEADERS)
_FIELD_ATTRS: tuple[str, ...] = (
"draft", "volume", "displacement", "awp",
"lcb", "lcf", "kb",
"it", "il",
"bmt", "bml", "kmt", "kml",
"tpc", "mct",
"cb", "cw", "cm", "cp",
)
# ---------------------------------------------------------------------------
# Dataclass principal
# ---------------------------------------------------------------------------
@dataclass
class HydrostaticCurves:
"""Curvas de formas hidrostáticas de un casco naval.
Se construye con el método de clase :meth:`compute`. Una vez creado,
los puntos individuales se acceden con índice (``curves[i]``) o
iteración, y los arrays vectorizados con propiedades nombradas
(``curves.drafts``, ``curves.displacements``, etc.).
Atributos
---------
hull_name : str
Nombre del casco.
lpp : float
Eslora entre perpendiculares [m].
beam : float
Manga máxima [m].
design_draft : float
Calado de diseño [m].
rho : float
Densidad del agua usada [kg/m³].
points : list[UprightHydrostatics]
Puntos de la curva, ordenados por calado creciente.
"""
hull_name: str
lpp: float
beam: float
design_draft: float
rho: float
points: list[UprightHydrostatics] = field(default_factory=list)
# ------------------------------------------------------------------
# Constructor principal
# ------------------------------------------------------------------
@classmethod
def compute(
cls,
hull,
n_points: int = 20,
rho: float = 1025.0,
kg: float | None = None,
t_min_fraction: float = 0.10,
) -> "HydrostaticCurves":
"""Calcula las curvas hidrostáticas completas para *hull*.
Parameters
----------
hull : Hull
Casco de referencia (``arshipdesign.core.hull.Hull``).
n_points : int
Número de calados a evaluar. Mínimo 5.
rho : float
Densidad del agua [kg/m³]. Default 1025 (agua salada).
kg : float | None
KG estimado [m]. Si None se usa ``hull.depth × 0.55``.
t_min_fraction : float
Fracción del calado de diseño para el calado mínimo de cálculo.
Default 0.10 (10 %). Se aplica un piso de 1 mm.
Returns
-------
HydrostaticCurves
"""
n_points = max(5, int(n_points))
T_min = max(hull.draft * float(t_min_fraction), 1e-3)
T_max = float(hull.draft)
drafts = np.linspace(T_min, T_max, n_points)
points = [
compute_upright(hull, float(T), rho=rho, kg=kg)
for T in drafts
]
return cls(
hull_name = str(hull.name),
lpp = float(hull.lpp),
beam = float(hull.beam),
design_draft = T_max,
rho = float(rho),
points = points,
)
# ------------------------------------------------------------------
# Acceso individual y por iteración
# ------------------------------------------------------------------
def __len__(self) -> int:
return len(self.points)
def __getitem__(self, idx: int) -> UprightHydrostatics:
return self.points[idx]
def __iter__(self) -> Iterator[UprightHydrostatics]:
return iter(self.points)
def __repr__(self) -> str:
n = len(self.points)
if n:
T_range = f"{self.points[0].draft:.3f}{self.points[-1].draft:.3f} m"
else:
T_range = "vacío"
return f"HydrostaticCurves({self.hull_name!r}, n={n}, T={T_range})"
# ------------------------------------------------------------------
# Arrays vectorizados (numpy)
# ------------------------------------------------------------------
def _col(self, attr: str) -> np.ndarray:
"""Extrae un atributo de todos los puntos como array numpy."""
return np.array([getattr(p, attr) for p in self.points])
@property
def drafts(self) -> np.ndarray:
"""Calados T [m]."""
return self._col("draft")
@property
def volumes(self) -> np.ndarray:
"""Volúmenes de desplazamiento V [m³]."""
return self._col("volume")
@property
def displacements(self) -> np.ndarray:
"""Desplazamientos Δ [t]."""
return self._col("displacement")
@property
def awp_values(self) -> np.ndarray:
"""Áreas de flotación Awp [m²]."""
return self._col("awp")
@property
def lcb_values(self) -> np.ndarray:
"""LCB desde AP [m]."""
return self._col("lcb")
@property
def lcf_values(self) -> np.ndarray:
"""LCF desde AP [m]."""
return self._col("lcf")
@property
def kb_values(self) -> np.ndarray:
"""KB sobre la quilla [m]."""
return self._col("kb")
@property
def bmt_values(self) -> np.ndarray:
"""Radios metacéntricos transversales BM_T [m]."""
return self._col("bmt")
@property
def bml_values(self) -> np.ndarray:
"""Radios metacéntricos longitudinales BM_L [m]."""
return self._col("bml")
@property
def kmt_values(self) -> np.ndarray:
"""Alturas metacéntricas transversales KM_T [m]."""
return self._col("kmt")
@property
def kml_values(self) -> np.ndarray:
"""Alturas metacéntricas longitudinales KM_L [m]."""
return self._col("kml")
@property
def tpc_values(self) -> np.ndarray:
"""TPC [t/cm]."""
return self._col("tpc")
@property
def mct_values(self) -> np.ndarray:
"""MCT [t·m/cm]."""
return self._col("mct")
@property
def cb_values(self) -> np.ndarray:
"""Coeficientes de bloque Cb [-]."""
return self._col("cb")
@property
def cw_values(self) -> np.ndarray:
"""Coeficientes de flotación Cw [-]."""
return self._col("cw")
@property
def cm_values(self) -> np.ndarray:
"""Coeficientes de cuaderna maestra Cm [-]."""
return self._col("cm")
@property
def cp_values(self) -> np.ndarray:
"""Coeficientes prismáticos Cp [-]."""
return self._col("cp")
# ------------------------------------------------------------------
# Interpolación a calado arbitrario
# ------------------------------------------------------------------
def at_draft(self, T: float) -> UprightHydrostatics:
"""Interpola linealmente todos los hidrostáticos al calado *T*.
Parameters
----------
T : float
Calado de consulta [m]. Se clampea al rango [T_min, T_design].
Returns
-------
UprightHydrostatics
Valores interpolados en *T*.
"""
drafts_arr = self.drafts
T_clamped = float(np.clip(T, drafts_arr[0], drafts_arr[-1]))
kwargs: dict[str, float] = {"draft": T_clamped}
for attr in _FIELD_ATTRS:
if attr == "draft":
continue
col = self._col(attr)
kwargs[attr] = float(np.interp(T_clamped, drafts_arr, col))
return UprightHydrostatics(**kwargs) # type: ignore[arg-type]
# ------------------------------------------------------------------
# Exportación
# ------------------------------------------------------------------
def to_csv_lines(self, sep: str = ",", decimal: str = ".") -> list[str]:
"""Genera las líneas CSV (encabezado + datos).
No requiere pandas.
Parameters
----------
sep : str
Separador de campos. Default ``','``.
decimal : str
Separador decimal. Default ``'.'``.
Returns
-------
list[str]
Lista de cadenas, cada una es una línea CSV sin ``\\n`` final.
"""
lines = [sep.join(CSV_HEADERS)]
for p in self.points:
row = [
f"{p.draft:.4f}", f"{p.volume:.4f}",
f"{p.displacement:.4f}", f"{p.awp:.4f}",
f"{p.lcb:.4f}", f"{p.lcf:.4f}",
f"{p.kb:.4f}", f"{p.it:.4f}",
f"{p.il:.4f}", f"{p.bmt:.4f}",
f"{p.bml:.4f}", f"{p.kmt:.4f}",
f"{p.kml:.4f}", f"{p.tpc:.4f}",
f"{p.mct:.4f}", f"{p.cb:.4f}",
f"{p.cw:.4f}", f"{p.cm:.4f}",
f"{p.cp:.4f}",
]
if decimal != ".":
row = [v.replace(".", decimal) for v in row]
lines.append(sep.join(row))
return lines
def to_dict(self) -> dict:
"""Serializa a dict JSON-serializable.
Útil para almacenar resultados en el archivo ``.arsd``.
"""
return {
"hull_name": self.hull_name,
"lpp": self.lpp,
"beam": self.beam,
"design_draft": self.design_draft,
"rho": self.rho,
"headers": list(CSV_HEADERS),
"points": [
{
"T": p.draft, "V": p.volume,
"Delta": p.displacement, "Awp": p.awp,
"LCB": p.lcb, "LCF": p.lcf,
"KB": p.kb, "IT": p.it,
"IL": p.il, "BMT": p.bmt,
"BML": p.bml, "KMT": p.kmt,
"KML": p.kml, "TPC": p.tpc,
"MCT": p.mct, "Cb": p.cb,
"Cw": p.cw, "Cm": p.cm,
"Cp": p.cp,
}
for p in self.points
],
}
+95 -2
View File
@@ -1,2 +1,95 @@
"""Integradores Simpson. Stub — Sprint 2."""
raise NotImplementedError("integrator — Sprint 2")
"""
Integradores numéricos para hidrostáticos navales.
Regla de Simpson con fallback a trapecios cuando hay menos de 3 puntos.
Conforme con IACS Rec.34 §4.2 — métodos de integración numérica.
Autor: Álvaro Romero
Módulo 2 — AR-ShipDesign
"""
from __future__ import annotations
import numpy as np
from scipy.integrate import simpson as _scipy_simpson
# ---------------------------------------------------------------------------
# Integración 1D
# ---------------------------------------------------------------------------
def integrate(y: np.ndarray, x: np.ndarray) -> float:
"""Integra y(x) usando la regla de Simpson (fallback a trapecios ≤ 2 pts).
Parameters
----------
y : array_like, shape (n,)
Ordenadas.
x : array_like, shape (n,)
Abscisas, monótonamente crecientes.
Returns
-------
float
∫ y dx
"""
y = np.asarray(y, dtype=float)
x = np.asarray(x, dtype=float)
n = len(x)
if n < 2:
return 0.0
if n >= 3:
return float(_scipy_simpson(y, x=x))
return float(np.trapz(y, x))
# ---------------------------------------------------------------------------
# Primitivas para plano de flotación y secciones
# ---------------------------------------------------------------------------
def waterplane_strips(offsets_table, draft: float) -> tuple[np.ndarray, np.ndarray]:
"""Devuelve (x_stations, y_half_breadths) en el calado *draft*.
Parameters
----------
offsets_table : OffsetsTable
Tabla de offsets del casco.
draft : float
Calado al que se evalúa el plano de flotación [m].
Returns
-------
x : np.ndarray, shape (n_sta,)
Posiciones longitudinales de las estaciones [m].
y : np.ndarray, shape (n_sta,)
Semi-mangas en el plano draft [m].
"""
x = offsets_table.x_stations
y = np.array([offsets_table.half_breadth(xi, float(draft)) for xi in x])
return x, y
def section_areas_and_centroids(
sections: list, draft: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Devuelve (x, areas, centroides_z) para todas las secciones al calado *draft*.
Parameters
----------
sections : list[Section]
Lista de secciones del casco.
draft : float
Calado de cálculo [m].
Returns
-------
x : np.ndarray, shape (n_sec,)
Posiciones longitudinales [m].
areas : np.ndarray, shape (n_sec,)
Áreas sumergidas [m²].
cz : np.ndarray, shape (n_sec,)
Centroides verticales de cada sección [m desde quilla].
"""
x = np.array([s.x for s in sections])
a = np.array([s.area(draft=draft) for s in sections])
cz = np.array([s.centroid_z(draft=draft) for s in sections])
return x, a, cz
+227 -2
View File
@@ -1,2 +1,227 @@
"""Hidrostáticos vertical. Stub — Sprint 2."""
raise NotImplementedError("upright — Sprint 2")
"""
UprightHydrostatics — hidrostáticos en condición vertical (quilla recta).
Calcula el conjunto completo de variables hidrostáticas para un calado
dado: volumen, desplazamiento, plano de flotación, metacentros, TPC, MCT
y los cuatro coeficientes de forma.
Conforme con IACS Rec.34 §4.3 — verificación analítica y §6 — trazabilidad.
Autor: Álvaro Romero
Módulo 2 — AR-ShipDesign
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from arshipdesign.hydrostatics.integrator import (
integrate,
waterplane_strips,
section_areas_and_centroids,
)
# ---------------------------------------------------------------------------
# Dataclass de resultado
# ---------------------------------------------------------------------------
@dataclass
class UprightHydrostatics:
"""Conjunto completo de hidrostáticos upright a un calado dado.
Todos los valores referidos a:
- Origen longitudinal: AP (x = 0)
- Origen vertical: quilla (z = 0)
- Plano de crujía: eje de simetría
Atributos
---------
draft : float
Calado T [m].
volume : float
Volumen de desplazamiento V [m³].
displacement : float
Desplazamiento Δ [t] (agua salada ρ = 1025 kg/m³ por defecto).
awp : float
Área del plano de flotación Awp [m²].
lcb : float
Centro longitudinal de carena desde AP LCB [m].
lcf : float
Centro longitudinal de flotación desde AP LCF [m].
kb : float
Altura del centro de carena sobre la quilla KB [m].
it : float
Segundo momento transversal del plano de flotación IT [m⁴].
il : float
Segundo momento longitudinal del plano de flotación IL [m⁴].
bmt : float
Radio metacéntrico transversal BM_T = IT / V [m].
bml : float
Radio metacéntrico longitudinal BM_L = IL / V [m].
kmt : float
Altura del metacentro transversal KM_T = KB + BM_T [m].
kml : float
Altura del metacentro longitudinal KM_L = KB + BM_L [m].
tpc : float
Toneladas por centímetro de inmersión TPC [t/cm].
mct : float
Momento para cambiar asiento 1 cm MCT [t·m/cm].
cb : float
Coeficiente de bloque Cb [-].
cw : float
Coeficiente de plano de flotación Cw [-].
cm : float
Coeficiente de cuaderna maestra Cm [-].
cp : float
Coeficiente prismático Cp [-].
"""
draft: float
volume: float
displacement: float
awp: float
lcb: float
lcf: float
kb: float
it: float
il: float
bmt: float
bml: float
kmt: float
kml: float
tpc: float
mct: float
cb: float
cw: float
cm: float
cp: float
# ---------------------------------------------------------------------------
# Función de cálculo
# ---------------------------------------------------------------------------
def compute_upright(
hull,
draft: float,
rho: float = 1025.0,
kg: float | None = None,
) -> UprightHydrostatics:
"""Calcula todos los hidrostáticos upright para *hull* al calado *draft*.
El cálculo se realiza en una sola pasada sobre las secciones, reutilizando
los arrays intermedios para evitar redundancia.
Parameters
----------
hull : Hull
Casco de referencia (objeto ``arshipdesign.core.hull.Hull``).
draft : float
Calado de cálculo T [m]. Si T ≤ 0, retorna ceros.
rho : float
Densidad del agua [kg/m³]. Default 1025 (agua salada).
kg : float | None
Altura del centro de gravedad KG [m]. Si None se estima
como ``hull.depth × 0.55`` (buque en rosca, conservador).
Returns
-------
UprightHydrostatics
Todos los hidrostáticos al calado *draft*.
"""
T = float(draft)
# Caso degenerado (calado nulo o negativo)
if T <= 1e-6:
return _zero_hydrostatics(T)
# ----------------------------------------------------------------
# 1. Secciones transversales → volumen, LCB, KB
# ----------------------------------------------------------------
sections = hull.offsets.to_sections()
x_s, areas, cz = section_areas_and_centroids(sections, T)
vol = abs(integrate(areas, x_s))
delta = vol * rho / 1000.0
if vol > 1e-12:
lcb = integrate(areas * x_s, x_s) / vol
kb = integrate(areas * cz, x_s) / vol
else:
lcb = hull.lpp / 2.0
kb = T / 2.0
# ----------------------------------------------------------------
# 2. Plano de flotación → Awp, LCF, IT, IL
# ----------------------------------------------------------------
x_wl, y_wl = waterplane_strips(hull.offsets, T)
strip = 2.0 * y_wl # ancho total a cada x
awp = abs(integrate(strip, x_wl))
if awp > 1e-12:
lcf = integrate(strip * x_wl, x_wl) / awp
else:
lcf = hull.lpp / 2.0
# IT = (2/3) · ∫ y³ dx (Rawson & Tupper §3.2)
it = abs(integrate((2.0 / 3.0) * y_wl ** 3, x_wl))
# IL = ∫ 2y · (x LCF)² dx
il = abs(integrate(strip * (x_wl - lcf) ** 2, x_wl))
# ----------------------------------------------------------------
# 3. Radios metacéntricos
# ----------------------------------------------------------------
bmt = it / vol if vol > 1e-12 else 0.0
bml = il / vol if vol > 1e-12 else 0.0
kmt = kb + bmt
kml = kb + bml
# ----------------------------------------------------------------
# 4. TPC y MCT
# ----------------------------------------------------------------
tpc = awp * rho / 100_000.0
kg_val = hull.depth * 0.55 if kg is None else float(kg)
gml = max(kb + bml - kg_val, 0.0)
mct = delta * gml / (100.0 * hull.lpp) if hull.lpp > 1e-12 else 0.0
# ----------------------------------------------------------------
# 5. Coeficientes de forma
# ----------------------------------------------------------------
cb = vol / (hull.lpp * hull.beam * T) if (hull.lpp * hull.beam * T) > 1e-12 else 0.0
cw = awp / (hull.lpp * hull.beam) if (hull.lpp * hull.beam) > 1e-12 else 0.0
# Área de cuaderna maestra: interpolar en x_mid
x_mid = hull.lpp / 2.0
am = float(np.interp(x_mid, x_s, areas))
cm = am / (hull.beam * T) if (hull.beam * T) > 1e-12 else 0.0
cp = vol / (am * hull.lpp) if (am * hull.lpp) > 1e-12 else 0.0
return UprightHydrostatics(
draft=T, volume=vol, displacement=delta,
awp=awp, lcb=lcb, lcf=lcf, kb=kb,
it=it, il=il,
bmt=bmt, bml=bml, kmt=kmt, kml=kml,
tpc=tpc, mct=mct,
cb=cb, cw=cw, cm=cm, cp=cp,
)
# ---------------------------------------------------------------------------
# Auxiliar privado
# ---------------------------------------------------------------------------
def _zero_hydrostatics(draft: float) -> UprightHydrostatics:
"""Devuelve un UprightHydrostatics con todos los valores en cero."""
return UprightHydrostatics(
draft=draft, volume=0.0, displacement=0.0,
awp=0.0, lcb=0.0, lcf=0.0, kb=0.0,
it=0.0, il=0.0,
bmt=0.0, bml=0.0, kmt=0.0, kml=0.0,
tpc=0.0, mct=0.0,
cb=0.0, cw=0.0, cm=0.0, cp=0.0,
)
+40 -13
View File
@@ -15,12 +15,45 @@ Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
"""
from __future__ import annotations
import math
import numpy as np
from arshipdesign.core.hull import Hull
from arshipdesign.core.offsets import OffsetsTable
# ---------------------------------------------------------------------------
# Forma de sección — carena redonda tipo desplazamiento
# ---------------------------------------------------------------------------
def _round_bilge_section(z: float, T: float, y_wl: float, Cm: float) -> float:
"""
Sección de carena redonda parametrizada por Cm.
Usa la forma y = y_wl · sin(π/2 · (z/T)ⁿ) donde n se obtiene de Cm.
- n < 1 → sección llena (Cm alto), convexa hacia afuera
- n = 1 → arco de círculo (Cm ≈ 0.637)
- n > 1 → sección en V (Cm bajo), convexa hacia el eje
Propiedades garantizadas:
y(z=0) = 0 (quilla puntual)
y(z=T) = y_wl (manga en flotación)
dy/dz ≥ 0 (monótona, sin inflexión indeseada)
"""
if y_wl < 1e-9 or T < 1e-9:
return 0.0
t = min(1.0, max(0.0, z / T))
if t < 1e-12:
return 0.0
# Tabla calibrada Cm → n (verificada contra integración numérica)
_CM = [0.45, 0.55, 0.65, 0.75, 0.82, 0.88, 0.94]
_N = [2.20, 1.60, 1.15, 0.80, 0.58, 0.42, 0.28]
n = float(np.interp(Cm, _CM, _N))
return float(y_wl * math.sin(math.pi / 2.0 * t ** n))
def make_displacement_hull(
name: str = "Crucero de Desplazamiento",
lpp: float = 12.0,
@@ -53,26 +86,20 @@ def make_displacement_hull(
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)
# Cm varía a lo largo de la eslora: lleno en midship, más en V en extremos.
# f_plan[i]=1 → midship → Cm=cm; f_plan[i]→0 → extremos → Cm≈0.52
local_cm = float(np.clip(
cm * (0.42 + 0.58 * f_plan[i] ** 0.40),
0.52, cm
))
for j, z in enumerate(z_wl):
v = z / draft # ∈ [0, 1]
data[i, j] = y_wl * (v ** alpha)
data[i, j] = _round_bilge_section(z, draft, y_wl, local_cm)
data = np.clip(data, 0.0, None)
+5 -3
View File
@@ -89,9 +89,11 @@ def make_planing_hull(
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)
# Costado recto (hard-chine) desde y_chine hasta y_max en cubierta.
# El parámetro flare añade ensanche adicional sobre y_max.
t_side = (z - z_c) / (draft - z_c + 1e-9)
y = y_chine + (y_max * (1.0 + flare) - y_chine) * t_side
y = min(y, y_max * (1.0 + flare))
data[i, j] = max(0.0, y)
+56 -11
View File
@@ -16,12 +16,59 @@ Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
"""
from __future__ import annotations
import math
import numpy as np
from arshipdesign.core.hull import Hull
from arshipdesign.core.offsets import OffsetsTable
# ---------------------------------------------------------------------------
# Forma de sección — velero (V-fondo + cuerpo redondeado)
# ---------------------------------------------------------------------------
def _sailing_section(
z: float, T: float, y_wl: float, Cm: float, deadrise_deg: float
) -> float:
"""
Sección de velero con V-fondo y cuerpo redondeado.
Dos zonas:
- [0, z_b]: Fondo en V recto definido por el ángulo de astilla muerta.
- [z_b, T]: Cuerpo redondeado (sin^n) desde el pantoque hasta la flotación.
Propiedades:
y(z=0) = 0 (quilla)
y(z=T) = y_wl (manga en flotación)
Continuidad C⁰ en z=z_b
"""
if y_wl < 1e-9 or T < 1e-9:
return 0.0
t_full = min(1.0, max(0.0, z / T))
if t_full < 1e-12:
return 0.0
# Altura del pantoque (bilge): 40 % del calado
z_b = 0.40 * T
# Semi-manga en el pantoque desde el ángulo de astilla
# y_b_v = z_b / tan(deadrise) → capped al 65% de y_wl
dr_rad = math.radians(max(5.0, min(deadrise_deg, 80.0)))
y_b_v = z_b / math.tan(dr_rad)
y_b = min(y_b_v, 0.65 * y_wl)
if z <= z_b:
# Fondo en V: lineal desde (0,0) hasta (z_b, y_b)
return y_b * z / z_b if z_b > 1e-9 else 0.0
else:
# Cuerpo redondeado desde (z_b, y_b) hasta (T, y_wl)
t = (z - z_b) / (T - z_b)
_CM = [0.42, 0.55, 0.65, 0.75, 0.83]
_N = [2.50, 1.70, 1.20, 0.82, 0.55]
n = float(np.interp(Cm, _CM, _N))
return float(y_b + (y_wl - y_b) * math.sin(math.pi / 2.0 * t ** n))
def make_sailing_hull(
name: str = "Velero Monocasco",
lpp: float = 10.0,
@@ -59,24 +106,22 @@ def make_sailing_hull(
# 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)
# Cm y deadrise varían a lo largo de la eslora.
# Midship: Cm=cm, deadrise=deadrise_mid
# Extremos: más en V (menor Cm, mayor deadrise)
local_cm = float(np.clip(
cm * (0.38 + 0.62 * f_plan[i] ** 0.45),
0.42, cm
))
local_dr = deadrise_mid + (60.0 - deadrise_mid) * max(0.0, 1.0 - f_plan[i] ** 0.5)
for j, z in enumerate(z_wl):
v = z / draft
y = y_wl * (v ** alpha)
data[i, j] = max(0.0, y)
data[i, j] = max(0.0, _sailing_section(z, draft, y_wl, local_cm, local_dr))
offsets = OffsetsTable(
x_stations=x_sta,
+1 -1
View File
@@ -69,7 +69,7 @@
"type_workboat": "Workboat",
"about_title": "About AR-ShipDesign",
"about_version": "Version",
"about_copyright": "Copyright © 2025 Álvaro Rodríguez. All rights reserved.",
"about_copyright": "Copyright © 2025 Álvaro Romero. All rights reserved.",
"tooltip_kmt": "KMT = KB + IT/∇ (transverse metacentric height)",
"tooltip_gmt": "GMT = KMT KG (corrected metacentric height)",
"tooltip_tpc": "TPC = Aw · ρ / 100 (tonnes per cm immersion)",
+1 -1
View File
@@ -69,7 +69,7 @@
"type_workboat": "Workboat / Embarcación de trabajo",
"about_title": "Acerca de AR-ShipDesign",
"about_version": "Versión",
"about_copyright": "Copyright © 2025 Álvaro Rodríguez",
"about_copyright": "Copyright © 2025 Álvaro Romero",
"tooltip_kmt": "KMT = KB + IT/∇ (altura metacéntrica transversal)",
"tooltip_gmt": "GMT = KMT KG (altura metacéntrica corregida)",
"tooltip_tpc": "TPC = Aw · ρ / 100 (toneladas por cm de inmersión)",
+34 -2
View File
@@ -860,6 +860,10 @@ class MainWindow(QMainWindow):
if _vp is not None:
_vp.set_canvas(_widget)
# Conectar edición interactiva de control points → propagar a todos los visores
self._viewer_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
self._viewer_plan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
# Editor interactivo de offsets (sustituye el placeholder MOD_OFFSETS)
from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor
self._offsets_editor = OffsetsEditor()
@@ -1316,11 +1320,39 @@ class MainWindow(QMainWindow):
"""Slot: el editor de offsets reconstruyo el Hull — propagar a visores y proyecto."""
self._current_hull = hull
if self._project is not None:
self._project.set_hull(hull) # mantener proyecto sincronizado
# _skip_offsets_editor=True para no re-poblar la tabla (ya esta actualizada)
self._project.set_hull(hull)
self._load_hull_viewers(hull, _skip_offsets_editor=True)
self.statusBar().showMessage(f"Offsets actualizados — {hull.name}")
def _on_offsets_edited_from_viewer(self, offsets_table) -> None:
"""Slot: un visor 2D editó un punto de control — sincronizar todos los visores.
La OffsetsTable ya fue modificada in-place por el visor (durante el drag).
Aquí propagamos el cambio al visor 3D, al panel de hidrostáticos y al
editor de offsets, e informamos al proyecto del estado nuevo.
"""
hull = self._current_hull
if hull is None:
return
# hull.offsets ya contiene los cambios (modificación in-place del visor)
if self._project is not None:
self._project.set_hull(hull)
# Refrescar la vista cruzada (edición body plan actualiza planta y viceversa)
self._viewer_bodyplan.set_hull(hull)
self._viewer_profile.set_hull(hull)
self._viewer_plan.set_hull(hull)
# Sincronizar editor de tabla de offsets
self._offsets_editor.set_hull(hull)
# Actualizar visor 3D con la geometría nueva
if self._viewer_3d is not None:
try:
self._viewer_3d.load_hull(hull)
except Exception as exc:
logger.warning("Error al actualizar visor 3D: %s", exc)
# Actualizar barra de hidrostáticos
self._update_hydrostatics(hull)
self.statusBar().showMessage(f"Geometría editada — {hull.name}")
def _update_hydrostatics(self, hull) -> None:
"""Calcula hidrostáticos al calado de diseño y actualiza la barra inferior.
+12 -3
View File
@@ -54,6 +54,7 @@ class Viewer3DWidget(QWidget):
super().__init__(parent)
self._plotter: Optional["QtInteractor"] = None
self._ready = False
self._pending_hull = None # hull recibido antes de que el plotter esté listo
self._build_ui()
# ------------------------------------------------------------------
@@ -105,8 +106,13 @@ class Viewer3DWidget(QWidget):
# Configurar tema dark para que combine con la UI
self._plotter.set_background("#1a1d30") # viewportCanvas color
# Cargar casco Wigley como geometría de bienvenida
self._load_default_wigley()
# Cargar casco pendiente (recibido antes del init) o Wigley por defecto
if self._pending_hull is not None:
mesh = self._pending_hull.to_mesh()
self._render_hull_mesh(mesh)
self._pending_hull = None
else:
self._load_default_wigley()
self._ready = True
logger.info("Viewer3DWidget: QtInteractor iniciado correctamente")
@@ -136,12 +142,15 @@ class Viewer3DWidget(QWidget):
def load_hull(self, hull) -> None:
"""Carga un objeto Hull en el visor.
Si el plotter aún no ha terminado de inicializarse (race condition de 500 ms),
guarda el hull como pendiente se cargará al final de _init_plotter().
Parámetros
----------
hull : arshipdesign.core.hull.Hull
"""
if not self._ready or self._plotter is None:
logger.warning("Viewer3DWidget no listo — hull no cargado")
self._pending_hull = hull # se cargará cuando _init_plotter termine
return
try:
mesh = hull.to_mesh()
+305 -153
View File
@@ -1,13 +1,17 @@
"""
Visores 2D del plano de líneas del casco.
Visores 2D del plano de líneas del casco con edición interactiva.
Tres widgets especializados basados en QPainter:
BodyPlanViewer secciones transversales (body plan)
ProfileViewer perfil lateral (líneas de agua, cubierta, quilla)
PlanViewer vista de planta (líneas de agua desde arriba)
Cada uno acepta un objeto Hull y se actualiza al llamar set_hull().
Soportan zoom con rueda del ratón y paneo con botón central/derecho.
Cada visor muestra la malla de puntos de control de la OffsetsTable.
El usuario puede arrastrar cualquier punto para modificar la geometría;
al soltar se emite la señal ``offsets_edited(OffsetsTable)``.
Soportan zoom con rueda del ratón y paneo con botón medio/derecho.
Doble clic restablece el encuadre automático.
Referencia:
Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 Lines Plan.
@@ -21,9 +25,9 @@ import math
from typing import Optional
import numpy as np
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
from PySide6.QtGui import (
QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
QBrush, QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
)
from PySide6.QtWidgets import QWidget
@@ -33,45 +37,62 @@ from arshipdesign.core.hull import Hull
# ─────────────────────────────────────────────────────────────────────────────
# Paleta del tema
# ─────────────────────────────────────────────────────────────────────────────
_BG = QColor("#1a1d30")
_GRID = QColor("#2a3060")
_WATERLINE = QColor("#4da8ff") # azul cyan
_SECTION = QColor("#48a858") # verde
_PROFILE = QColor("#e8a020") # dorado
_DECK = QColor("#8868c8") # púrpura
_KEEL = QColor("#e06060") # rojo suave
_TEXT = QColor("#7a8ba8")
_AXIS = QColor("#3e4255")
_WL_DESIGN = QColor("#4da8ff") # flotación de diseño (más gruesa)
_BG = QColor("#1a1d30")
_GRID = QColor("#2a3060") # Estaciones (muy tenue)
_WATERLINE = QColor("#4da8ff") # Líneas de agua
_WL_DESIGN = QColor("#00d4ff") # Flotación de diseño (más gruesa)
_SECTION = QColor("#48a858") # Secciones de proa (verde)
_SECTION_AFT= QColor("#4da8ff") # Secciones de popa (azul)
_MIDSHIP = QColor("#e8a020") # Cuaderna maestra (dorado)
_DECK = QColor("#8868c8") # Línea de cubierta (púrpura)
_KEEL = QColor("#e06060") # Quilla (rojo suave)
_TEXT = QColor("#7a8ba8")
_AXIS = QColor("#3e4255")
# Puntos de control (malla editable)
_CPT_NORMAL = QColor("#c8d8f0") # blanco-azulado
_CPT_HOVER = QColor("#ffd700") # oro
_CPT_DRAG = QColor("#ff5555") # rojo activo
_CPT_RADIUS = 4.0 # px en reposo
_CPT_HIT = 14.0 # px umbral de captura
# ─────────────────────────────────────────────────────────────────────────────
# Base común
# Clase base
# ─────────────────────────────────────────────────────────────────────────────
class _BaseViewer(QWidget):
"""Widget base con zoom/paneo común."""
"""Widget base con zoom/paneo y edición de puntos de control."""
# Emitido cuando el usuario arrastra un punto y suelta el botón
offsets_edited = Signal(object) # OffsetsTable modificada
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._hull: Optional[Hull] = None
self._scale = 1.0
self._offset = QPointF(0.0, 0.0)
self._drag_start: Optional[QPointF] = None
self.setMouseTracking(True)
self._pan_start: Optional[QPointF] = None # para paneo (botón medio/derecho)
# Estado de edición de puntos de control
self._hover_idx: Optional[tuple[int, int]] = None # (station, waterline)
self._drag_idx: Optional[tuple[int, int]] = None
self._drag_orig: float = 0.0 # valor antes del drag (para deshacer si se escapa)
self.setMouseTracking(True)
self.setCursor(Qt.CursorShape.ArrowCursor)
# ─── API pública ──────────────────────────────────────────────────────────
# ------------------------------------------------------------------
def set_hull(self, hull: Optional[Hull]) -> None:
self._hull = hull
self._hover_idx = None
self._drag_idx = None
self._fit_to_view()
self.update()
# ------------------------------------------------------------------
# Transformación mundo → pantalla
# ------------------------------------------------------------------
# ─── Transform mundo ↔ pantalla ──────────────────────────────────────────
def _w2s(self, wx: float, wy: float) -> QPointF:
"""Coordenada mundo → coordenada de pantalla."""
return QPointF(
wx * self._scale + self._offset.x(),
wy * self._scale + self._offset.y(),
@@ -84,7 +105,6 @@ class _BaseViewer(QWidget):
)
def _fit_to_view(self) -> None:
"""Ajusta zoom y offset para encuadrar el casco."""
if self._hull is None:
return
bbox = self._world_bbox()
@@ -96,30 +116,29 @@ class _BaseViewer(QWidget):
return
pw, ph = max(self.width(), 100), max(self.height(), 100)
margin = 0.08
scale_x = pw * (1 - margin * 2) / ww
scale_y = ph * (1 - margin * 2) / wh
self._scale = min(scale_x, scale_y)
# Centrar
self._scale = min(
pw * (1 - margin * 2) / ww,
ph * (1 - margin * 2) / wh,
)
cx = pw / 2 - (wx0 + ww / 2) * self._scale
cy = ph / 2 - (wy0 + wh / 2) * self._scale
self._offset = QPointF(cx, cy)
def _world_bbox(self) -> Optional[tuple[float, float, float, float]]:
return None # subclases lo sobreescriben
return None # subclases
# ------------------------------------------------------------------
# Eventos
# ------------------------------------------------------------------
# ─── Eventos ─────────────────────────────────────────────────────────────
def resizeEvent(self, event) -> None: # type: ignore[override]
def resizeEvent(self, event) -> None:
self._fit_to_view()
super().resizeEvent(event)
def wheelEvent(self, event: QWheelEvent) -> None:
if self._drag_idx is not None:
return
delta = event.angleDelta().y()
factor = 1.15 if delta > 0 else 1.0 / 1.15
pos = event.position()
# Zoom centrado en el cursor
self._offset = QPointF(
pos.x() + (self._offset.x() - pos.x()) * factor,
pos.y() + (self._offset.y() - pos.y()) * factor,
@@ -127,60 +146,111 @@ class _BaseViewer(QWidget):
self._scale *= factor
self.update()
def mousePressEvent(self, event) -> None: # type: ignore[override]
if event.button() in (Qt.MouseButton.MiddleButton,
Qt.MouseButton.RightButton):
self._drag_start = event.position()
def mousePressEvent(self, event) -> None:
btn = event.button()
if btn == Qt.MouseButton.LeftButton and self._hull is not None:
idx = self._hit_test(event.position())
if idx is not None:
self._drag_idx = idx
self._drag_orig = float(self._hull.offsets.data[idx[0], idx[1]])
self.setCursor(Qt.CursorShape.SizeAllCursor)
event.accept()
return
if btn in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton):
self._pan_start = event.position()
def mouseMoveEvent(self, event) -> None: # type: ignore[override]
if self._drag_start is not None:
d = event.position() - self._drag_start
def mouseMoveEvent(self, event) -> None:
# ── Paneo ─────────────────────────────────────────────────────────
if self._pan_start is not None:
d = event.position() - self._pan_start
self._offset += d
self._drag_start = event.position()
self._pan_start = event.position()
self.update()
return
# ── Arrastre de punto de control ──────────────────────────────────
if self._drag_idx is not None and self._hull is not None:
self._apply_drag(event.position(), self._drag_idx)
self.update()
return
# ── Hover ─────────────────────────────────────────────────────────
old = self._hover_idx
if self._hull is not None:
self._hover_idx = self._hit_test(event.position())
else:
self._hover_idx = None
cursor = (Qt.CursorShape.SizeAllCursor
if self._hover_idx is not None
else Qt.CursorShape.ArrowCursor)
self.setCursor(cursor)
if self._hover_idx != old:
self.update()
def mouseReleaseEvent(self, event) -> None: # type: ignore[override]
self._drag_start = None
def mouseReleaseEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton and self._drag_idx is not None:
self._drag_idx = None
self.setCursor(Qt.CursorShape.ArrowCursor)
if self._hull is not None:
self.offsets_edited.emit(self._hull.offsets)
event.accept()
return
if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton):
self._pan_start = None
def mouseDoubleClickEvent(self, event) -> None: # type: ignore[override]
def mouseDoubleClickEvent(self, event) -> None:
self._fit_to_view()
self.update()
# ------------------------------------------------------------------
# Helpers de dibujo
# ------------------------------------------------------------------
# ─── Métodos de edición (implementados por subclases) ────────────────────
def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]:
"""Busca el punto de control más cercano dentro del umbral de captura."""
return None # subclases
def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None:
"""Actualiza la OffsetsTable con la nueva posición del ratón."""
pass # subclases
# ─── Helpers de dibujo ───────────────────────────────────────────────────
def _draw_background(self, p: QPainter) -> None:
p.fillRect(self.rect(), _BG)
def _draw_axes(self, p: QPainter,
x0w: float, x1w: float, y0w: float, y1w: float,
x_label: str = "x [m]", y_label: str = "y [m]") -> None:
"""Ejes y grilla con etiquetas."""
p.setPen(QPen(_AXIS, 1, Qt.PenStyle.SolidLine))
# Eje X
p0 = self._w2s(x0w, 0.0)
p1 = self._w2s(x1w, 0.0)
p.drawLine(p0, p1)
# Eje Y
p0 = self._w2s(0.0, y0w)
p1 = self._w2s(0.0, y1w)
p.drawLine(p0, p1)
def _draw_label(self, p: QPainter, text: str) -> None:
p.setPen(QPen(_TEXT))
fnt = QFont("Monospace", 8)
p.setFont(fnt)
p.drawText(self.rect().adjusted(4, 4, -4, -4), Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft, text)
p.setFont(QFont("Monospace", 8))
p.drawText(
self.rect().adjusted(4, 4, -4, -4),
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
text,
)
def _draw_no_hull(self, p: QPainter, msg: str) -> None:
p.setPen(QPen(_TEXT))
fnt = QFont("Monospace", 10)
p.setFont(fnt)
p.setFont(QFont("Monospace", 10))
p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg)
def _draw_control_point(
self,
p: QPainter,
screen_pt: QPointF,
idx: tuple[int, int],
) -> None:
"""Dibuja un punto de control con color según estado."""
if idx == self._drag_idx:
color = _CPT_DRAG
r = _CPT_RADIUS * 1.8
elif idx == self._hover_idx:
color = _CPT_HOVER
r = _CPT_RADIUS * 1.5
else:
color = _CPT_NORMAL
r = _CPT_RADIUS
p.setPen(QPen(color.darker(130), 1))
p.setBrush(QBrush(color))
p.drawEllipse(screen_pt, r, r)
# ─────────────────────────────────────────────────────────────────────────────
# 1. Body Plan — secciones transversales
@@ -190,19 +260,56 @@ class BodyPlanViewer(_BaseViewer):
"""Vista de cuadernas (body plan).
Espacio de mundo: x = semi-manga [m] (derecha +), y = z altura [m] (arriba +).
Muestra mitad de babor izquierda (y<0) y estribor derecha (y>0).
La quilla maestra se resalta.
Mitad de proa estribor (derecha, verde).
Mitad de popa babor (izquierda, azul).
Edición: arrastra cualquier punto de control (y[i][j], z[j]) en x para
cambiar la semi-manga en esa estación y línea de agua.
"""
def _world_bbox(self) -> Optional[tuple]:
if self._hull is None:
return None
ot = self._hull.offsets
y_max = ot.max_half_breadth * 1.1
z_max = ot.draft * 1.15
y_max = ot.max_half_breadth * 1.15
z_max = ot.draft * 1.20
return (-y_max, -z_max * 0.05, y_max, z_max)
def paintEvent(self, event) -> None: # type: ignore[override]
# ── Edición ───────────────────────────────────────────────────────────────
def _screen_pt(self, i: int, j: int) -> QPointF:
"""Punto de control (i, j) en coordenadas de pantalla."""
ot = self._hull.offsets
y = ot.data[i, j]
z = ot.z_waterlines[j]
sign = 1.0 if i >= ot.n_stations // 2 else -1.0
return self._w2s(sign * y, z)
def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]:
if self._hull is None:
return None
ot = self._hull.offsets
best_d, best_idx = _CPT_HIT, None
for i in range(ot.n_stations):
for j in range(ot.n_waterlines):
d = _dist(pos, self._screen_pt(i, j))
if d < best_d:
best_d, best_idx = d, (i, j)
return best_idx
def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None:
ot = self._hull.offsets
i, j = idx
sign = 1.0 if i >= ot.n_stations // 2 else -1.0
wx, _ = self._s2w(pos.x(), pos.y())
new_y = max(0.0, sign * wx)
# Limitar al doble de la manga para evitar explosiones
new_y = min(new_y, self._hull.beam)
ot.data[i, j] = new_y
# ── Dibujo ────────────────────────────────────────────────────────────────
def paintEvent(self, event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
self._draw_background(p)
@@ -216,71 +323,74 @@ class BodyPlanViewer(_BaseViewer):
T = self._hull.draft
n = ot.n_stations
# ── Grilla de líneas de agua ───────────────────────────────
wl_pen = QPen(_GRID, 0.5, Qt.PenStyle.DotLine)
p.setPen(wl_pen)
for z in ot.z_waterlines:
# Línea horizontal en z
x_max = ot.max_half_breadth * 1.1
left = self._w2s(-x_max, z)
right = self._w2s( x_max, z)
p.drawLine(left, right)
# ── Líneas de agua — grilla horizontal ────────────────────────
x_max = ot.max_half_breadth * 1.15
for j, z in enumerate(ot.z_waterlines):
is_design = abs(z - T) < 1e-6
if is_design:
p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine))
else:
p.setPen(QPen(_WATERLINE.darker(160), 0.6, Qt.PenStyle.DotLine))
p.drawLine(self._w2s(-x_max, z), self._w2s(x_max, z))
# Línea de flotación de diseño (más gruesa)
p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine))
x_max = ot.max_half_breadth * 1.1
# Línea de flotación de diseño (más visible)
p.setPen(QPen(_WL_DESIGN, 1.5, Qt.PenStyle.DashLine))
p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T))
# ── Dibujar secciones ──────────────────────────────────────
# ── Secciones ─────────────────────────────────────────────────
for i in range(n):
# Progreso de AP a FP: proa a estribor, popa a babor
is_forward = i >= n // 2
is_fwd = i >= n // 2
is_mid = i == n // 2
if is_forward:
pen = QPen(_SECTION, 1.2) # verde: mitad de proa (estribor)
if is_mid:
pen = QPen(_MIDSHIP, 2.5)
elif is_fwd:
pen = QPen(_SECTION, 1.4)
else:
pen = QPen(_WATERLINE, 1.2) # azul: mitad de popa (babor)
# Cuaderna maestra más gruesa
if i == n // 2:
pen.setWidthF(2.5)
pen.setColor(_PROFILE)
pen = QPen(_SECTION_AFT, 1.4)
p.setPen(pen)
y_arr = ot.data[i, :]
z_arr = ot.z_waterlines
sign = 1.0 if is_forward else -1.0 # estribor o babor
sign = 1.0 if is_fwd else -1.0
path = QPainterPath()
started = False
for y, z in zip(y_arr, z_arr):
for k, (y, z) in enumerate(zip(y_arr, z_arr)):
pt = self._w2s(sign * y, z)
if not started:
if k == 0:
path.moveTo(pt)
started = True
else:
path.lineTo(pt)
# Cerrar en quilla
path.lineTo(self._w2s(0.0, 0.0))
p.drawPath(path)
# ── Ejes ──────────────────────────────────────────────────
# ── Ejes ──────────────────────────────────────────────────────
p.setPen(QPen(_AXIS, 1))
x_max = ot.max_half_breadth * 1.1
p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.1)) # eje simétrico
p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.15)) # eje crujía
# ── Puntos de control ─────────────────────────────────────────
p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
for i in range(n):
for j in range(ot.n_waterlines):
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
self._draw_label(p, "BODY PLAN")
p.end()
# ─────────────────────────────────────────────────────────────────────────────
# 2. Profile Viewer — vista lateral
# 2. Profile Viewer — vista lateral (solo lectura)
# ─────────────────────────────────────────────────────────────────────────────
class ProfileViewer(_BaseViewer):
"""Vista lateral del casco (perfil).
Mundo: x = posición longitudinal [m] (AP izquierda), y = z altura [m].
Muestra: líneas de agua proyectadas, perfil de cubierta, quilla.
Muestra líneas de agua, perfil de cubierta y quilla.
No es editable (las z son constantes en la OffsetsTable).
"""
def _world_bbox(self) -> Optional[tuple]:
@@ -290,10 +400,10 @@ class ProfileViewer(_BaseViewer):
-self._hull.lpp * 0.05,
-self._hull.draft * 0.15,
self._hull.lpp * 1.05,
self._hull.draft * 1.25,
self._hull.draft * 1.30,
)
def paintEvent(self, event) -> None: # type: ignore[override]
def paintEvent(self, event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
self._draw_background(p)
@@ -307,23 +417,24 @@ class ProfileViewer(_BaseViewer):
T = self._hull.draft
Lpp = self._hull.lpp
# ── Grilla de estaciones ───────────────────────────────────
# ── Grilla de estaciones ───────────────────────────────────────
p.setPen(QPen(_GRID, 0.5, Qt.PenStyle.DotLine))
for x in ot.x_stations:
p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.15))
p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.2))
# ── Líneas de agua en perfil (ancho máximo a cada z) ────────
# ── Líneas de agua en perfil ───────────────────────────────────
for j, z in enumerate(ot.z_waterlines):
color = _WL_DESIGN if abs(z - T) < 1e-6 else _WATERLINE
width = 1.5 if abs(z - T) < 1e-6 else 0.8
p.setPen(QPen(color, width))
# En perfil, la línea de agua aparece como línea recta horizontal
# con el "ancho" dado por las semi-mangas (no visible en perfil lateral)
# Lo que sí se muestra: intersección de líneas de agua con la proa y la popa
# Dibujamos la línea completa
is_design = abs(z - T) < 1e-6
if is_design:
p.setPen(QPen(_WL_DESIGN, 1.8))
else:
frac = j / max(ot.n_waterlines - 1, 1)
color = QColor(_WATERLINE)
color.setAlphaF(0.40 + 0.50 * frac)
p.setPen(QPen(color, 0.9))
p.drawLine(self._w2s(0, z), self._w2s(Lpp, z))
# ── Cubierta (z = puntal) ──────────────────────────────────
# ── Cubierta ──────────────────────────────────────────────────
p.setPen(QPen(_DECK, 1.8))
path_deck = QPainterPath()
for k, x in enumerate(ot.x_stations):
@@ -334,24 +445,23 @@ class ProfileViewer(_BaseViewer):
path_deck.lineTo(pt)
p.drawPath(path_deck)
# ── Quilla ─────────────────────────────────────────────────
# ── Quilla ────────────────────────────────────────────────────
p.setPen(QPen(_KEEL, 2.0))
p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 0))
# ── Perpendiculares AP y FP ────────────────────────────────
# ── Perpendiculares AP / FP ────────────────────────────────────
p.setPen(QPen(_AXIS, 1.5))
p.drawLine(self._w2s(0, -T * 0.05), self._w2s(0, self._hull.depth * 1.05))
p.drawLine(self._w2s(Lpp, -T * 0.05), self._w2s(Lpp, self._hull.depth * 1.05))
# Etiquetas AP / FP
p.setPen(QPen(_TEXT))
p.setFont(QFont("Monospace", 8))
ap_pt = self._w2s(0, -T * 0.12)
fp_pt = self._w2s(Lpp, -T * 0.12)
p.drawText(QRectF(ap_pt.x() - 14, ap_pt.y() - 8, 28, 14),
Qt.AlignmentFlag.AlignCenter, "AP")
p.drawText(QRectF(fp_pt.x() - 14, fp_pt.y() - 8, 28, 14),
Qt.AlignmentFlag.AlignCenter, "FP")
_lbl = lambda text, x, z: p.drawText(
QRectF(self._w2s(x, z).x() - 14, self._w2s(x, z).y() - 8, 28, 14),
Qt.AlignmentFlag.AlignCenter, text
)
_lbl("AP", 0, -T * 0.12)
_lbl("FP", Lpp, -T * 0.12)
self._draw_label(p, "PERFIL LATERAL")
p.end()
@@ -365,7 +475,9 @@ class PlanViewer(_BaseViewer):
"""Vista de planta (semiplano superior).
Mundo: x = posición longitudinal [m], y = semi-manga [m] (arriba = estribor).
Muestra: líneas de agua superpuestas como contornos.
Edición: arrastra un punto de contorno (x[i], y[i][j]) en y para cambiar
la semi-manga de esa estación en esa línea de agua.
"""
def _world_bbox(self) -> Optional[tuple]:
@@ -376,10 +488,37 @@ class PlanViewer(_BaseViewer):
-self._hull.lpp * 0.05,
-y_max * 0.15,
self._hull.lpp * 1.05,
y_max * 1.20,
y_max * 1.25,
)
def paintEvent(self, event) -> None: # type: ignore[override]
# ── Edición ───────────────────────────────────────────────────────────────
def _screen_pt(self, i: int, j: int) -> QPointF:
ot = self._hull.offsets
return self._w2s(ot.x_stations[i], ot.data[i, j])
def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]:
if self._hull is None:
return None
ot = self._hull.offsets
best_d, best_idx = _CPT_HIT, None
for i in range(ot.n_stations):
for j in range(ot.n_waterlines):
d = _dist(pos, self._screen_pt(i, j))
if d < best_d:
best_d, best_idx = d, (i, j)
return best_idx
def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None:
ot = self._hull.offsets
i, j = idx
_, wy = self._s2w(pos.x(), pos.y())
new_y = max(0.0, min(wy, self._hull.beam))
ot.data[i, j] = new_y
# ── Dibujo ────────────────────────────────────────────────────────────────
def paintEvent(self, event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
self._draw_background(p)
@@ -391,44 +530,57 @@ class PlanViewer(_BaseViewer):
ot = self._hull.offsets
T = self._hull.draft
# ── Líneas de agua como contornos ──────────────────────────
n_wl = ot.n_waterlines
# ── Líneas de agua como contornos ─────────────────────────────
for j in range(n_wl):
z = ot.z_waterlines[j]
is_design = abs(z - T) < 1e-6
color = _WL_DESIGN if is_design else _WATERLINE
alpha = int(60 + 195 * j / max(n_wl - 1, 1))
c = QColor(color)
c.setAlpha(alpha)
width = 2.0 if is_design else 0.9
frac = j / max(n_wl - 1, 1)
p.setPen(QPen(c, width))
if is_design:
color = QColor(_WL_DESIGN)
color.setAlphaF(1.0)
width = 2.0
else:
color = QColor(_WATERLINE)
color.setAlphaF(0.30 + 0.55 * frac)
width = 0.9
p.setPen(QPen(color, width))
path = QPainterPath()
x_arr = ot.x_stations
y_arr = ot.data[:, j]
started = False
for x, y in zip(x_arr, y_arr):
for k, (x, y) in enumerate(zip(x_arr, y_arr)):
pt = self._w2s(x, y)
if not started:
if k == 0:
path.moveTo(pt)
started = True
else:
path.lineTo(pt)
p.drawPath(path)
# ── Eje de crujía ──────────────────────────────────────────
# ── Eje de crujía ─────────────────────────────────────────────
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
p.drawLine(
self._w2s(0, 0),
self._w2s(self._hull.lpp, 0),
)
p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0))
# ── Estaciones (líneas verticales tenues) ──────────────────
# ── Estaciones ────────────────────────────────────────────────
p.setPen(QPen(_GRID, 0.4, Qt.PenStyle.DotLine))
y_max = ot.max_half_breadth
for x in ot.x_stations:
p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.1))
p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.15))
# ── Puntos de control ─────────────────────────────────────────
for i in range(ot.n_stations):
for j in range(n_wl):
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
self._draw_label(p, "VISTA DE PLANTA")
p.end()
# ─────────────────────────────────────────────────────────────────────────────
# Utilidad interna
# ─────────────────────────────────────────────────────────────────────────────
def _dist(a: QPointF, b: QPointF) -> float:
return math.hypot(a.x() - b.x(), a.y() - b.y())
+1 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from PySide6.QtCore import QSettings
APP_NAME = "ARShipDesign"
ORG_NAME = "AlvaroRodriguez"
ORG_NAME = "AlvaroRomero"
# Claves de configuración
KEY_LANGUAGE = "ui/language"