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:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user