feat: AR-Shipdesign initial commit
This commit is contained in:
@@ -9,7 +9,32 @@
|
|||||||
"Bash(sort -t/ -k1)",
|
"Bash(sort -t/ -k1)",
|
||||||
"Bash(xargs -I{} wc -l {})",
|
"Bash(xargs -I{} wc -l {})",
|
||||||
"Bash(mkdir -p \"D:/Proyectos Software/AR-Shipdesign/.claude\")",
|
"Bash(mkdir -p \"D:/Proyectos Software/AR-Shipdesign/.claude\")",
|
||||||
"Bash(python create_stubs.py)"
|
"Bash(python create_stubs.py)",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:www.delftship.net)",
|
||||||
|
"WebFetch(domain:charlestonmarineconsulting.com)",
|
||||||
|
"WebFetch(domain:www.bentley.com)",
|
||||||
|
"WebFetch(domain:maxsurf.net)",
|
||||||
|
"WebFetch(domain:www.boatdesign.net)",
|
||||||
|
"WebFetch(domain:bentley.aufieroinformatica.com)",
|
||||||
|
"WebFetch(domain:www.kastenmarine.com)",
|
||||||
|
"WebFetch(domain:cyberships.wordpress.com)",
|
||||||
|
"WebFetch(domain:mycourses.aalto.fi)",
|
||||||
|
"WebFetch(domain:studylib.net)",
|
||||||
|
"WebFetch(domain:sourceforge.net)",
|
||||||
|
"WebFetch(domain:forum.delftship.net)",
|
||||||
|
"WebFetch(domain:manualzz.com)",
|
||||||
|
"WebFetch(domain:www.scribd.com)",
|
||||||
|
"WebFetch(domain:zdocs.tips)",
|
||||||
|
"WebFetch(domain:www.coursehero.com)",
|
||||||
|
"WebFetch(domain:nanopdf.com)",
|
||||||
|
"WebFetch(domain:pdfcoffee.com)",
|
||||||
|
"WebFetch(domain:www.kashti.ir)",
|
||||||
|
"Skill(update-config)",
|
||||||
|
"Skill(update-config:*)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"Bash(mkdir -p ~/.claude/session-data && date)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
AR-ShipDesign — Software de Diseño Naval
|
AR-ShipDesign — Software de Diseño Naval
|
||||||
Copyright (c) 2025 Álvaro Rodríguez. Todos los derechos reservados.
|
Copyright (c) 2025 Álvaro Romero. Todos los derechos reservados.
|
||||||
|
|
||||||
LICENCIA PROPIETARIA
|
LICENCIA PROPIETARIA
|
||||||
|
|
||||||
Este software y su código fuente son propiedad exclusiva de Álvaro Rodríguez.
|
Este software y su código fuente son propiedad exclusiva de Álvaro Romero.
|
||||||
Queda prohibida su copia, distribución, modificación, ingeniería inversa o
|
Queda prohibida su copia, distribución, modificación, ingeniería inversa o
|
||||||
uso comercial sin autorización expresa y por escrito del titular.
|
uso comercial sin autorización expresa y por escrito del titular.
|
||||||
|
|
||||||
|
|||||||
@@ -45,5 +45,5 @@ python main.py
|
|||||||
|
|
||||||
## Licencia
|
## Licencia
|
||||||
|
|
||||||
Copyright © 2025 Álvaro Rodríguez. Todos los derechos reservados.
|
Copyright © 2025 Álvaro Romero. Todos los derechos reservados.
|
||||||
Ver `LICENSE.txt` para detalles.
|
Ver `LICENSE.txt` para detalles.
|
||||||
|
|||||||
+308
-24
@@ -48,6 +48,28 @@ class Hull:
|
|||||||
draft: float
|
draft: float
|
||||||
offsets: OffsetsTable
|
offsets: OffsetsTable
|
||||||
_surface: Optional[LoftedSurface] = field(default=None, repr=False, compare=False)
|
_surface: Optional[LoftedSurface] = field(default=None, repr=False, compare=False)
|
||||||
|
# Altura del sheer (cubierta) por estación [m].
|
||||||
|
# Permite líneas de cubierta con arrufo/caída sin alterar el puntal escalar.
|
||||||
|
# Default vacío → se usa hull.depth uniforme en todas las estaciones.
|
||||||
|
sheer_z: np.ndarray = field(default_factory=lambda: np.array([]), repr=False, compare=False)
|
||||||
|
# Puntos de control de la roda (stem) en el plano X-Z — shape (n, 2).
|
||||||
|
# Default vacío → 3 puntos colineales de quilla-FP a cubierta-FP.
|
||||||
|
stem_ctrl: np.ndarray = field(default_factory=lambda: np.zeros((0, 2)), repr=False, compare=False)
|
||||||
|
# Puntos de control del contorno del espejo (AP) en el plano X-Z — shape (n, 2).
|
||||||
|
# Default vacío → 3 puntos colineales de quilla-AP a cubierta-AP.
|
||||||
|
transom_ctrl: np.ndarray = field(default_factory=lambda: np.zeros((0, 2)), repr=False, compare=False)
|
||||||
|
# Desviaciones X per-estación para los nodos de quilla y sheer [m].
|
||||||
|
# X efectiva quilla(i) = offsets.x_stations[i] + keel_x_offsets[i].
|
||||||
|
keel_x_offsets: np.ndarray = field(default_factory=lambda: np.array([]), repr=False, compare=False)
|
||||||
|
sheer_x_offsets: np.ndarray = field(default_factory=lambda: np.array([]), repr=False, compare=False)
|
||||||
|
# Posiciones X de los planos de estación para la visualización [m].
|
||||||
|
# Independiente de la malla paramétrica (x_stations).
|
||||||
|
# Default vacío → se generan n=11 planos uniformes entre 0 y Lpp.
|
||||||
|
station_planes: np.ndarray = field(default_factory=lambda: np.array([]), repr=False, compare=False)
|
||||||
|
# Nodos marcados como esquina: rompen la suavidad B-spline en ese punto.
|
||||||
|
# Cada elemento es [i, j] donde j puede ser _KEEL_IDX(-1), _SHEER_IDX(-2)
|
||||||
|
# o un índice de línea de agua (0 .. n_wl-1).
|
||||||
|
corner_nodes: list = field(default_factory=list, repr=False, compare=False)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Fábricas
|
# Fábricas
|
||||||
@@ -98,6 +120,149 @@ class Hull:
|
|||||||
self._surface = self._build_surface()
|
self._surface = self._build_surface()
|
||||||
return self._surface
|
return self._surface
|
||||||
|
|
||||||
|
def get_stem_ctrl(self) -> np.ndarray:
|
||||||
|
"""Puntos de control de la roda en X-Z.
|
||||||
|
|
||||||
|
El primer punto siempre coincide con la quilla en FP y el último
|
||||||
|
con el sheer en FP, garantizando continuidad del contorno del perfil.
|
||||||
|
Solo los puntos intermedios son libremente editables.
|
||||||
|
"""
|
||||||
|
x_fp = float(self.offsets.x_stations[-1])
|
||||||
|
z0 = float(self.offsets.keel_z[-1]) # quilla en FP
|
||||||
|
z1 = float(self.get_sheer_z()[-1]) # sheer en FP
|
||||||
|
if self.stem_ctrl.shape[0] >= 3:
|
||||||
|
ctrl = self.stem_ctrl.copy()
|
||||||
|
ctrl[0] = [x_fp, z0] # snap inferior → quilla-FP
|
||||||
|
ctrl[-1] = [x_fp, z1] # snap superior → sheer-FP
|
||||||
|
return ctrl
|
||||||
|
# Default: roda vertical con 3 puntos
|
||||||
|
return np.array([[x_fp, z0], [x_fp, (z0 + z1) * 0.5], [x_fp, z1]])
|
||||||
|
|
||||||
|
def get_transom_ctrl(self) -> np.ndarray:
|
||||||
|
"""Puntos de control del contorno del espejo (AP) en X-Z.
|
||||||
|
|
||||||
|
Orden: [0] = quilla-AP, [-1] = sheer-AP.
|
||||||
|
El primer y último punto siempre están fijados a quilla/sheer en AP.
|
||||||
|
Solo los puntos intermedios son libremente editables.
|
||||||
|
"""
|
||||||
|
x_ap = float(self.offsets.x_stations[0])
|
||||||
|
z0 = float(self.offsets.keel_z[0]) # quilla en AP
|
||||||
|
z1 = float(self.get_sheer_z()[0]) # sheer en AP
|
||||||
|
if self.transom_ctrl.shape[0] >= 3:
|
||||||
|
ctrl = self.transom_ctrl.copy()
|
||||||
|
ctrl[0] = [x_ap, z0] # snap inferior → quilla-AP
|
||||||
|
ctrl[-1] = [x_ap, z1] # snap superior → sheer-AP
|
||||||
|
return ctrl
|
||||||
|
return np.array([[x_ap, z0], [x_ap, (z0 + z1) * 0.5], [x_ap, z1]])
|
||||||
|
|
||||||
|
def get_sheer_z(self) -> np.ndarray:
|
||||||
|
"""Alturas de cubierta (sheer) por estación [m].
|
||||||
|
|
||||||
|
Si ``sheer_z`` no está inicializado o tiene dimensión incorrecta,
|
||||||
|
devuelve un array uniforme con el valor del puntal (``depth``).
|
||||||
|
"""
|
||||||
|
n = self.offsets.n_stations
|
||||||
|
if len(self.sheer_z) == n:
|
||||||
|
return self.sheer_z
|
||||||
|
return np.full(n, self.depth)
|
||||||
|
|
||||||
|
def get_keel_x_offsets(self) -> np.ndarray:
|
||||||
|
"""Desviaciones X per-estación para los nodos de quilla [m]."""
|
||||||
|
n = self.offsets.n_stations
|
||||||
|
if len(self.keel_x_offsets) == n:
|
||||||
|
return self.keel_x_offsets
|
||||||
|
return np.zeros(n)
|
||||||
|
|
||||||
|
def get_sheer_x_offsets(self) -> np.ndarray:
|
||||||
|
"""Desviaciones X per-estación para los nodos de sheer [m]."""
|
||||||
|
n = self.offsets.n_stations
|
||||||
|
if len(self.sheer_x_offsets) == n:
|
||||||
|
return self.sheer_x_offsets
|
||||||
|
return np.zeros(n)
|
||||||
|
|
||||||
|
def is_corner(self, i: int, j: int) -> bool:
|
||||||
|
"""Indica si el nodo (i, j) está marcado como esquina."""
|
||||||
|
return any(int(e[0]) == i and int(e[1]) == j for e in self.corner_nodes)
|
||||||
|
|
||||||
|
def toggle_corner(self, i: int, j: int) -> None:
|
||||||
|
"""Alterna el estado de esquina del nodo (i, j)."""
|
||||||
|
if self.is_corner(i, j):
|
||||||
|
self.corner_nodes = [e for e in self.corner_nodes
|
||||||
|
if not (int(e[0]) == i and int(e[1]) == j)]
|
||||||
|
else:
|
||||||
|
self.corner_nodes.append([i, j])
|
||||||
|
|
||||||
|
def get_station_planes(self, n: int = 11) -> np.ndarray:
|
||||||
|
"""Posiciones X [m] de los planos de estación para visualización.
|
||||||
|
|
||||||
|
Si no están configurados, devuelve n planos uniformes entre 0 y Lpp.
|
||||||
|
"""
|
||||||
|
if len(self.station_planes) >= 2:
|
||||||
|
return self.station_planes
|
||||||
|
return np.linspace(0.0, self.lpp, n)
|
||||||
|
|
||||||
|
def insert_station(self, x: float) -> None:
|
||||||
|
"""Inserta una nueva estación en la posición x [m], interpolando todos los arrays.
|
||||||
|
|
||||||
|
Actualiza OffsetsTable (x_stations, data, keel_z) y Hull (sheer_z).
|
||||||
|
AP y FP no se pueden sobrepasar.
|
||||||
|
"""
|
||||||
|
ot = self.offsets
|
||||||
|
x = float(np.clip(x, float(ot.x_stations[0]) + 1e-3, float(ot.x_stations[-1]) - 1e-3))
|
||||||
|
idx = int(np.searchsorted(ot.x_stations, x))
|
||||||
|
|
||||||
|
old_x = ot.x_stations.copy()
|
||||||
|
old_sheer = self.get_sheer_z().copy()
|
||||||
|
|
||||||
|
# Interpolar semi-mangas y desviaciones para la nueva estación
|
||||||
|
new_y = np.array([
|
||||||
|
float(np.interp(x, old_x, ot.data[:, j]))
|
||||||
|
for j in range(ot.n_waterlines)
|
||||||
|
])
|
||||||
|
new_keel_z = float(np.interp(x, old_x, ot.keel_z))
|
||||||
|
new_sheer_z = float(np.interp(x, old_x, old_sheer))
|
||||||
|
new_z_offsets = np.array([
|
||||||
|
float(np.interp(x, old_x, ot.z_offsets[:, j]))
|
||||||
|
for j in range(ot.n_waterlines)
|
||||||
|
])
|
||||||
|
new_x_offsets = np.array([
|
||||||
|
float(np.interp(x, old_x, ot.x_offsets[:, j]))
|
||||||
|
for j in range(ot.n_waterlines)
|
||||||
|
])
|
||||||
|
new_keel_x_off = float(np.interp(x, old_x, self.get_keel_x_offsets()))
|
||||||
|
new_sheer_x_off = float(np.interp(x, old_x, self.get_sheer_x_offsets()))
|
||||||
|
|
||||||
|
ot.x_stations = np.insert(old_x, idx, x)
|
||||||
|
ot.data = np.insert(ot.data, idx, new_y, axis=0)
|
||||||
|
ot.keel_z = np.insert(ot.keel_z, idx, new_keel_z)
|
||||||
|
ot.z_offsets = np.insert(ot.z_offsets, idx, new_z_offsets, axis=0)
|
||||||
|
ot.x_offsets = np.insert(ot.x_offsets, idx, new_x_offsets, axis=0)
|
||||||
|
lbl = f"S{idx}"
|
||||||
|
ot.station_labels.insert(idx, lbl)
|
||||||
|
|
||||||
|
self.sheer_z = np.insert(old_sheer, idx, new_sheer_z)
|
||||||
|
self.keel_x_offsets = np.insert(self.get_keel_x_offsets(), idx, new_keel_x_off)
|
||||||
|
self.sheer_x_offsets = np.insert(self.get_sheer_x_offsets(), idx, new_sheer_x_off)
|
||||||
|
self.invalidate()
|
||||||
|
|
||||||
|
def insert_waterline(self, z: float) -> None:
|
||||||
|
"""Inserta una nueva línea de agua en altura z [m], interpolando semi-mangas.
|
||||||
|
|
||||||
|
No afecta keel_z ni sheer_z (son arrays por estación, no por LdA).
|
||||||
|
"""
|
||||||
|
ot = self.offsets
|
||||||
|
z = float(np.clip(z, float(ot.z_waterlines[0]) + 1e-3, float(ot.z_waterlines[-1]) - 1e-3))
|
||||||
|
idx = int(np.searchsorted(ot.z_waterlines, z))
|
||||||
|
new_y = np.array([
|
||||||
|
float(np.interp(z, ot.z_waterlines, ot.data[i, :]))
|
||||||
|
for i in range(ot.n_stations)
|
||||||
|
])
|
||||||
|
ot.z_waterlines = np.insert(ot.z_waterlines, idx, z)
|
||||||
|
ot.data = np.insert(ot.data, idx, new_y, axis=1)
|
||||||
|
ot.z_offsets = np.insert(ot.z_offsets, idx, 0.0, axis=1)
|
||||||
|
ot.x_offsets = np.insert(ot.x_offsets, idx, 0.0, axis=1)
|
||||||
|
self.invalidate()
|
||||||
|
|
||||||
def invalidate(self) -> None:
|
def invalidate(self) -> None:
|
||||||
"""Invalida la caché de la superficie NURBS.
|
"""Invalida la caché de la superficie NURBS.
|
||||||
|
|
||||||
@@ -108,13 +273,81 @@ class Hull:
|
|||||||
"""
|
"""
|
||||||
self._surface = None
|
self._surface = None
|
||||||
|
|
||||||
|
def snap_boundary_nodes_to_contours(self) -> None:
|
||||||
|
"""Enclava los nodos extremos de cada línea de agua sobre las
|
||||||
|
aristas de terminación del casco: roda (FP, índice -1) y espejo
|
||||||
|
(AP, índice 0).
|
||||||
|
|
||||||
|
El stem y el transom son **aristas de terminación** (boundary edges)
|
||||||
|
— no son curvas interiores de suavizado. Cada línea de agua debe
|
||||||
|
terminar exactamente donde intersecta esa arista. Este método
|
||||||
|
calcula la coordenada X de esa intersección para cada altura Z y
|
||||||
|
actualiza x_offsets[0,:] y x_offsets[-1,:] en consecuencia.
|
||||||
|
|
||||||
|
Llamar tras:
|
||||||
|
- Cargar un casco desde disco (``from_dict``).
|
||||||
|
- Arrastrar un punto de control del stem o del transom.
|
||||||
|
"""
|
||||||
|
from arshipdesign.geometry.nurbs_curve import BSplineCurve
|
||||||
|
|
||||||
|
ot = self.offsets
|
||||||
|
n_wl = ot.n_waterlines
|
||||||
|
NSAMP = max(300, n_wl * 30)
|
||||||
|
|
||||||
|
def _sample_edge(ctrl: np.ndarray) -> np.ndarray:
|
||||||
|
"""Muestrea la curva spline de una arista de terminación.
|
||||||
|
|
||||||
|
Devuelve (NSAMP, 2) [X, Z] ordenado por Z creciente para
|
||||||
|
poder usar np.interp(z, ...) de forma segura.
|
||||||
|
"""
|
||||||
|
m = len(ctrl)
|
||||||
|
if m < 2:
|
||||||
|
return ctrl[np.argsort(ctrl[:, 1])]
|
||||||
|
try:
|
||||||
|
deg = min(3, m - 1)
|
||||||
|
curve = BSplineCurve(ctrl, degree=deg)
|
||||||
|
pts = curve.sample(NSAMP) # (NSAMP, 2): X, Z
|
||||||
|
except Exception:
|
||||||
|
t_raw = np.linspace(0.0, 1.0, m)
|
||||||
|
t_new = np.linspace(0.0, 1.0, NSAMP)
|
||||||
|
pts = np.column_stack([
|
||||||
|
np.interp(t_new, t_raw, ctrl[:, 0]),
|
||||||
|
np.interp(t_new, t_raw, ctrl[:, 1]),
|
||||||
|
])
|
||||||
|
return pts[np.argsort(pts[:, 1])] # ordenar por Z
|
||||||
|
|
||||||
|
stem_samp = _sample_edge(self.get_stem_ctrl()) # FP (i = -1)
|
||||||
|
trans_samp = _sample_edge(self.get_transom_ctrl()) # AP (i = 0)
|
||||||
|
|
||||||
|
# Asegurar forma correcta
|
||||||
|
if ot.x_offsets.shape != (ot.n_stations, n_wl):
|
||||||
|
ot.x_offsets = np.zeros((ot.n_stations, n_wl))
|
||||||
|
|
||||||
|
z_stem_min, z_stem_max = float(stem_samp[:, 1].min()), float(stem_samp[:, 1].max())
|
||||||
|
z_trans_min, z_trans_max = float(trans_samp[:, 1].min()), float(trans_samp[:, 1].max())
|
||||||
|
|
||||||
|
for j in range(n_wl):
|
||||||
|
z_fp = float(ot.z_waterlines[j]) + float(ot.z_offsets[-1, j])
|
||||||
|
z_ap = float(ot.z_waterlines[j]) + float(ot.z_offsets[0, j])
|
||||||
|
|
||||||
|
# Clamp dentro del rango de la arista (evita extrapolación)
|
||||||
|
z_fp = float(np.clip(z_fp, z_stem_min, z_stem_max))
|
||||||
|
z_ap = float(np.clip(z_ap, z_trans_min, z_trans_max))
|
||||||
|
|
||||||
|
x_on_stem = float(np.interp(z_fp, stem_samp[:, 1], stem_samp[:, 0]))
|
||||||
|
x_on_trans = float(np.interp(z_ap, trans_samp[:, 1], trans_samp[:, 0]))
|
||||||
|
|
||||||
|
# x_offsets = X_efectiva − X_referencia_paramétrica
|
||||||
|
ot.x_offsets[-1, j] = x_on_stem - float(ot.x_stations[-1])
|
||||||
|
ot.x_offsets[0, j] = x_on_trans - float(ot.x_stations[0])
|
||||||
|
|
||||||
def _build_surface(self) -> LoftedSurface:
|
def _build_surface(self) -> LoftedSurface:
|
||||||
sections_data = []
|
sections_data = []
|
||||||
u_arr = self.offsets.x_stations / self.lpp # normalizar a [0,1]
|
u_arr = self.offsets.x_stations / self.lpp # normalizar a [0,1]
|
||||||
for i, u in enumerate(u_arr):
|
for i, u in enumerate(u_arr):
|
||||||
pts = np.column_stack([
|
pts = np.column_stack([
|
||||||
self.offsets.data[i, :],
|
self.offsets.data[i, :],
|
||||||
self.offsets.z_waterlines,
|
self.offsets.z_waterlines + self.offsets.z_offsets[i, :],
|
||||||
])
|
])
|
||||||
sections_data.append((float(u), pts))
|
sections_data.append((float(u), pts))
|
||||||
n_sec = len(sections_data)
|
n_sec = len(sections_data)
|
||||||
@@ -350,6 +583,8 @@ class Hull:
|
|||||||
def to_mesh(self, n_u: int = 40, n_v: int = 20) -> "pyvista.PolyData":
|
def to_mesh(self, n_u: int = 40, n_v: int = 20) -> "pyvista.PolyData":
|
||||||
"""Genera una malla PyVista del casco (ambas bandas).
|
"""Genera una malla PyVista del casco (ambas bandas).
|
||||||
|
|
||||||
|
Cada sección se construye desde la quilla (keel_z per-estación)
|
||||||
|
hasta la cubierta (sheer_z per-estación), igual que BodyPlanViewer.
|
||||||
Requiere PyVista instalado. Retorna un PolyData triangulado.
|
Requiere PyVista instalado. Retorna un PolyData triangulado.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -357,33 +592,58 @@ class Hull:
|
|||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise ImportError("PyVista no está instalado") from exc
|
raise ImportError("PyVista no está instalado") from exc
|
||||||
|
|
||||||
surf = self.surface
|
from arshipdesign.geometry.nurbs_curve import BSplineCurve
|
||||||
u_range = surf.u_range
|
|
||||||
u_arr = np.linspace(u_range[0], u_range[1], n_u)
|
|
||||||
v_arr = np.linspace(0.0, 1.0, n_v)
|
|
||||||
uu, vv = np.meshgrid(u_arr, v_arr, indexing="ij") # (n_u, n_v)
|
|
||||||
|
|
||||||
# Evaluar (y, z) en la malla
|
ot = self.offsets
|
||||||
y_mat = surf._spline_y(u_arr, v_arr) # (n_u, n_v)
|
sheer = self.get_sheer_z()
|
||||||
z_mat = surf._spline_z(u_arr, v_arr) # (n_u, n_v)
|
|
||||||
|
|
||||||
# x real desde parámetro u
|
# ── Paso 1: perfiles (y, z) a las estaciones originales ──────────
|
||||||
x_mat = uu * self.lpp
|
def _section_yz(i: int, n_pts: int) -> np.ndarray:
|
||||||
|
"""Muestrea n_pts puntos del perfil (y, z) de la sección i."""
|
||||||
|
kz = float(ot.keel_z[i])
|
||||||
|
sz = float(sheer[i])
|
||||||
|
y_arr = ot.data[i, :]
|
||||||
|
z_arr = ot.z_waterlines + ot.z_offsets[i, :]
|
||||||
|
keel_pt = np.array([[0.0, kz]])
|
||||||
|
sheer_pt = np.array([[float(y_arr[-1]), sz]])
|
||||||
|
raw = np.vstack([keel_pt, np.column_stack([y_arr, z_arr]), sheer_pt])
|
||||||
|
m = len(raw)
|
||||||
|
try:
|
||||||
|
k = min(3, max(m - 1, 1))
|
||||||
|
curve = BSplineCurve(raw, degree=k)
|
||||||
|
return curve.sample(n_pts) # (n_pts, 2)
|
||||||
|
except Exception:
|
||||||
|
# Fallback: linear re-sample of the raw polyline
|
||||||
|
t_raw = np.linspace(0, 1, m)
|
||||||
|
t_new = np.linspace(0, 1, n_pts)
|
||||||
|
return np.column_stack([
|
||||||
|
np.interp(t_new, t_raw, raw[:, 0]),
|
||||||
|
np.interp(t_new, t_raw, raw[:, 1]),
|
||||||
|
])
|
||||||
|
|
||||||
# Banda de estribor (y > 0)
|
n_sta = ot.n_stations
|
||||||
pts_stbd = np.stack([
|
profiles = np.array([_section_yz(i, n_v) for i in range(n_sta)])
|
||||||
x_mat.ravel(), y_mat.ravel(), z_mat.ravel()
|
# profiles shape: (n_sta, n_v, 2) — (y, z) at each station
|
||||||
], axis=1)
|
|
||||||
|
|
||||||
# Banda de babor (y < 0)
|
# ── Paso 2: interpolar a n_u estaciones uniformes ────────────────
|
||||||
pts_port = np.stack([
|
x_orig = ot.x_stations / self.lpp # normalized [0,1]
|
||||||
x_mat.ravel(), -y_mat.ravel(), z_mat.ravel()
|
x_new = np.linspace(0.0, 1.0, n_u)
|
||||||
], axis=1)
|
|
||||||
|
|
||||||
# Unir ambas bandas
|
yz_grid = np.zeros((n_u, n_v, 2))
|
||||||
|
for v_idx in range(n_v):
|
||||||
|
for c in range(2): # y, z
|
||||||
|
yz_grid[:, v_idx, c] = np.interp(x_new, x_orig, profiles[:, v_idx, c])
|
||||||
|
|
||||||
|
# ── Paso 3: construir vértices 3D ─────────────────────────────────
|
||||||
|
x_grid = (x_new[:, None] * self.lpp) * np.ones((n_u, n_v))
|
||||||
|
y_grid = yz_grid[:, :, 0] # semi-manga (estribor +)
|
||||||
|
z_grid = yz_grid[:, :, 1]
|
||||||
|
|
||||||
|
pts_stbd = np.stack([x_grid.ravel(), y_grid.ravel(), z_grid.ravel()], axis=1)
|
||||||
|
pts_port = np.stack([x_grid.ravel(), -y_grid.ravel(), z_grid.ravel()], axis=1)
|
||||||
all_pts = np.vstack([pts_stbd, pts_port])
|
all_pts = np.vstack([pts_stbd, pts_port])
|
||||||
|
|
||||||
# Construir caras de la malla estructurada
|
# ── Paso 4: caras de la malla estructurada ────────────────────────
|
||||||
faces = []
|
faces = []
|
||||||
offset = n_u * n_v
|
offset = n_u * n_v
|
||||||
for band in [0, offset]:
|
for band in [0, offset]:
|
||||||
@@ -395,8 +655,7 @@ class Hull:
|
|||||||
p3 = band + i * n_v + (j + 1)
|
p3 = band + i * n_v + (j + 1)
|
||||||
faces.extend([4, p0, p1, p2, p3])
|
faces.extend([4, p0, p1, p2, p3])
|
||||||
|
|
||||||
faces_arr = np.array(faces, dtype=int)
|
mesh = pv.PolyData(all_pts, np.array(faces, dtype=int))
|
||||||
mesh = pv.PolyData(all_pts, faces_arr)
|
|
||||||
return mesh.triangulate()
|
return mesh.triangulate()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -421,6 +680,9 @@ class Hull:
|
|||||||
"beam": self.beam,
|
"beam": self.beam,
|
||||||
"depth": self.depth,
|
"depth": self.depth,
|
||||||
"draft": self.draft,
|
"draft": self.draft,
|
||||||
|
"sheer_z": self.get_sheer_z().tolist(),
|
||||||
|
"stem_ctrl": self.get_stem_ctrl().tolist(),
|
||||||
|
"transom_ctrl": self.get_transom_ctrl().tolist(),
|
||||||
"offsets": {
|
"offsets": {
|
||||||
"lpp": ot.lpp,
|
"lpp": ot.lpp,
|
||||||
"beam": ot.beam,
|
"beam": ot.beam,
|
||||||
@@ -429,7 +691,14 @@ class Hull:
|
|||||||
"z_waterlines": ot.z_waterlines.tolist(),
|
"z_waterlines": ot.z_waterlines.tolist(),
|
||||||
"station_labels": list(ot.station_labels),
|
"station_labels": list(ot.station_labels),
|
||||||
"data": ot.data.tolist(), # (n_sta, n_wl)
|
"data": ot.data.tolist(), # (n_sta, n_wl)
|
||||||
|
"keel_z": ot.keel_z.tolist(),
|
||||||
|
"z_offsets": ot.z_offsets.tolist(), # (n_sta, n_wl)
|
||||||
|
"x_offsets": ot.x_offsets.tolist(), # (n_sta, n_wl)
|
||||||
},
|
},
|
||||||
|
"keel_x_offsets": self.get_keel_x_offsets().tolist(),
|
||||||
|
"sheer_x_offsets": self.get_sheer_x_offsets().tolist(),
|
||||||
|
"station_planes": self.get_station_planes().tolist(),
|
||||||
|
"corner_nodes": self.corner_nodes,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -459,15 +728,30 @@ class Hull:
|
|||||||
lpp = float(od["lpp"]),
|
lpp = float(od["lpp"]),
|
||||||
beam = float(od["beam"]),
|
beam = float(od["beam"]),
|
||||||
draft = float(od["draft"]),
|
draft = float(od["draft"]),
|
||||||
|
keel_z = np.array(od.get("keel_z", []), dtype=float),
|
||||||
|
z_offsets = np.array(od.get("z_offsets", np.zeros((0, 0))), dtype=float),
|
||||||
|
x_offsets = np.array(od.get("x_offsets", np.zeros((0, 0))), dtype=float),
|
||||||
)
|
)
|
||||||
return cls(
|
hull = cls(
|
||||||
name = str(data["name"]),
|
name = str(data["name"]),
|
||||||
lpp = float(data["lpp"]),
|
lpp = float(data["lpp"]),
|
||||||
beam = float(data["beam"]),
|
beam = float(data["beam"]),
|
||||||
depth = float(data["depth"]),
|
depth = float(data["depth"]),
|
||||||
draft = float(data["draft"]),
|
draft = float(data["draft"]),
|
||||||
offsets = offsets,
|
offsets = offsets,
|
||||||
|
sheer_z = np.array(data.get("sheer_z", []), dtype=float),
|
||||||
|
stem_ctrl = np.array(data.get("stem_ctrl", []), dtype=float).reshape(-1, 2),
|
||||||
|
transom_ctrl = np.array(data.get("transom_ctrl", []), dtype=float).reshape(-1, 2),
|
||||||
|
keel_x_offsets = np.array(data.get("keel_x_offsets", []), dtype=float),
|
||||||
|
sheer_x_offsets = np.array(data.get("sheer_x_offsets", []), dtype=float),
|
||||||
|
station_planes = np.array(data.get("station_planes", []), dtype=float),
|
||||||
|
corner_nodes = list(data.get("corner_nodes", [])),
|
||||||
)
|
)
|
||||||
|
# NOTA: NO snap_boundary_nodes_to_contours() al cargar.
|
||||||
|
# Los x_offsets guardados son la posición que el usuario definió
|
||||||
|
# en el Visor Perfil (eje X libre) y se restauran tal cual.
|
||||||
|
# El snap solo aplica en la creación inicial del proyecto (wizard).
|
||||||
|
return hull
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Dunder
|
# Dunder
|
||||||
|
|||||||
@@ -47,11 +47,27 @@ class OffsetsTable:
|
|||||||
lpp: float = 0.0
|
lpp: float = 0.0
|
||||||
beam: float = 0.0
|
beam: float = 0.0
|
||||||
draft: float = 0.0
|
draft: float = 0.0
|
||||||
|
# Altura de la quilla por estación [m]. Permite quillas inclinadas
|
||||||
|
# (rise of keel / rocker) y quillas con perfil curvo sin alterar la
|
||||||
|
# cuadrícula de líneas de agua que permanece compartida.
|
||||||
|
# Default: cero en todas las estaciones (quilla plana sobre baseline).
|
||||||
|
keel_z: np.ndarray = field(default_factory=lambda: np.array([]))
|
||||||
|
# Desviación vertical per-nodo [m]. shape (n_sta, n_wl).
|
||||||
|
# La Z efectiva del nodo (i, j) = z_waterlines[j] + z_offsets[i, j].
|
||||||
|
# Default: ceros → todos los nodos en los planos horizontales de referencia.
|
||||||
|
z_offsets: np.ndarray = field(default_factory=lambda: np.zeros((0, 0)))
|
||||||
|
# Desviación longitudinal per-nodo [m]. shape (n_sta, n_wl).
|
||||||
|
# La X efectiva del nodo (i, j) = x_stations[i] + x_offsets[i, j].
|
||||||
|
# x_stations es la referencia paramétrica FIJA; nunca se modifica en drag.
|
||||||
|
x_offsets: np.ndarray = field(default_factory=lambda: np.zeros((0, 0)))
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self.x_stations = np.asarray(self.x_stations, dtype=float)
|
self.x_stations = np.asarray(self.x_stations, dtype=float)
|
||||||
self.z_waterlines = np.asarray(self.z_waterlines, dtype=float)
|
self.z_waterlines = np.asarray(self.z_waterlines, dtype=float)
|
||||||
self.data = np.asarray(self.data, dtype=float)
|
self.data = np.asarray(self.data, dtype=float)
|
||||||
|
self.keel_z = np.asarray(self.keel_z, dtype=float)
|
||||||
|
self.z_offsets = np.asarray(self.z_offsets, dtype=float)
|
||||||
|
self.x_offsets = np.asarray(self.x_offsets, dtype=float)
|
||||||
|
|
||||||
n_sta = len(self.x_stations)
|
n_sta = len(self.x_stations)
|
||||||
n_wl = len(self.z_waterlines)
|
n_wl = len(self.z_waterlines)
|
||||||
@@ -61,6 +77,15 @@ class OffsetsTable:
|
|||||||
)
|
)
|
||||||
if not self.station_labels:
|
if not self.station_labels:
|
||||||
self.station_labels = [str(i) for i in range(n_sta)]
|
self.station_labels = [str(i) for i in range(n_sta)]
|
||||||
|
# Inicializar keel_z si no se proporcionó o tiene dimensión incorrecta
|
||||||
|
if self.keel_z.shape != (n_sta,):
|
||||||
|
self.keel_z = np.zeros(n_sta)
|
||||||
|
# Inicializar z_offsets si no se proporcionó o tiene dimensiones incorrectas
|
||||||
|
if self.z_offsets.shape != (n_sta, n_wl):
|
||||||
|
self.z_offsets = np.zeros((n_sta, n_wl))
|
||||||
|
# Inicializar x_offsets si no se proporcionó o tiene dimensiones incorrectas
|
||||||
|
if self.x_offsets.shape != (n_sta, n_wl):
|
||||||
|
self.x_offsets = np.zeros((n_sta, n_wl))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Fábrica: casco Wigley analítico
|
# Fábrica: casco Wigley analítico
|
||||||
@@ -127,7 +152,7 @@ class OffsetsTable:
|
|||||||
station=sta,
|
station=sta,
|
||||||
x=float(x),
|
x=float(x),
|
||||||
half_breadths=self.data[i, :].copy(),
|
half_breadths=self.data[i, :].copy(),
|
||||||
z_positions=self.z_waterlines.copy(),
|
z_positions=(self.z_waterlines + self.z_offsets[i, :]).copy(),
|
||||||
label=f"x={x:.3f} m",
|
label=f"x={x:.3f} m",
|
||||||
)
|
)
|
||||||
sections.append(sec)
|
sections.append(sec)
|
||||||
@@ -139,13 +164,14 @@ class OffsetsTable:
|
|||||||
|
|
||||||
def half_breadth(self, x: float, z: float) -> float:
|
def half_breadth(self, x: float, z: float) -> float:
|
||||||
"""Interpola la semi-manga en cualquier (x, z) [m]."""
|
"""Interpola la semi-manga en cualquier (x, z) [m]."""
|
||||||
# Interpolar en x
|
# Para cada estación: interpola la semi-manga a la altura z usando las
|
||||||
|
# z efectivas per-nodo (z_waterlines[j] + z_offsets[i, j]).
|
||||||
col_y = np.array([
|
col_y = np.array([
|
||||||
float(np.interp(x, self.x_stations, self.data[:, j]))
|
float(np.interp(z, self.z_waterlines + self.z_offsets[i, :], self.data[i, :]))
|
||||||
for j in range(len(self.z_waterlines))
|
for i in range(len(self.x_stations))
|
||||||
])
|
])
|
||||||
# Interpolar en z
|
# Interpolar el resultado en x
|
||||||
return float(np.interp(z, self.z_waterlines, col_y))
|
return float(np.interp(x, self.x_stations, col_y))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def n_stations(self) -> int:
|
def n_stations(self) -> int:
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ from arshipdesign.core.hull import Hull
|
|||||||
from arshipdesign.core.offsets import OffsetsTable
|
from arshipdesign.core.offsets import OffsetsTable
|
||||||
|
|
||||||
|
|
||||||
|
def _standard_sheer_z(
|
||||||
|
x_sta: np.ndarray, lpp: float, depth: float,
|
||||||
|
fwd_rise_frac: float = 0.04, aft_rise_frac: float = 0.020,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Línea de cubierta parabólica: mínimo en cuaderna maestra, sube hacia proa/popa."""
|
||||||
|
xi = x_sta / lpp # 0=AP, 1=FP, 0.5=midship
|
||||||
|
return np.where(
|
||||||
|
xi >= 0.5,
|
||||||
|
depth * (1.0 + fwd_rise_frac * ((xi - 0.5) / 0.5) ** 2),
|
||||||
|
depth * (1.0 + aft_rise_frac * ((0.5 - xi) / 0.5) ** 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_merchant_hull(
|
def make_merchant_hull(
|
||||||
name: str = "Buque Mercante / Supply",
|
name: str = "Buque Mercante / Supply",
|
||||||
lpp: float = 20.0,
|
lpp: float = 20.0,
|
||||||
@@ -50,7 +63,8 @@ def make_merchant_hull(
|
|||||||
Ancho del fondo plano / manga (0.85–0.94).
|
Ancho del fondo plano / manga (0.85–0.94).
|
||||||
"""
|
"""
|
||||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
sheer_z = _standard_sheer_z(x_sta, lpp, depth, fwd_rise_frac=0.04, aft_rise_frac=0.020)
|
||||||
|
z_wl = np.linspace(0.0, float(sheer_z.max()), n_waterlines)
|
||||||
xi = (x_sta / lpp - 0.5) * 2.0
|
xi = (x_sta / lpp - 0.5) * 2.0
|
||||||
|
|
||||||
lcb_shift = 2.0 * (lcb_frac - 0.5)
|
lcb_shift = 2.0 * (lcb_frac - 0.5)
|
||||||
@@ -91,7 +105,8 @@ def make_merchant_hull(
|
|||||||
lpp=lpp, beam=beam, draft=draft,
|
lpp=lpp, beam=beam, draft=draft,
|
||||||
)
|
)
|
||||||
return Hull(
|
return Hull(
|
||||||
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets
|
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft,
|
||||||
|
offsets=offsets, sheer_z=sheer_z,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ from arshipdesign.core.hull import Hull
|
|||||||
from arshipdesign.core.offsets import OffsetsTable
|
from arshipdesign.core.offsets import OffsetsTable
|
||||||
|
|
||||||
|
|
||||||
|
def _standard_sheer_z(
|
||||||
|
x_sta: np.ndarray, lpp: float, depth: float,
|
||||||
|
fwd_rise_frac: float = 0.055, aft_rise_frac: float = 0.025,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Línea de cubierta parabólica: mínimo en cuaderna maestra, sube hacia proa/popa."""
|
||||||
|
xi = x_sta / lpp # 0=AP, 1=FP, 0.5=midship
|
||||||
|
return np.where(
|
||||||
|
xi >= 0.5,
|
||||||
|
depth * (1.0 + fwd_rise_frac * ((xi - 0.5) / 0.5) ** 2),
|
||||||
|
depth * (1.0 + aft_rise_frac * ((0.5 - xi) / 0.5) ** 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Forma de sección — carena redonda tipo desplazamiento
|
# Forma de sección — carena redonda tipo desplazamiento
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -78,7 +91,8 @@ def make_displacement_hull(
|
|||||||
Coeficiente de cuaderna maestra (0.82–0.92).
|
Coeficiente de cuaderna maestra (0.82–0.92).
|
||||||
"""
|
"""
|
||||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
sheer_z = _standard_sheer_z(x_sta, lpp, depth, fwd_rise_frac=0.055, aft_rise_frac=0.025)
|
||||||
|
z_wl = np.linspace(0.0, float(sheer_z.max()), n_waterlines)
|
||||||
xi = (x_sta / lpp - 0.5) * 2.0 # ∈ [−1, 1], 0=midship
|
xi = (x_sta / lpp - 0.5) * 2.0 # ∈ [−1, 1], 0=midship
|
||||||
|
|
||||||
# ── Plan form (semi-manga en flotación) ────────────────────────────
|
# ── Plan form (semi-manga en flotación) ────────────────────────────
|
||||||
@@ -111,7 +125,8 @@ def make_displacement_hull(
|
|||||||
lpp=lpp, beam=beam, draft=draft,
|
lpp=lpp, beam=beam, draft=draft,
|
||||||
)
|
)
|
||||||
return Hull(
|
return Hull(
|
||||||
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets
|
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft,
|
||||||
|
offsets=offsets, sheer_z=sheer_z,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ from arshipdesign.core.hull import Hull
|
|||||||
from arshipdesign.core.offsets import OffsetsTable
|
from arshipdesign.core.offsets import OffsetsTable
|
||||||
|
|
||||||
|
|
||||||
|
def _standard_sheer_z(
|
||||||
|
x_sta: np.ndarray, lpp: float, depth: float,
|
||||||
|
fwd_rise_frac: float = 0.03, aft_rise_frac: float = 0.015,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Línea de cubierta parabólica: mínimo en cuaderna maestra, sube hacia proa/popa."""
|
||||||
|
xi = x_sta / lpp # 0=AP, 1=FP, 0.5=midship
|
||||||
|
return np.where(
|
||||||
|
xi >= 0.5,
|
||||||
|
depth * (1.0 + fwd_rise_frac * ((xi - 0.5) / 0.5) ** 2),
|
||||||
|
depth * (1.0 + aft_rise_frac * ((0.5 - xi) / 0.5) ** 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# API pública
|
# API pública
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -57,7 +70,8 @@ def make_planing_hull(
|
|||||||
Fracción de ensanchamiento por encima del chine (0 = sin ensanche).
|
Fracción de ensanchamiento por encima del chine (0 = sin ensanche).
|
||||||
"""
|
"""
|
||||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
sheer_z = _standard_sheer_z(x_sta, lpp, depth, fwd_rise_frac=0.03, aft_rise_frac=0.015)
|
||||||
|
z_wl = np.linspace(0.0, float(sheer_z.max()), n_waterlines)
|
||||||
xi = (x_sta / lpp - 0.5) * 2.0 # normalizado ∈ [−1, 1], 0=midship
|
xi = (x_sta / lpp - 0.5) * 2.0 # normalizado ∈ [−1, 1], 0=midship
|
||||||
|
|
||||||
# ── Plan form: ancho en línea de agua por estación ─────────────────
|
# ── Plan form: ancho en línea de agua por estación ─────────────────
|
||||||
@@ -105,7 +119,8 @@ def make_planing_hull(
|
|||||||
lpp=lpp, beam=beam, draft=draft,
|
lpp=lpp, beam=beam, draft=draft,
|
||||||
)
|
)
|
||||||
return Hull(
|
return Hull(
|
||||||
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets
|
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft,
|
||||||
|
offsets=offsets, sheer_z=sheer_z,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ from arshipdesign.core.hull import Hull
|
|||||||
from arshipdesign.core.offsets import OffsetsTable
|
from arshipdesign.core.offsets import OffsetsTable
|
||||||
|
|
||||||
|
|
||||||
|
def _standard_sheer_z(
|
||||||
|
x_sta: np.ndarray, lpp: float, depth: float,
|
||||||
|
fwd_rise_frac: float = 0.08, aft_rise_frac: float = 0.04,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Línea de cubierta parabólica: mínimo en cuaderna maestra, sube hacia proa/popa."""
|
||||||
|
xi = x_sta / lpp # 0=AP, 1=FP, 0.5=midship
|
||||||
|
return np.where(
|
||||||
|
xi >= 0.5,
|
||||||
|
depth * (1.0 + fwd_rise_frac * ((xi - 0.5) / 0.5) ** 2),
|
||||||
|
depth * (1.0 + aft_rise_frac * ((0.5 - xi) / 0.5) ** 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Forma de sección — velero (V-fondo + cuerpo redondeado)
|
# Forma de sección — velero (V-fondo + cuerpo redondeado)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -45,6 +58,8 @@ def _sailing_section(
|
|||||||
"""
|
"""
|
||||||
if y_wl < 1e-9 or T < 1e-9:
|
if y_wl < 1e-9 or T < 1e-9:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
if z >= T:
|
||||||
|
return y_wl # plumb topside above design waterline
|
||||||
t_full = min(1.0, max(0.0, z / T))
|
t_full = min(1.0, max(0.0, z / T))
|
||||||
if t_full < 1e-12:
|
if t_full < 1e-12:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -98,7 +113,8 @@ def make_sailing_hull(
|
|||||||
Ángulo de astilla muerta en cuaderna maestra [°].
|
Ángulo de astilla muerta en cuaderna maestra [°].
|
||||||
"""
|
"""
|
||||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
sheer_z = _standard_sheer_z(x_sta, lpp, depth, fwd_rise_frac=0.08, aft_rise_frac=0.04)
|
||||||
|
z_wl = np.linspace(0.0, float(sheer_z.max()), n_waterlines)
|
||||||
xi = (x_sta / lpp - 0.5) * 2.0
|
xi = (x_sta / lpp - 0.5) * 2.0
|
||||||
|
|
||||||
lcb_shift = 2.0 * (lcb_frac - 0.5)
|
lcb_shift = 2.0 * (lcb_frac - 0.5)
|
||||||
@@ -131,7 +147,8 @@ def make_sailing_hull(
|
|||||||
lpp=lpp, beam=beam, draft=draft,
|
lpp=lpp, beam=beam, draft=draft,
|
||||||
)
|
)
|
||||||
return Hull(
|
return Hull(
|
||||||
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets
|
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft,
|
||||||
|
offsets=offsets, sheer_z=sheer_z,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ from arshipdesign.core.hull import Hull
|
|||||||
from arshipdesign.core.offsets import OffsetsTable
|
from arshipdesign.core.offsets import OffsetsTable
|
||||||
|
|
||||||
|
|
||||||
|
def _standard_sheer_z(
|
||||||
|
x_sta: np.ndarray, lpp: float, depth: float,
|
||||||
|
fwd_rise_frac: float = 0.05, aft_rise_frac: float = 0.025,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Línea de cubierta parabólica: mínimo en cuaderna maestra, sube hacia proa/popa.
|
||||||
|
El puntal de trazado (depth) es el valor en cuaderna maestra.
|
||||||
|
"""
|
||||||
|
xi = x_sta / lpp # 0=AP, 1=FP, 0.5=midship
|
||||||
|
return np.where(
|
||||||
|
xi >= 0.5,
|
||||||
|
depth * (1.0 + fwd_rise_frac * ((xi - 0.5) / 0.5) ** 2),
|
||||||
|
depth * (1.0 + aft_rise_frac * ((0.5 - xi) / 0.5) ** 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_workboat_hull(
|
def make_workboat_hull(
|
||||||
name: str = "Workboat / Supply",
|
name: str = "Workboat / Supply",
|
||||||
lpp: float = 15.0,
|
lpp: float = 15.0,
|
||||||
@@ -45,7 +60,8 @@ def make_workboat_hull(
|
|||||||
Anchura del fondo plano como fracción de la manga (0.80–0.92).
|
Anchura del fondo plano como fracción de la manga (0.80–0.92).
|
||||||
"""
|
"""
|
||||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
sheer_z = _standard_sheer_z(x_sta, lpp, depth, fwd_rise_frac=0.035, aft_rise_frac=0.020)
|
||||||
|
z_wl = np.linspace(0.0, float(sheer_z.max()), n_waterlines)
|
||||||
xi = (x_sta / lpp - 0.5) * 2.0
|
xi = (x_sta / lpp - 0.5) * 2.0
|
||||||
|
|
||||||
lcb_shift = 2.0 * (lcb_frac - 0.5)
|
lcb_shift = 2.0 * (lcb_frac - 0.5)
|
||||||
@@ -91,7 +107,8 @@ def make_workboat_hull(
|
|||||||
lpp=lpp, beam=beam, draft=draft,
|
lpp=lpp, beam=beam, draft=draft,
|
||||||
)
|
)
|
||||||
return Hull(
|
return Hull(
|
||||||
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft, offsets=offsets
|
name=name, lpp=lpp, beam=beam, depth=depth, draft=draft,
|
||||||
|
offsets=offsets, sheer_z=sheer_z,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+474
-128
@@ -21,7 +21,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QSize, Signal
|
from PySide6.QtCore import Qt, QSize, Signal, QThread, QObject
|
||||||
from PySide6.QtGui import QAction, QFont, QKeySequence, QIcon
|
from PySide6.QtGui import QAction, QFont, QKeySequence, QIcon
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
@@ -37,6 +37,7 @@ from PySide6.QtWidgets import (
|
|||||||
QSplitter,
|
QSplitter,
|
||||||
QStackedWidget,
|
QStackedWidget,
|
||||||
QToolBar,
|
QToolBar,
|
||||||
|
QPushButton,
|
||||||
QToolButton,
|
QToolButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
@@ -46,6 +47,7 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from arshipdesign import __version__
|
from arshipdesign import __version__
|
||||||
from arshipdesign.core.project import Project
|
from arshipdesign.core.project import Project
|
||||||
|
from arshipdesign.ui.icons import icon as _ico
|
||||||
from arshipdesign.utils.logger import get_logger
|
from arshipdesign.utils.logger import get_logger
|
||||||
from arshipdesign.stability import compute_gz_wall_sided, GZCurve, check_imo_is2008
|
from arshipdesign.stability import compute_gz_wall_sided, GZCurve, check_imo_is2008
|
||||||
from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget
|
from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget
|
||||||
@@ -184,12 +186,15 @@ _VIEW_LABELS: dict[str, str] = {
|
|||||||
|
|
||||||
|
|
||||||
class ViewportFrame(QFrame):
|
class ViewportFrame(QFrame):
|
||||||
"""Un viewport individual con barra de título."""
|
"""Un viewport individual con barra de título y botón de maximizar."""
|
||||||
|
|
||||||
|
maximize_requested = Signal(str) # emite view_type al pedir maximizar/restaurar
|
||||||
|
|
||||||
def __init__(self, view_type: str, parent: Optional[QWidget] = None) -> None:
|
def __init__(self, view_type: str, parent: Optional[QWidget] = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.view_type = view_type
|
self.view_type = view_type
|
||||||
self.setObjectName("viewportFrame")
|
self.setObjectName("viewportFrame")
|
||||||
|
self._maximized = False
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
@@ -197,19 +202,30 @@ class ViewportFrame(QFrame):
|
|||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(0)
|
layout.setSpacing(0)
|
||||||
|
|
||||||
# ── Barra de título (objectName único por vista) ──────────
|
# ── Barra de título ───────────────────────────────────────
|
||||||
title_bar = QWidget()
|
title_bar = QWidget()
|
||||||
# p.ej. "viewportTitleBar_perspective", "viewportTitleBar_profile"…
|
|
||||||
title_bar.setObjectName(f"viewportTitleBar_{self.view_type}")
|
title_bar.setObjectName(f"viewportTitleBar_{self.view_type}")
|
||||||
title_bar.setFixedHeight(24)
|
title_bar.setFixedHeight(24)
|
||||||
|
title_bar.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
tbl = QHBoxLayout(title_bar)
|
tbl = QHBoxLayout(title_bar)
|
||||||
tbl.setContentsMargins(10, 0, 4, 0)
|
tbl.setContentsMargins(10, 0, 4, 0)
|
||||||
tbl.setSpacing(0)
|
tbl.setSpacing(0)
|
||||||
|
|
||||||
lbl = QLabel(_VIEW_LABELS.get(self.view_type, self.view_type).upper())
|
self._title_lbl = QLabel(_VIEW_LABELS.get(self.view_type, self.view_type).upper())
|
||||||
lbl.setObjectName(f"viewportTitle_{self.view_type}")
|
self._title_lbl.setObjectName(f"viewportTitle_{self.view_type}")
|
||||||
tbl.addWidget(lbl)
|
tbl.addWidget(self._title_lbl)
|
||||||
tbl.addStretch()
|
tbl.addStretch()
|
||||||
|
|
||||||
|
# Botón maximizar / restaurar
|
||||||
|
self._max_btn = QPushButton("⬜")
|
||||||
|
self._max_btn.setObjectName("viewportMaxBtn")
|
||||||
|
self._max_btn.setFixedSize(20, 20)
|
||||||
|
self._max_btn.setFlat(True)
|
||||||
|
self._max_btn.setToolTip("Maximizar / Restaurar (doble clic en la barra)")
|
||||||
|
self._max_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self._max_btn.clicked.connect(lambda: self.maximize_requested.emit(self.view_type))
|
||||||
|
tbl.addWidget(self._max_btn)
|
||||||
|
|
||||||
layout.addWidget(title_bar)
|
layout.addWidget(title_bar)
|
||||||
|
|
||||||
# ── Área de dibujo (placeholder Sprint 0) ────────────────
|
# ── Área de dibujo (placeholder Sprint 0) ────────────────
|
||||||
@@ -224,6 +240,14 @@ class ViewportFrame(QFrame):
|
|||||||
cl.addWidget(ph)
|
cl.addWidget(ph)
|
||||||
layout.addWidget(self._canvas, 1)
|
layout.addWidget(self._canvas, 1)
|
||||||
|
|
||||||
|
# Doble clic en la barra también maximiza
|
||||||
|
title_bar.mouseDoubleClickEvent = lambda _e: self.maximize_requested.emit(self.view_type)
|
||||||
|
|
||||||
|
def set_maximized_icon(self, maximized: bool) -> None:
|
||||||
|
"""Actualiza el icono del botón según el estado."""
|
||||||
|
self._max_btn.setText("❎" if maximized else "⬜")
|
||||||
|
self._max_btn.setToolTip("Restaurar (doble clic)" if maximized else "Maximizar (doble clic)")
|
||||||
|
|
||||||
def set_canvas(self, widget: QWidget) -> None:
|
def set_canvas(self, widget: QWidget) -> None:
|
||||||
"""Sprint 1: sustituye el placeholder por el widget 3D / 2D real."""
|
"""Sprint 1: sustituye el placeholder por el widget 3D / 2D real."""
|
||||||
lo = self.layout()
|
lo = self.layout()
|
||||||
@@ -249,6 +273,7 @@ class FourViewport(QWidget):
|
|||||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("fourViewport")
|
self.setObjectName("fourViewport")
|
||||||
|
self._maximized_view: Optional[str] = None
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
@@ -256,34 +281,39 @@ class FourViewport(QWidget):
|
|||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(0)
|
layout.setSpacing(0)
|
||||||
|
|
||||||
v_split = QSplitter(Qt.Orientation.Vertical)
|
self._v_split = QSplitter(Qt.Orientation.Vertical)
|
||||||
v_split.setObjectName("viewportSplitter")
|
self._v_split.setObjectName("viewportSplitter")
|
||||||
v_split.setHandleWidth(5)
|
self._v_split.setHandleWidth(5)
|
||||||
|
|
||||||
top_split = QSplitter(Qt.Orientation.Horizontal)
|
self._top_split = QSplitter(Qt.Orientation.Horizontal)
|
||||||
top_split.setObjectName("viewportSplitter")
|
self._top_split.setObjectName("viewportSplitter")
|
||||||
top_split.setHandleWidth(5)
|
self._top_split.setHandleWidth(5)
|
||||||
self._vp_perspective = ViewportFrame("perspective")
|
self._vp_perspective = ViewportFrame("perspective")
|
||||||
self._vp_profile = ViewportFrame("profile")
|
self._vp_profile = ViewportFrame("profile")
|
||||||
top_split.addWidget(self._vp_perspective)
|
self._top_split.addWidget(self._vp_perspective)
|
||||||
top_split.addWidget(self._vp_profile)
|
self._top_split.addWidget(self._vp_profile)
|
||||||
top_split.setSizes([600, 600])
|
self._top_split.setSizes([600, 600])
|
||||||
|
|
||||||
bot_split = QSplitter(Qt.Orientation.Horizontal)
|
self._bot_split = QSplitter(Qt.Orientation.Horizontal)
|
||||||
bot_split.setObjectName("viewportSplitter")
|
self._bot_split.setObjectName("viewportSplitter")
|
||||||
bot_split.setHandleWidth(5)
|
self._bot_split.setHandleWidth(5)
|
||||||
self._vp_bodyplan = ViewportFrame("bodyplan")
|
self._vp_bodyplan = ViewportFrame("bodyplan")
|
||||||
self._vp_plan = ViewportFrame("plan")
|
self._vp_plan = ViewportFrame("plan")
|
||||||
bot_split.addWidget(self._vp_bodyplan)
|
self._bot_split.addWidget(self._vp_bodyplan)
|
||||||
bot_split.addWidget(self._vp_plan)
|
self._bot_split.addWidget(self._vp_plan)
|
||||||
bot_split.setSizes([600, 600])
|
self._bot_split.setSizes([600, 600])
|
||||||
|
|
||||||
v_split.addWidget(top_split)
|
self._v_split.addWidget(self._top_split)
|
||||||
v_split.addWidget(bot_split)
|
self._v_split.addWidget(self._bot_split)
|
||||||
v_split.setSizes([400, 400])
|
self._v_split.setSizes([400, 400])
|
||||||
layout.addWidget(v_split)
|
layout.addWidget(self._v_split)
|
||||||
|
|
||||||
def viewport(self, view_type: str) -> Optional[ViewportFrame]:
|
# Conectar señal de maximizar de cada frame
|
||||||
|
for vp in (self._vp_perspective, self._vp_profile,
|
||||||
|
self._vp_bodyplan, self._vp_plan):
|
||||||
|
vp.maximize_requested.connect(self.toggle_maximize)
|
||||||
|
|
||||||
|
def viewport(self, view_type: str) -> Optional["ViewportFrame"]:
|
||||||
return {
|
return {
|
||||||
"perspective": self._vp_perspective,
|
"perspective": self._vp_perspective,
|
||||||
"profile": self._vp_profile,
|
"profile": self._vp_profile,
|
||||||
@@ -291,6 +321,59 @@ class FourViewport(QWidget):
|
|||||||
"plan": self._vp_plan,
|
"plan": self._vp_plan,
|
||||||
}.get(view_type)
|
}.get(view_type)
|
||||||
|
|
||||||
|
def toggle_maximize(self, view_type: str) -> None:
|
||||||
|
"""Maximiza el viewport indicado, o restaura el layout de 4 paneles."""
|
||||||
|
if self._maximized_view == view_type:
|
||||||
|
self._restore_layout()
|
||||||
|
else:
|
||||||
|
self._maximize(view_type)
|
||||||
|
|
||||||
|
def _maximize(self, view_type: str) -> None:
|
||||||
|
"""Colapsa los otros tres paneles y expande el solicitado."""
|
||||||
|
companion_map = {
|
||||||
|
"perspective": self._vp_profile,
|
||||||
|
"profile": self._vp_perspective,
|
||||||
|
"bodyplan": self._vp_plan,
|
||||||
|
"plan": self._vp_bodyplan,
|
||||||
|
}
|
||||||
|
# Ocultar el otro viewport de la misma fila
|
||||||
|
companion_map[view_type].hide()
|
||||||
|
# Ocultar la fila opuesta completa
|
||||||
|
if view_type in ("perspective", "profile"):
|
||||||
|
self._bot_split.hide()
|
||||||
|
else:
|
||||||
|
self._top_split.hide()
|
||||||
|
|
||||||
|
self._maximized_view = view_type
|
||||||
|
all_vps = {
|
||||||
|
"perspective": self._vp_perspective,
|
||||||
|
"profile": self._vp_profile,
|
||||||
|
"bodyplan": self._vp_bodyplan,
|
||||||
|
"plan": self._vp_plan,
|
||||||
|
}
|
||||||
|
for name, vp in all_vps.items():
|
||||||
|
vp.set_maximized_icon(name == view_type)
|
||||||
|
|
||||||
|
def _restore_layout(self) -> None:
|
||||||
|
"""Restaura el layout normal de 4 paneles iguales."""
|
||||||
|
for vp in (self._vp_perspective, self._vp_profile,
|
||||||
|
self._vp_bodyplan, self._vp_plan):
|
||||||
|
vp.show()
|
||||||
|
self._top_split.show()
|
||||||
|
self._bot_split.show()
|
||||||
|
|
||||||
|
# Restaurar proporciones iguales
|
||||||
|
w = max(self.width(), 100)
|
||||||
|
h = max(self.height(), 100)
|
||||||
|
self._v_split.setSizes([h // 2, h // 2])
|
||||||
|
self._top_split.setSizes([w // 2, w // 2])
|
||||||
|
self._bot_split.setSizes([w // 2, w // 2])
|
||||||
|
|
||||||
|
self._maximized_view = None
|
||||||
|
for vp in (self._vp_perspective, self._vp_profile,
|
||||||
|
self._vp_bodyplan, self._vp_plan):
|
||||||
|
vp.set_maximized_icon(False)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# PANEL DE CAPAS (estilo DELFTship)
|
# PANEL DE CAPAS (estilo DELFTship)
|
||||||
@@ -792,6 +875,54 @@ class RibbonBar(QWidget):
|
|||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# WORKER: cálculo GZ en hilo separado (evita freeze del UI thread)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _HydroWorker(QObject):
|
||||||
|
"""Calcula HydrostaticCurves en un hilo secundario."""
|
||||||
|
finished = Signal(object) # HydrostaticCurves
|
||||||
|
error = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, hull, n_points: int = 30, rho: float = 1025.0) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._hull = hull
|
||||||
|
self._n_points = n_points
|
||||||
|
self._rho = rho
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves
|
||||||
|
curves = HydrostaticCurves.compute(
|
||||||
|
self._hull, n_points=self._n_points, rho=self._rho
|
||||||
|
)
|
||||||
|
self.finished.emit(curves)
|
||||||
|
except Exception as exc:
|
||||||
|
self.error.emit(str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
class _GZWorker(QObject):
|
||||||
|
"""Ejecuta compute_gz_wall_sided en un QThread para no bloquear la UI."""
|
||||||
|
|
||||||
|
finished = Signal(object, object) # (GZCurve, ImoResult)
|
||||||
|
error = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, hull, draft: float, kg: float) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._hull = hull
|
||||||
|
self._draft = draft
|
||||||
|
self._kg = kg
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
from arshipdesign.stability import compute_gz_wall_sided, check_imo_is2008
|
||||||
|
gz_curve = compute_gz_wall_sided(self._hull, self._draft, kg=self._kg)
|
||||||
|
imo_result = check_imo_is2008(gz_curve)
|
||||||
|
self.finished.emit(gz_curve, imo_result)
|
||||||
|
except Exception as exc:
|
||||||
|
self.error.emit(str(exc))
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# VENTANA PRINCIPAL
|
# VENTANA PRINCIPAL
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -816,6 +947,15 @@ class MainWindow(QMainWindow):
|
|||||||
self._project: Optional[Project] = None
|
self._project: Optional[Project] = None
|
||||||
self._current_hull = None # Hull activo en todos los visores
|
self._current_hull = None # Hull activo en todos los visores
|
||||||
self._gz_widget: Optional[GZCurveWidget] = None
|
self._gz_widget: Optional[GZCurveWidget] = None
|
||||||
|
self._gz_thread: Optional[QThread] = None
|
||||||
|
self._gz_worker = None
|
||||||
|
self._hydro_thread: Optional[QThread] = None
|
||||||
|
self._hydro_worker = None
|
||||||
|
# ── Historial de deshacer / rehacer ───────────────────────────
|
||||||
|
self._undo_stack: list[dict] = [] # estados anteriores (más viejo → índice 0)
|
||||||
|
self._redo_stack: list[dict] = [] # estados rehechos disponibles
|
||||||
|
self._last_hull_state: Optional[dict] = None # snapshot ANTES del último edit
|
||||||
|
self._MAX_UNDO = 50
|
||||||
self._lang = get_language()
|
self._lang = get_language()
|
||||||
self._strings = _load_i18n(self._lang)
|
self._strings = _load_i18n(self._lang)
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
@@ -866,10 +1006,17 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Edición live durante drag → actualizar vistas cruzadas sin resetear zoom
|
# Edición live durante drag → actualizar vistas cruzadas sin resetear zoom
|
||||||
self._viewer_bodyplan.offsets_dragging.connect(self._on_offsets_dragging)
|
self._viewer_bodyplan.offsets_dragging.connect(self._on_offsets_dragging)
|
||||||
|
self._viewer_profile.offsets_dragging.connect(self._on_offsets_dragging)
|
||||||
self._viewer_plan.offsets_dragging.connect(self._on_offsets_dragging)
|
self._viewer_plan.offsets_dragging.connect(self._on_offsets_dragging)
|
||||||
# Fin del drag → persistir + actualizar 3D + hidrostáticos
|
# Fin del drag → persistir + actualizar 3D + hidrostáticos
|
||||||
self._viewer_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
self._viewer_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
||||||
|
self._viewer_profile.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
||||||
self._viewer_plan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
self._viewer_plan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
||||||
|
# Selección cruzada de nodo: resaltar en las otras dos vistas
|
||||||
|
self._viewer_bodyplan.node_selected.connect(self._on_node_selected_in_viewer)
|
||||||
|
self._viewer_profile.node_selected.connect(self._on_node_selected_in_viewer)
|
||||||
|
self._viewer_plan.node_selected.connect(self._on_node_selected_in_viewer)
|
||||||
|
# Zoom independiente por visor — sin sincronización de escala
|
||||||
|
|
||||||
# Editor interactivo de offsets (sustituye el placeholder MOD_OFFSETS)
|
# Editor interactivo de offsets (sustituye el placeholder MOD_OFFSETS)
|
||||||
from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor
|
from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor
|
||||||
@@ -950,117 +1097,131 @@ class MainWindow(QMainWindow):
|
|||||||
g.add_button(_spi(sp.SP_ArrowForward), "Rehacer", "Rehacer Ctrl+Y", enabled=False)
|
g.add_button(_spi(sp.SP_ArrowForward), "Rehacer", "Rehacer Ctrl+Y", enabled=False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_HOME, "Vistas")
|
g = self._ribbon.new_group(RibbonBar.TAB_HOME, "Vistas")
|
||||||
g.add_button(_spi(sp.SP_DesktopIcon), "4 Vistas", "4 Viewports F2",
|
g.add_button(_ico("4views"), "4 Vistas", "4 Viewports F2",
|
||||||
lambda: self._module_area.activate(M.MOD_4VP))
|
lambda: self._module_area.activate(M.MOD_4VP))
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Líneas", "Plano de Líneas F3",
|
g.add_button(_ico("lines_plan"), "Líneas", "Plano de Líneas F3",
|
||||||
lambda: self._module_area.activate(M.MOD_LINES))
|
lambda: self._module_area.activate(M.MOD_LINES))
|
||||||
g.add_button(_spi(sp.SP_FileDialogListView), "Offsets", "Tabla de Offsets F4",
|
g.add_button(_spi(sp.SP_FileDialogListView), "Offsets", "Tabla de Offsets F4",
|
||||||
lambda: self._module_area.activate(M.MOD_OFFSETS))
|
lambda: self._module_area.activate(M.MOD_OFFSETS))
|
||||||
|
|
||||||
# ── Tab GEOMETRÍA ─────────────────────────────────────────
|
# ── Tab GEOMETRÍA ─────────────────────────────────────────
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Nuevo")
|
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Nuevo")
|
||||||
g.add_button(_spi(sp.SP_FileIcon), "Asistente", "Asistente de embarcación", enabled=False)
|
g.add_button(_ico("wizard"), "Asistente", "Asistente de embarcación", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileIcon), "Casco NURBS", "Nuevo casco NURBS", enabled=False)
|
g.add_button(_ico("hull_nurbs"), "Casco NURBS", "Nuevo casco NURBS", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileIcon), "Apéndice", "Añadir apéndice", enabled=False)
|
g.add_button(_ico("appendage"), "Apéndice", "Añadir apéndice", enabled=False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Edición NURBS")
|
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Edición NURBS")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Pts. Ctrl.", "Editar puntos de control", enabled=False)
|
g.add_button(_ico("ctrl_pts"), "Pts. Ctrl.", "Editar puntos de control", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Extruir", "Extruir sección", enabled=False)
|
g.add_button(_ico("extrude"), "Extruir", "Extruir sección", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Espejo", "Espejo por eje de crujía", enabled=False)
|
g.add_button(_ico("mirror"), "Espejo", "Espejo por eje de crujía", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Lackenby", "Transformación de Lackenby", enabled=False)
|
g.add_button(_ico("lackenby"), "Lackenby", "Transformación de Lackenby", enabled=False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Importar")
|
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Importar")
|
||||||
g.add_button(_spi(sp.SP_DirOpenIcon), "Offsets", "Importar tabla de offsets (.txt / .csv)", enabled=False)
|
g.add_button(_ico("import_offsets"), "Offsets", "Importar tabla de offsets (.txt / .csv)", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_DirOpenIcon), "DXF", "Importar plano DXF", enabled=False)
|
g.add_button(_ico("import_dxf"), "DXF", "Importar plano DXF", enabled=False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Exportar")
|
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Exportar")
|
||||||
g.add_button(_spi(sp.SP_DialogSaveButton), "IGES", "Exportar IGES", enabled=False)
|
g.add_button(_ico("export_iges"), "IGES", "Exportar IGES", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_DialogSaveButton), "STEP", "Exportar STEP", enabled=False)
|
g.add_button(_ico("export_step"), "STEP", "Exportar STEP", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_DialogSaveButton), "DXF", "Exportar DXF", enabled=False)
|
g.add_button(_ico("export_dxf"), "DXF", "Exportar DXF", enabled=False)
|
||||||
|
|
||||||
|
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Suavizado")
|
||||||
|
g.add_button(_ico("smooth"),
|
||||||
|
"Suavizar",
|
||||||
|
"Un paso de suavizado Laplaciano global — presiona varias veces",
|
||||||
|
self._fair_hull_step)
|
||||||
|
g.add_button(_ico("combs"),
|
||||||
|
"Peines",
|
||||||
|
"[C] Mostrar/ocultar peines de curvatura — selecciona una curva con Shift+clic primero",
|
||||||
|
self._toggle_curvature_display)
|
||||||
|
g.add_button(_ico("fairness"),
|
||||||
|
"Equidad",
|
||||||
|
"[F] Mostrar/ocultar coloreo de equidad (verde=suave, rojo=quiebre)",
|
||||||
|
self._toggle_fairness_display)
|
||||||
|
|
||||||
# ── Tab ANÁLISIS ──────────────────────────────────────────
|
# ── Tab ANÁLISIS ──────────────────────────────────────────
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Hidrostática")
|
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Hidrostática")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calcular", "Calcular curvas hidrostáticas",
|
g.add_button(_ico("hydro_calc"), "Calcular", "Calcular curvas hidrostáticas",
|
||||||
self._on_compute_hydrostatics)
|
self._on_compute_hydrostatics)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curvas", "Curvas hidrostáticas",
|
g.add_button(_ico("hydro_curves"), "Curvas", "Curvas hidrostáticas",
|
||||||
self._on_show_hydrostatics)
|
self._on_show_hydrostatics)
|
||||||
g.add_button(_spi(sp.SP_DialogSaveButton), "Exp. CSV", "Exportar curvas como CSV",
|
g.add_button(_ico("export_csv"), "Exp. CSV", "Exportar curvas como CSV",
|
||||||
self._on_export_hydrostatics_csv)
|
self._on_export_hydrostatics_csv)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad")
|
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curva GZ", "Curva GZ estática",
|
g.add_button(_ico("gz_curve"), "Curva GZ", "Curva GZ estática",
|
||||||
self._on_show_stability)
|
self._on_show_stability)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "IMO IS2008", "Criterios IMO IS Code 2008", enabled=False)
|
g.add_button(_ico("imo"), "IMO IS2008", "Criterios IMO IS Code 2008", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Avería", "Estabilidad en avería", enabled=False)
|
g.add_button(_ico("damage"), "Avería", "Estabilidad en avería", enabled=False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Resistencia")
|
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Resistencia")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Holtrop", "Holtrop & Mennen",
|
g.add_button(_ico("holtrop"), "Holtrop", "Holtrop & Mennen",
|
||||||
lambda: self._module_area.activate(M.MOD_RESISTANCE), False)
|
lambda: self._module_area.activate(M.MOD_RESISTANCE), False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Savitsky", "Savitsky (planeo)", enabled=False)
|
g.add_button(_ico("savitsky"), "Savitsky", "Savitsky (planeo)", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "VPP", "VPP Velero / DSYHS",
|
g.add_button(_ico("vpp"), "VPP", "VPP Velero / DSYHS",
|
||||||
lambda: self._module_area.activate(M.MOD_VPP), False)
|
lambda: self._module_area.activate(M.MOD_VPP), False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Seakeeping")
|
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Seakeeping")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "STF", "Strip Theory (STF)",
|
g.add_button(_ico("stf"), "STF", "Strip Theory (STF)",
|
||||||
lambda: self._module_area.activate(M.MOD_SEAKEEPING), False)
|
lambda: self._module_area.activate(M.MOD_SEAKEEPING), False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Espectro", "Espectro de respuesta", enabled=False)
|
g.add_button(_ico("spectrum"), "Espectro", "Espectro de respuesta", enabled=False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estructura")
|
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estructura")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "ISO 12215", "Escantillado ISO 12215-5",
|
g.add_button(_ico("iso12215"), "ISO 12215", "Escantillado ISO 12215-5",
|
||||||
lambda: self._module_area.activate(M.MOD_SCANTLING), False)
|
lambda: self._module_area.activate(M.MOD_SCANTLING), False)
|
||||||
|
|
||||||
# ── Tab TANQUES ───────────────────────────────────────────
|
# ── Tab TANQUES ───────────────────────────────────────────
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_TANKS, "Tanques")
|
g = self._ribbon.new_group(RibbonBar.TAB_TANKS, "Tanques")
|
||||||
g.add_button(_spi(sp.SP_FileIcon), "Nuevo Tq.", "Definir nuevo tanque",
|
g.add_button(_ico("new_tank"), "Nuevo Tq.", "Definir nuevo tanque",
|
||||||
lambda: self._module_area.activate(M.MOD_TANKS), False)
|
lambda: self._module_area.activate(M.MOD_TANKS), False)
|
||||||
g.add_button(_spi(sp.SP_FileIcon), "Modelar", "Modelar tanque NURBS", enabled=False)
|
g.add_button(_ico("model_tank"), "Modelar", "Modelar tanque NURBS", enabled=False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_TANKS, "Casos de Carga")
|
g = self._ribbon.new_group(RibbonBar.TAB_TANKS, "Casos de Carga")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Nuevo caso", "Definir caso de carga", enabled=False)
|
g.add_button(_ico("load_case"), "Nuevo caso", "Definir caso de carga", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Sondeos", "Tablas de sondeo",
|
g.add_button(_ico("sounding"), "Sondeos", "Tablas de sondeo",
|
||||||
lambda: self._module_area.activate(M.MOD_CAPACITY), False)
|
lambda: self._module_area.activate(M.MOD_CAPACITY), False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calc. KG", "Calcular KG por caso", enabled=False)
|
g.add_button(_ico("calc_kg"), "Calc. KG", "Calcular KG por caso", enabled=False)
|
||||||
|
|
||||||
# ── Tab SISTEMAS ──────────────────────────────────────────
|
# ── Tab SISTEMAS ──────────────────────────────────────────
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Eléctrico")
|
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Eléctrico")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "EPLA", "Balance eléctrico (EPLA)",
|
g.add_button(_ico("epla"), "EPLA", "Balance eléctrico (EPLA)",
|
||||||
lambda: self._module_area.activate(M.MOD_ELECTRICAL), False)
|
lambda: self._module_area.activate(M.MOD_ELECTRICAL), False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Fluidos")
|
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Fluidos")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Combustible", "Sistema de combustible",
|
g.add_button(_ico("fuel"), "Combustible", "Sistema de combustible",
|
||||||
lambda: self._module_area.activate(M.MOD_FUEL), False)
|
lambda: self._module_area.activate(M.MOD_FUEL), False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Agua Dulce", "Sistema de agua dulce",
|
g.add_button(_ico("freshwater"), "Agua Dulce", "Sistema de agua dulce",
|
||||||
lambda: self._module_area.activate(M.MOD_FRESHWATER), False)
|
lambda: self._module_area.activate(M.MOD_FRESHWATER), False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Achique", "Sistema de achique",
|
g.add_button(_ico("bilge"), "Achique", "Sistema de achique",
|
||||||
lambda: self._module_area.activate(M.MOD_BILGE), False)
|
lambda: self._module_area.activate(M.MOD_BILGE), False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "C. Incendio", "Sistema contra incendios",
|
g.add_button(_ico("firefight"), "C. Incendio", "Sistema contra incendios",
|
||||||
lambda: self._module_area.activate(M.MOD_FIREFIGHT), False)
|
lambda: self._module_area.activate(M.MOD_FIREFIGHT), False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Routing 3D")
|
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Routing 3D")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Tuberías", "Routing de tuberías 3D",
|
g.add_button(_ico("pipes"), "Tuberías", "Routing de tuberías 3D",
|
||||||
lambda: self._module_area.activate(M.MOD_ROUTING_PIPES), False)
|
lambda: self._module_area.activate(M.MOD_ROUTING_PIPES), False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Cableados", "Routing de cableados 3D",
|
g.add_button(_ico("cables"), "Cableados", "Routing de cableados 3D",
|
||||||
lambda: self._module_area.activate(M.MOD_ROUTING_CABLES), False)
|
lambda: self._module_area.activate(M.MOD_ROUTING_CABLES), False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Clima / Control")
|
g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Clima / Control")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "HVAC", "Sistema HVAC",
|
g.add_button(_ico("hvac"), "HVAC", "Sistema HVAC",
|
||||||
lambda: self._module_area.activate(M.MOD_HVAC), False)
|
lambda: self._module_area.activate(M.MOD_HVAC), False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Gobierno", "Sistema de gobierno", enabled=False)
|
g.add_button(_ico("steering"), "Gobierno", "Sistema de gobierno", enabled=False)
|
||||||
|
|
||||||
# ── Tab FABRICACIÓN ───────────────────────────────────────
|
# ── Tab FABRICACIÓN ───────────────────────────────────────
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_FABRICATION, "CNC")
|
g = self._ribbon.new_group(RibbonBar.TAB_FABRICATION, "CNC")
|
||||||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Materiales", "Estimación de materiales", enabled=False)
|
g.add_button(_ico("materials"), "Materiales", "Estimación de materiales", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Nesting", "Optimización de cortes (nesting)",
|
g.add_button(_ico("nesting"), "Nesting", "Optimización de cortes (nesting)",
|
||||||
lambda: self._module_area.activate(M.MOD_CNC), False)
|
lambda: self._module_area.activate(M.MOD_CNC), False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogContentsView), "G-code", "Generar G-code", enabled=False)
|
g.add_button(_ico("gcode"), "G-code", "Generar G-code", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Post-Proc.", "Configurar post-procesador CNC", enabled=False)
|
g.add_button(_ico("postproc"), "Post-Proc.", "Configurar post-procesador CNC", enabled=False)
|
||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_FABRICATION, "Moldes FRP")
|
g = self._ribbon.new_group(RibbonBar.TAB_FABRICATION, "Moldes FRP")
|
||||||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Lofting", "Lofting del molde",
|
g.add_button(_ico("lofting"), "Lofting", "Lofting del molde",
|
||||||
lambda: self._module_area.activate(M.MOD_MOLDS), False)
|
lambda: self._module_area.activate(M.MOD_MOLDS), False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Laminado", "Schedule de laminado", enabled=False)
|
g.add_button(_ico("laminate"), "Laminado", "Schedule de laminado", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogContentsView), "Resina", "Calculadora de resina", enabled=False)
|
g.add_button(_ico("resin"), "Resina", "Calculadora de resina", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogContentsView), "BOM", "BOM de materiales", enabled=False)
|
g.add_button(_ico("bom"), "BOM", "BOM de materiales", enabled=False)
|
||||||
|
|
||||||
def _setup_menu(self) -> None:
|
def _setup_menu(self) -> None:
|
||||||
mb = self.menuBar()
|
mb = self.menuBar()
|
||||||
@@ -1082,8 +1243,10 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# ── EDITAR ─────────────────────────────────────────────────
|
# ── EDITAR ─────────────────────────────────────────────────
|
||||||
m = mb.addMenu("Editar")
|
m = mb.addMenu("Editar")
|
||||||
self._act_undo = self._add_action(m, "Deshacer", QKeySequence.StandardKey.Undo, enabled=False)
|
self._act_undo = self._add_action(m, "Deshacer", QKeySequence.StandardKey.Undo,
|
||||||
self._act_redo = self._add_action(m, "Rehacer", QKeySequence.StandardKey.Redo, enabled=False)
|
slot=self._undo, enabled=False)
|
||||||
|
self._act_redo = self._add_action(m, "Rehacer", QKeySequence.StandardKey.Redo,
|
||||||
|
slot=self._redo, enabled=False)
|
||||||
m.addSeparator()
|
m.addSeparator()
|
||||||
self._add_action(m, "Preferencias...", slot=self._on_preferences)
|
self._add_action(m, "Preferencias...", slot=self._on_preferences)
|
||||||
|
|
||||||
@@ -1250,9 +1413,11 @@ class MainWindow(QMainWindow):
|
|||||||
self._project = Project.new(hull.name if hull else "Proyecto sin título")
|
self._project = Project.new(hull.name if hull else "Proyecto sin título")
|
||||||
self._on_project_loaded()
|
self._on_project_loaded()
|
||||||
if hull is not None:
|
if hull is not None:
|
||||||
|
hull.snap_boundary_nodes_to_contours()
|
||||||
self._current_hull = hull
|
self._current_hull = hull
|
||||||
self._project.set_hull(hull) # persistir en ship_data
|
self._project.set_hull(hull) # persistir en ship_data
|
||||||
self._load_hull_viewers(hull)
|
self._load_hull_viewers(hull)
|
||||||
|
self._reset_undo_history(hull)
|
||||||
self.statusBar().showMessage(
|
self.statusBar().showMessage(
|
||||||
f"Nuevo proyecto: {self._project.name}"
|
f"Nuevo proyecto: {self._project.name}"
|
||||||
)
|
)
|
||||||
@@ -1273,7 +1438,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._on_project_loaded()
|
self._on_project_loaded()
|
||||||
self.statusBar().showMessage(f"Abierto: {path}")
|
self.statusBar().showMessage(f"Abierto: {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error al abrir", str(e))
|
logger.error("Error al abrir proyecto '%s': %s", path, e)
|
||||||
|
QMessageBox.critical(self, "Error al abrir",
|
||||||
|
"No se pudo abrir el archivo. Consulte el log para más detalles.")
|
||||||
|
|
||||||
def _on_save_project(self) -> None:
|
def _on_save_project(self) -> None:
|
||||||
if not self._project:
|
if not self._project:
|
||||||
@@ -1286,7 +1453,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_title()
|
self._update_title()
|
||||||
self.statusBar().showMessage(f"Guardado: {self._project.path}")
|
self.statusBar().showMessage(f"Guardado: {self._project.path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error al guardar", str(e))
|
logger.error("Error al guardar proyecto: %s", e)
|
||||||
|
QMessageBox.critical(self, "Error al guardar",
|
||||||
|
"No se pudo guardar el archivo. Consulte el log para más detalles.")
|
||||||
|
|
||||||
def _on_save_as(self) -> None:
|
def _on_save_as(self) -> None:
|
||||||
if not self._project:
|
if not self._project:
|
||||||
@@ -1305,7 +1474,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_title()
|
self._update_title()
|
||||||
self.statusBar().showMessage(f"Guardado como: {path}")
|
self.statusBar().showMessage(f"Guardado como: {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error al guardar", str(e))
|
logger.error("Error al guardar proyecto como '%s': %s", path, e)
|
||||||
|
QMessageBox.critical(self, "Error al guardar",
|
||||||
|
"No se pudo guardar el archivo. Consulte el log para más detalles.")
|
||||||
|
|
||||||
def _on_project_loaded(self) -> None:
|
def _on_project_loaded(self) -> None:
|
||||||
self._update_title()
|
self._update_title()
|
||||||
@@ -1315,6 +1486,7 @@ class MainWindow(QMainWindow):
|
|||||||
if hull is not None:
|
if hull is not None:
|
||||||
self._current_hull = hull
|
self._current_hull = hull
|
||||||
self._load_hull_viewers(hull)
|
self._load_hull_viewers(hull)
|
||||||
|
self._reset_undo_history(hull)
|
||||||
logger.info("Hull '%s' restaurado desde proyecto", hull.name)
|
logger.info("Hull '%s' restaurado desde proyecto", hull.name)
|
||||||
|
|
||||||
def _load_hull_viewers(self, hull, *, _skip_offsets_editor: bool = False) -> None:
|
def _load_hull_viewers(self, hull, *, _skip_offsets_editor: bool = False) -> None:
|
||||||
@@ -1323,10 +1495,11 @@ class MainWindow(QMainWindow):
|
|||||||
``_skip_offsets_editor=True`` evita el bucle de retroalimentacion cuando
|
``_skip_offsets_editor=True`` evita el bucle de retroalimentacion cuando
|
||||||
la llamada proviene del propio editor de offsets.
|
la llamada proviene del propio editor de offsets.
|
||||||
"""
|
"""
|
||||||
# ── Visores 2D ────────────────────────────────────────────
|
# ── Visores 2D — cargar y unificar escala px/m ────────────
|
||||||
self._viewer_bodyplan.set_hull(hull)
|
self._viewer_bodyplan.set_hull(hull)
|
||||||
self._viewer_profile.set_hull(hull)
|
self._viewer_profile.set_hull(hull)
|
||||||
self._viewer_plan.set_hull(hull)
|
self._viewer_plan.set_hull(hull)
|
||||||
|
# Cada visor ajusta su propio zoom con _fit_to_view al recibir set_hull.
|
||||||
# ── Editor de offsets ─────────────────────────────────────
|
# ── Editor de offsets ─────────────────────────────────────
|
||||||
if not _skip_offsets_editor:
|
if not _skip_offsets_editor:
|
||||||
self._offsets_editor.set_hull(hull)
|
self._offsets_editor.set_hull(hull)
|
||||||
@@ -1376,7 +1549,14 @@ class MainWindow(QMainWindow):
|
|||||||
hull = self._current_hull
|
hull = self._current_hull
|
||||||
if hull is None:
|
if hull is None:
|
||||||
return
|
return
|
||||||
# hull.offsets ya fue modificado in-place durante el drag.
|
# ── Capturar estado ANTERIOR para Deshacer (Ctrl+Z) ──────────
|
||||||
|
if self._last_hull_state is not None:
|
||||||
|
self._undo_stack.append(self._last_hull_state)
|
||||||
|
if len(self._undo_stack) > self._MAX_UNDO:
|
||||||
|
self._undo_stack.pop(0)
|
||||||
|
self._redo_stack.clear() # nueva rama invalida el redo
|
||||||
|
self._act_undo.setEnabled(True)
|
||||||
|
self._act_redo.setEnabled(False)
|
||||||
# Invalidar caché NURBS para que to_mesh() reconstruya desde los
|
# Invalidar caché NURBS para que to_mesh() reconstruya desde los
|
||||||
# offsets editados y no devuelva la geometría anterior.
|
# offsets editados y no devuelva la geometría anterior.
|
||||||
hull.invalidate()
|
hull.invalidate()
|
||||||
@@ -1396,38 +1576,170 @@ class MainWindow(QMainWindow):
|
|||||||
logger.warning("Error al actualizar visor 3D: %s", exc)
|
logger.warning("Error al actualizar visor 3D: %s", exc)
|
||||||
# Barra de hidrostáticos
|
# Barra de hidrostáticos
|
||||||
self._update_hydrostatics(hull)
|
self._update_hydrostatics(hull)
|
||||||
|
# Guardar estado actual como referencia para el próximo Deshacer
|
||||||
|
self._last_hull_state = hull.to_dict()
|
||||||
self.statusBar().showMessage(f"Geometría editada — {hull.name}")
|
self.statusBar().showMessage(f"Geometría editada — {hull.name}")
|
||||||
|
|
||||||
|
# ── Deshacer / Rehacer ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _reset_undo_history(self, hull) -> None:
|
||||||
|
"""Limpia ambos stacks y captura el estado inicial del casco."""
|
||||||
|
self._undo_stack.clear()
|
||||||
|
self._redo_stack.clear()
|
||||||
|
self._last_hull_state = hull.to_dict()
|
||||||
|
self._act_undo.setEnabled(False)
|
||||||
|
self._act_redo.setEnabled(False)
|
||||||
|
|
||||||
|
def _undo(self) -> None:
|
||||||
|
"""Ctrl+Z — restaura el estado anterior al último drag/edición."""
|
||||||
|
if not self._undo_stack or self._current_hull is None:
|
||||||
|
return
|
||||||
|
from arshipdesign.core.hull import Hull
|
||||||
|
# Guardar estado actual en redo antes de revertir
|
||||||
|
self._redo_stack.append(self._current_hull.to_dict())
|
||||||
|
# Restaurar estado anterior
|
||||||
|
state = self._undo_stack.pop()
|
||||||
|
hull = Hull.from_dict(state)
|
||||||
|
self._current_hull = hull
|
||||||
|
if self._project is not None:
|
||||||
|
self._project.set_hull(hull)
|
||||||
|
self._load_hull_viewers(hull)
|
||||||
|
self._last_hull_state = hull.to_dict()
|
||||||
|
# Actualizar acciones
|
||||||
|
self._act_undo.setEnabled(bool(self._undo_stack))
|
||||||
|
self._act_redo.setEnabled(True)
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"Deshacer — {len(self._undo_stack)} paso(s) disponibles"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _redo(self) -> None:
|
||||||
|
"""Ctrl+Y — rehace el último cambio deshecho."""
|
||||||
|
if not self._redo_stack or self._current_hull is None:
|
||||||
|
return
|
||||||
|
from arshipdesign.core.hull import Hull
|
||||||
|
# Guardar estado actual en undo
|
||||||
|
self._undo_stack.append(self._current_hull.to_dict())
|
||||||
|
# Restaurar estado rehechos
|
||||||
|
state = self._redo_stack.pop()
|
||||||
|
hull = Hull.from_dict(state)
|
||||||
|
self._current_hull = hull
|
||||||
|
if self._project is not None:
|
||||||
|
self._project.set_hull(hull)
|
||||||
|
self._load_hull_viewers(hull)
|
||||||
|
self._last_hull_state = hull.to_dict()
|
||||||
|
# Actualizar acciones
|
||||||
|
self._act_undo.setEnabled(True)
|
||||||
|
self._act_redo.setEnabled(bool(self._redo_stack))
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"Rehacer — {len(self._redo_stack)} paso(s) por rehacer"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_node_selected_in_viewer(self, idx) -> None:
|
||||||
|
"""Propaga la selección de nodo a las otras dos vistas como anillo cian."""
|
||||||
|
sender = self.sender()
|
||||||
|
for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan):
|
||||||
|
if v is not sender:
|
||||||
|
v.set_peer_selection(idx)
|
||||||
|
|
||||||
|
def _on_viewer_scale_changed(self, scale: float) -> None:
|
||||||
|
"""Sincroniza el zoom: aplica la misma escala px/m a los otros dos visores."""
|
||||||
|
sender = self.sender()
|
||||||
|
for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan):
|
||||||
|
if v is not sender:
|
||||||
|
v.center_at_scale(scale)
|
||||||
|
|
||||||
|
def _fair_hull_step(self) -> None:
|
||||||
|
"""Un paso de suavizado Laplaciano sobre los offsets del casco activo.
|
||||||
|
|
||||||
|
Aplica el filtro y[i] = 0.5·y[i] + 0.25·(y[i-1] + y[i+1]) a cada
|
||||||
|
línea de agua, preservando las estaciones de borde (AP, FP).
|
||||||
|
Presionar el botón "Suavizar" varias veces para suavizar gradualmente.
|
||||||
|
"""
|
||||||
|
import numpy as np # local — numpy no es importado en el módulo
|
||||||
|
|
||||||
|
hull = self._current_hull
|
||||||
|
if hull is None:
|
||||||
|
return
|
||||||
|
ot = hull.offsets
|
||||||
|
if ot.n_stations < 3 or ot.n_waterlines < 3:
|
||||||
|
return
|
||||||
|
|
||||||
|
data = ot.data.copy()
|
||||||
|
# Laplaciano 1-D sobre el eje longitudinal (estaciones), bordes fijos
|
||||||
|
for j in range(ot.n_waterlines):
|
||||||
|
for i in range(1, ot.n_stations - 1):
|
||||||
|
data[i, j] = (0.5 * data[i, j]
|
||||||
|
+ 0.25 * (data[i - 1, j] + data[i + 1, j]))
|
||||||
|
ot.data[:] = np.maximum(0.0, data)
|
||||||
|
|
||||||
|
hull.invalidate()
|
||||||
|
if self._project is not None:
|
||||||
|
self._project.set_hull(hull)
|
||||||
|
self._viewer_bodyplan.update_offsets(hull)
|
||||||
|
self._viewer_profile.update_offsets(hull)
|
||||||
|
self._viewer_plan.update_offsets(hull)
|
||||||
|
self._offsets_editor.set_hull(hull)
|
||||||
|
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 tras suavizado: %s", exc)
|
||||||
|
self._update_hydrostatics(hull)
|
||||||
|
self.statusBar().showMessage("Suavizado — un paso Laplaciano aplicado")
|
||||||
|
|
||||||
|
def _toggle_curvature_display(self) -> None:
|
||||||
|
"""Activa/desactiva los peines de curvatura en los tres visores 2D.
|
||||||
|
|
||||||
|
Los pelos son perpendiculares a la curva — su longitud es proporcional
|
||||||
|
a la curvatura local (normalizada). Usar Shift+clic sobre una curva
|
||||||
|
para ver los pelos solo en esa curva.
|
||||||
|
"""
|
||||||
|
for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan):
|
||||||
|
v._show_curvature = not v._show_curvature
|
||||||
|
v.update()
|
||||||
|
any_on = self._viewer_bodyplan._show_curvature
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
"Peines ON — Shift+clic sobre una curva para enfocar [C] para apagar"
|
||||||
|
if any_on else "Peines OFF"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _toggle_fairness_display(self) -> None:
|
||||||
|
"""Activa/desactiva el coloreo de equidad en los tres visores 2D.
|
||||||
|
|
||||||
|
Verde = nodo suave (segunda derivada baja).
|
||||||
|
Rojo = quiebre brusco de curvatura — candidato para suavizar con [S].
|
||||||
|
"""
|
||||||
|
for v in (self._viewer_bodyplan, self._viewer_profile, self._viewer_plan):
|
||||||
|
v._show_fairness = not v._show_fairness
|
||||||
|
v.update()
|
||||||
|
any_on = self._viewer_bodyplan._show_fairness
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
"Equidad ON — verde=suave, rojo=quiebre [S]=suavizar nodo seleccionado"
|
||||||
|
if any_on else "Equidad OFF"
|
||||||
|
)
|
||||||
|
|
||||||
def _update_hydrostatics(self, hull) -> None:
|
def _update_hydrostatics(self, hull) -> None:
|
||||||
"""Calcula hidrostáticos al calado de diseño y actualiza la barra inferior.
|
"""Calcula hidrostáticos al calado de diseño y actualiza la barra inferior.
|
||||||
|
|
||||||
Métodos numéricos internos (regla de Simpson sobre las secciones
|
Usa compute_upright en una sola pasada (una sola integración) en lugar
|
||||||
muestreadas de la OffsetsTable) verificados contra el casco analítico
|
de llamar a cada método del Hull por separado (que haría 9× to_sections).
|
||||||
Wigley según IACS Rec.34 §4.3.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from arshipdesign.hydrostatics.upright import compute_upright
|
||||||
T = hull.draft
|
T = hull.draft
|
||||||
delta = hull.displacement_tonnes(T)
|
h = compute_upright(hull, T)
|
||||||
lcb_v = hull.lcb(T)
|
|
||||||
kb = hull.vcb(T)
|
|
||||||
kmt = hull.km_transverse(T)
|
|
||||||
tpc = hull.tpc(T)
|
|
||||||
mct = hull.mct1cm(T)
|
|
||||||
cb = hull.block_coefficient(T)
|
|
||||||
cw = hull.waterplane_coefficient(T)
|
|
||||||
cm = hull.midship_coefficient(T)
|
|
||||||
self._hydro.update_values({
|
self._hydro.update_values({
|
||||||
"T": f"{T:.2f}",
|
"T": f"{T:.2f}",
|
||||||
"Δ": f"{delta:.1f} t",
|
"Δ": f"{h.displacement:.1f} t",
|
||||||
"LCB": f"{lcb_v:.2f}",
|
"LCB": f"{h.lcb:.2f}",
|
||||||
"KB": f"{kb:.2f}",
|
"KB": f"{h.kb:.2f}",
|
||||||
"KMT": f"{kmt:.2f}",
|
"KMT": f"{h.kmt:.2f}",
|
||||||
"GMT": "—", # requiere KG del caso de carga
|
"GMT": "—",
|
||||||
"TPC": f"{tpc:.3f}",
|
"TPC": f"{h.tpc:.3f}",
|
||||||
"MCT": f"{mct:.2f}",
|
"MCT": f"{h.mct:.2f}",
|
||||||
"Cb": f"{cb:.3f}",
|
"Cb": f"{h.cb:.3f}",
|
||||||
"Cw": f"{cw:.3f}",
|
"Cw": f"{h.cw:.3f}",
|
||||||
"Cm": f"{cm:.3f}",
|
"Cm": f"{h.cm:.3f}",
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Error al calcular hidrostáticos: %s", exc)
|
logger.warning("Error al calcular hidrostáticos: %s", exc)
|
||||||
@@ -1437,20 +1749,36 @@ class MainWindow(QMainWindow):
|
|||||||
# ─────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_compute_hydrostatics(self) -> None:
|
def _on_compute_hydrostatics(self) -> None:
|
||||||
"""Calcula las curvas hidrostáticas y muestra el módulo."""
|
"""Lanza el cálculo de curvas hidrostáticas en un hilo secundario."""
|
||||||
if self._current_hull is None:
|
if self._current_hull is None:
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self, "Sin casco", "Crea o abre un proyecto con un casco definido."
|
self, "Sin casco", "Crea o abre un proyecto con un casco definido."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
# Evitar dos cálculos simultáneos
|
||||||
from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves
|
if hasattr(self, "_hydro_thread") and self._hydro_thread is not None:
|
||||||
|
if self._hydro_thread.isRunning():
|
||||||
|
return
|
||||||
|
|
||||||
self.statusBar().showMessage("Calculando curvas hidrostáticas…")
|
self.statusBar().showMessage("Calculando curvas hidrostáticas…")
|
||||||
QApplication.processEvents()
|
worker = _HydroWorker(self._current_hull, n_points=30, rho=1025.0)
|
||||||
curves = HydrostaticCurves.compute(
|
thread = QThread(self)
|
||||||
self._current_hull, n_points=30, rho=1025.0
|
worker.moveToThread(thread)
|
||||||
)
|
thread.started.connect(worker.run)
|
||||||
|
worker.finished.connect(self._on_hydro_done)
|
||||||
|
worker.error.connect(lambda msg: (
|
||||||
|
logger.error("Error curvas hidro: %s", msg),
|
||||||
|
self.statusBar().showMessage(f"Error: {msg}"),
|
||||||
|
))
|
||||||
|
worker.finished.connect(thread.quit)
|
||||||
|
worker.error.connect(thread.quit)
|
||||||
|
self._hydro_thread = thread
|
||||||
|
self._hydro_worker = worker
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _on_hydro_done(self, curves) -> None:
|
||||||
|
"""Callback en hilo principal cuando el cálculo hidrostático termina."""
|
||||||
self._hydro_chart.set_curves(curves)
|
self._hydro_chart.set_curves(curves)
|
||||||
self._module_area.activate(ModuleArea.MOD_CURVES)
|
self._module_area.activate(ModuleArea.MOD_CURVES)
|
||||||
self.statusBar().showMessage(
|
self.statusBar().showMessage(
|
||||||
@@ -1458,10 +1786,6 @@ class MainWindow(QMainWindow):
|
|||||||
f"({len(curves.points)} puntos, T: "
|
f"({len(curves.points)} puntos, T: "
|
||||||
f"{curves.drafts[0]:.2f}–{curves.drafts[-1]:.2f} m)"
|
f"{curves.drafts[0]:.2f}–{curves.drafts[-1]:.2f} m)"
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Error al calcular curvas: %s", exc)
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
|
||||||
QMessageBox.critical(self, "Error al calcular", str(exc))
|
|
||||||
|
|
||||||
def _on_show_hydrostatics(self) -> None:
|
def _on_show_hydrostatics(self) -> None:
|
||||||
"""Muestra el módulo de curvas (sin recalcular si ya hay datos)."""
|
"""Muestra el módulo de curvas (sin recalcular si ya hay datos)."""
|
||||||
@@ -1494,39 +1818,59 @@ class MainWindow(QMainWindow):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Error al exportar CSV: %s", exc)
|
logger.error("Error al exportar CSV: %s", exc)
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
QMessageBox.critical(self, "Error al exportar", str(exc))
|
QMessageBox.critical(self, "Error al exportar",
|
||||||
|
"No se pudo exportar el CSV. Consulte el log para más detalles.")
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────
|
||||||
# CURVA GZ — ESTABILIDAD
|
# CURVA GZ — ESTABILIDAD
|
||||||
# ─────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _compute_and_show_gz(self) -> None:
|
def _compute_and_show_gz(self) -> None:
|
||||||
"""Calcula la curva GZ wall-sided y actualiza el widget de estabilidad."""
|
"""Lanza el cálculo GZ en un hilo separado para no bloquear la UI."""
|
||||||
if self._current_hull is None:
|
if self._current_hull is None or self._gz_widget is None:
|
||||||
return
|
return
|
||||||
if self._gz_widget is None:
|
# Evitar lanzar dos cálculos simultáneos
|
||||||
|
if hasattr(self, "_gz_thread") and self._gz_thread is not None:
|
||||||
|
if self._gz_thread.isRunning():
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
hull = self._current_hull
|
hull = self._current_hull
|
||||||
kg = hull.depth * 0.55
|
kg = hull.depth * 0.55
|
||||||
self.statusBar().showMessage("Calculando curva GZ…")
|
self.statusBar().showMessage("Calculando curva GZ…")
|
||||||
QApplication.processEvents()
|
|
||||||
gz_curve = compute_gz_wall_sided(hull, hull.draft, kg=kg)
|
self._gz_worker = _GZWorker(hull, hull.draft, kg)
|
||||||
imo_result = check_imo_is2008(gz_curve)
|
self._gz_thread = QThread(self)
|
||||||
|
self._gz_worker.moveToThread(self._gz_thread)
|
||||||
|
|
||||||
|
self._gz_thread.started.connect(self._gz_worker.run)
|
||||||
|
self._gz_worker.finished.connect(self._on_gz_done)
|
||||||
|
self._gz_worker.error.connect(
|
||||||
|
lambda msg: (
|
||||||
|
logger.warning("Error GZ: %s", msg),
|
||||||
|
self.statusBar().showMessage(f"Error GZ: {msg}"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._gz_worker.finished.connect(self._gz_thread.quit)
|
||||||
|
self._gz_worker.error.connect(self._gz_thread.quit)
|
||||||
|
self._gz_thread.start()
|
||||||
|
|
||||||
|
def _on_gz_done(self, gz_curve, imo_result) -> None:
|
||||||
|
"""Callback en hilo principal cuando el cálculo GZ termina."""
|
||||||
|
if self._gz_widget is None:
|
||||||
|
return
|
||||||
self._gz_widget.set_curve(gz_curve, imo_result)
|
self._gz_widget.set_curve(gz_curve, imo_result)
|
||||||
# Actualizar indicador IMO en la barra de hidrostáticos
|
|
||||||
self._hydro.set_imo_status(
|
self._hydro.set_imo_status(
|
||||||
imo_result.overall_passed,
|
imo_result.overall_passed,
|
||||||
"" if imo_result.overall_passed else "GZ",
|
"" if imo_result.overall_passed else "GZ",
|
||||||
)
|
)
|
||||||
|
hull = self._current_hull
|
||||||
|
name = hull.name if hull else ""
|
||||||
self.statusBar().showMessage(
|
self.statusBar().showMessage(
|
||||||
f"Curva GZ calculada — {hull.name} "
|
f"Curva GZ calculada — {name} "
|
||||||
f"GM={gz_curve.gm:.3f}m GZmax={gz_curve.gz_max:.3f}m "
|
f"GM={gz_curve.gm:.3f}m GZmax={gz_curve.gz_max:.3f}m "
|
||||||
f"AVS={gz_curve.avs:.0f}° "
|
f"AVS={gz_curve.avs:.0f}° "
|
||||||
f"IMO={'CUMPLE' if imo_result.overall_passed else 'FALLA'}"
|
f"IMO={'CUMPLE' if imo_result.overall_passed else 'FALLA'}"
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Error al calcular curva GZ: %s", exc)
|
|
||||||
|
|
||||||
def _on_show_stability(self) -> None:
|
def _on_show_stability(self) -> None:
|
||||||
"""Muestra el módulo de estabilidad GZ (calcula si hay casco disponible)."""
|
"""Muestra el módulo de estabilidad GZ (calcula si hay casco disponible)."""
|
||||||
@@ -1607,7 +1951,9 @@ class MainWindow(QMainWindow):
|
|||||||
add_recent_file(path)
|
add_recent_file(path)
|
||||||
self._on_project_loaded()
|
self._on_project_loaded()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", str(e))
|
logger.error("Error al abrir archivo reciente '%s': %s", path, e)
|
||||||
|
QMessageBox.critical(self, "Error",
|
||||||
|
"No se pudo abrir el archivo reciente. Consulte el log para más detalles.")
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────
|
||||||
# GEOMETRÍA DE VENTANA
|
# GEOMETRÍA DE VENTANA
|
||||||
|
|||||||
@@ -370,7 +370,9 @@ class OffsetsEditor(QWidget):
|
|||||||
Path(path).write_text(buf.getvalue(), encoding="utf-8")
|
Path(path).write_text(buf.getvalue(), encoding="utf-8")
|
||||||
logger.info("Offsets exportados a %s", path)
|
logger.info("Offsets exportados a %s", path)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
QMessageBox.critical(self, "Error al exportar", str(exc))
|
logger.error("Error al exportar offsets: %s", exc)
|
||||||
|
QMessageBox.critical(self, "Error al exportar",
|
||||||
|
"No se pudieron exportar los offsets. Consulte el log para más detalles.")
|
||||||
|
|
||||||
def _on_import_csv(self) -> None:
|
def _on_import_csv(self) -> None:
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
@@ -424,7 +426,9 @@ class OffsetsEditor(QWidget):
|
|||||||
self.hull_changed.emit(new_hull)
|
self.hull_changed.emit(new_hull)
|
||||||
logger.info("Offsets importados desde %s", path)
|
logger.info("Offsets importados desde %s", path)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
QMessageBox.critical(self, "Error al importar", str(exc))
|
logger.error("Error al importar offsets: %s", exc)
|
||||||
|
QMessageBox.critical(self, "Error al importar",
|
||||||
|
"No se pudieron importar los offsets. Consulte el log para más detalles.")
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Info label
|
# Info label
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from typing import Optional
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PySide6.QtCore import Qt, QTimer, Signal
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||||||
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
logger = logging.getLogger("ui.viewer_3d")
|
logger = logging.getLogger("ui.viewer_3d")
|
||||||
|
|
||||||
@@ -57,6 +57,9 @@ class Viewer3DWidget(QWidget):
|
|||||||
self._plotter: Optional["QtInteractor"] = None
|
self._plotter: Optional["QtInteractor"] = None
|
||||||
self._ready = False
|
self._ready = False
|
||||||
self._pending_hull = None # hull recibido antes de que el plotter esté listo
|
self._pending_hull = None # hull recibido antes de que el plotter esté listo
|
||||||
|
self._hull_actor = None # actor VTK del casco — para toggle de aristas
|
||||||
|
self._show_edges = False # mallas apagadas por defecto (como Delftship)
|
||||||
|
self._edge_btn: Optional[QPushButton] = None
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -96,7 +99,26 @@ class Viewer3DWidget(QWidget):
|
|||||||
old.hide()
|
old.hide()
|
||||||
old.deleteLater()
|
old.deleteLater()
|
||||||
|
|
||||||
# Crear el interactor PyVista embebido
|
# ── Barra de herramientas 3D ───────────────────────────────
|
||||||
|
toolbar = QWidget()
|
||||||
|
toolbar.setObjectName("viewer3dToolbar")
|
||||||
|
tb_lo = QHBoxLayout(toolbar)
|
||||||
|
tb_lo.setContentsMargins(4, 2, 4, 2)
|
||||||
|
tb_lo.setSpacing(4)
|
||||||
|
|
||||||
|
self._edge_btn = QPushButton("⬡ Mallas")
|
||||||
|
self._edge_btn.setCheckable(True)
|
||||||
|
self._edge_btn.setChecked(self._show_edges)
|
||||||
|
self._edge_btn.setFixedHeight(22)
|
||||||
|
self._edge_btn.setToolTip(
|
||||||
|
"Mostrar / ocultar aristas de la malla (como Delftship)"
|
||||||
|
)
|
||||||
|
self._edge_btn.toggled.connect(self._on_edge_toggled)
|
||||||
|
tb_lo.addWidget(self._edge_btn)
|
||||||
|
tb_lo.addStretch()
|
||||||
|
lo.addWidget(toolbar)
|
||||||
|
|
||||||
|
# ── Interactor PyVista embebido ────────────────────────────
|
||||||
self._plotter = QtInteractor(
|
self._plotter = QtInteractor(
|
||||||
parent=self,
|
parent=self,
|
||||||
auto_update=False, # sin polling continuo de GPU
|
auto_update=False, # sin polling continuo de GPU
|
||||||
@@ -192,15 +214,22 @@ class Viewer3DWidget(QWidget):
|
|||||||
return
|
return
|
||||||
self._plotter.clear()
|
self._plotter.clear()
|
||||||
|
|
||||||
# Casco principal — color acero naval
|
# Casco principal — color sólido estilo DelftShip
|
||||||
self._plotter.add_mesh(
|
# smooth_shading=False → facetas planas, aspecto sólido sin blur
|
||||||
|
# ambient alto → menos sombras duras, color uniforme
|
||||||
|
# specular bajo → sin brillos que difuminen el color
|
||||||
|
self._hull_actor = self._plotter.add_mesh(
|
||||||
mesh,
|
mesh,
|
||||||
color="#3a6080",
|
color="#4a8ab0", # azul acero más vivo
|
||||||
smooth_shading=True,
|
smooth_shading=False, # facetado / sólido (no blur)
|
||||||
show_edges=True,
|
show_edges=self._show_edges,
|
||||||
edge_color="#4da8ff",
|
edge_color="#90c8f0",
|
||||||
line_width=0.3,
|
line_width=0.6,
|
||||||
opacity=0.92,
|
opacity=1.0, # totalmente opaco
|
||||||
|
ambient=0.40, # luz ambiente alta: sombras suaves
|
||||||
|
diffuse=0.60, # difuso moderado
|
||||||
|
specular=0.05, # casi sin especular (anti-blur)
|
||||||
|
specular_power=5,
|
||||||
name="hull",
|
name="hull",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -226,6 +255,17 @@ class Viewer3DWidget(QWidget):
|
|||||||
self._plotter.view_isometric()
|
self._plotter.view_isometric()
|
||||||
self._plotter.reset_camera()
|
self._plotter.reset_camera()
|
||||||
|
|
||||||
|
def _on_edge_toggled(self, checked: bool) -> None:
|
||||||
|
"""Activa / desactiva la malla de aristas sin re-renderizar el casco."""
|
||||||
|
self._show_edges = checked
|
||||||
|
if self._hull_actor is not None and self._plotter is not None:
|
||||||
|
prop = self._hull_actor.GetProperty()
|
||||||
|
if checked:
|
||||||
|
prop.EdgeVisibilityOn()
|
||||||
|
else:
|
||||||
|
prop.EdgeVisibilityOff()
|
||||||
|
self._plotter.render()
|
||||||
|
|
||||||
def closeEvent(self, event) -> None: # type: ignore[override]
|
def closeEvent(self, event) -> None: # type: ignore[override]
|
||||||
"""Libera el contexto PyVista al cerrar."""
|
"""Libera el contexto PyVista al cerrar."""
|
||||||
if self._plotter is not None:
|
if self._plotter is not None:
|
||||||
|
|||||||
+1542
-144
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,549 @@
|
|||||||
|
# BITÁCORA DE DESARROLLO — AR-ShipDesign
|
||||||
|
|
||||||
|
> **Propósito:** Registro técnico vivo de cada módulo funcional de la app.
|
||||||
|
> A diferencia de `CHANGELOG.md` (que registra versiones), esta bitácora documenta
|
||||||
|
> el estado interno de cada módulo: qué funciona, qué se corrigió, por qué se
|
||||||
|
> tomaron ciertas decisiones y qué queda pendiente.
|
||||||
|
>
|
||||||
|
> **Actualizar** al final de cada sesión de trabajo o al completar un feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Convenciones de estado
|
||||||
|
|
||||||
|
| Símbolo | Significado |
|
||||||
|
|---------|-------------|
|
||||||
|
| ✅ | Implementado y verificado (tests + visual) |
|
||||||
|
| 🔧 | Implementado, pendiente verificación visual en la app |
|
||||||
|
| 🐛 | Bug conocido, no resuelto aún |
|
||||||
|
| 📋 | Planificado, no iniciado |
|
||||||
|
| ❌ | Descartado o revertido (con explicación) |
|
||||||
|
| ⚠️ | Restricción crítica — no romper |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reglas Inquebrantables (leer SIEMPRE antes de editar los visores)
|
||||||
|
|
||||||
|
### ⚠️ REGLA DE EJES — NUNCA VIOLAR
|
||||||
|
|
||||||
|
```
|
||||||
|
Vista Perfil (ProfileViewer) → nodos en EJE X (longitudinal) + EJE Z (vertical)
|
||||||
|
Vista Planta (PlanViewer) → nodos en EJE X (longitudinal) + EJE Y (transversal)
|
||||||
|
Vista Frontal (BodyPlanViewer) → nodos en EJE Y (transversal) + EJE Z (vertical)
|
||||||
|
```
|
||||||
|
|
||||||
|
Nunca bloquear nodos en ninguna vista. Nunca añadir restricciones `_hit_test` a nodos normales.
|
||||||
|
Si un cambio rompe el movimiento en alguno de estos ejes → **REVERTIR INMEDIATAMENTE**.
|
||||||
|
|
||||||
|
### ⚠️ REGLA DE SNAP
|
||||||
|
|
||||||
|
El snap de nodos de contorno (`snap_boundary_nodes_to_contours`) **solo** se ejecuta en
|
||||||
|
`_on_new_project` (wizard de creación). Nunca en `_on_offsets_edited_from_viewer` ni en
|
||||||
|
`Hull.from_dict()`. Los `x_offsets` son datos del usuario y se restauran tal cual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sentinels de nodo especial (`viewer_lines.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
_KEEL_IDX = -1 # nodo de quilla (keel_z[i] por estación)
|
||||||
|
_SHEER_IDX = -2 # nodo de cubierta (sheer_z[i] por estación)
|
||||||
|
_STEM_IDX = -10 # punto de control de roda
|
||||||
|
_TRANS_IDX = -20 # punto de control de espejo de popa
|
||||||
|
```
|
||||||
|
|
||||||
|
El índice `j` en `(i, j)` siendo negativo indica nodo especial, no columna de `data[i,j]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 1 — Geometría del Casco
|
||||||
|
|
||||||
|
**Archivo clave:** `arshipdesign/core/hull.py`, `arshipdesign/core/offsets.py`
|
||||||
|
|
||||||
|
### Estructura de datos
|
||||||
|
|
||||||
|
```
|
||||||
|
Hull
|
||||||
|
├── offsets: OffsetsTable
|
||||||
|
│ ├── x_stations[n_sta] — posición X de cada estación [m]
|
||||||
|
│ ├── data[n_sta, n_wl] — semi-manga Y por (estación, LdA) [m]
|
||||||
|
│ ├── keel_z[n_sta] — Z de la quilla por estación [m]
|
||||||
|
│ ├── z_waterlines[n_wl] — Z absoluta de cada LdA [m]
|
||||||
|
│ ├── z_offsets[n_sta, n_wl] — ajuste Z local por nodo [m]
|
||||||
|
│ └── x_offsets[n_sta, n_wl] — ajuste X visual del nodo en los visores 2D [m]
|
||||||
|
├── sheer_z[n_sta] — Z de la cubierta (arrufo) por estación [m]
|
||||||
|
├── stem_ctrl[k, 2] — polígono de control de la roda (B-spline)
|
||||||
|
├── transom_ctrl[k, 2] — polígono de control del espejo de popa
|
||||||
|
└── corner_nodes: list[[i,j]] — nodos marcados como esquina (rompen suavidad)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estado ✅
|
||||||
|
|
||||||
|
- **Serialización** (`to_dict` / `from_dict`): guarda todos los arrays sin recalcular.
|
||||||
|
Al cargar, los `x_offsets` se restauran exactamente como el usuario los dejó.
|
||||||
|
- **Inserción de estaciones** (`insert_station`): interpola Y, keel_z, sheer_z y offsets.
|
||||||
|
- **Inserción de líneas de agua** (`insert_waterline`): interpola semi-mangas.
|
||||||
|
- **B-Spline de sección** (`_section_yz` en `to_mesh`): muestrea el perfil Y-Z
|
||||||
|
desde quilla → LdA de control → cubierta con grado mín(3, n-1).
|
||||||
|
- **Malla 3D** (`to_mesh`): grilla estructurada n_u × n_v interpolada entre estaciones,
|
||||||
|
triangulada para PyVista. Genera ambas bandas (estribor + babor).
|
||||||
|
- **Lazy cache** (`station_planes`, `get_sheer_z`): no recalcula si los datos no cambian.
|
||||||
|
|
||||||
|
### Bug conocido 🐛
|
||||||
|
|
||||||
|
**"Tabla en quilla"** — Si se mueve `keel_z[i]` de una sola estación muy lejos de
|
||||||
|
las vecinas, la malla 3D muestra una depresión abrupta (tabla/aleta) porque:
|
||||||
|
- Las LdA permanecen en sus Z fijos absolutas.
|
||||||
|
- La interpolación entre estaciones crea una concavidad estrecha en esa estación.
|
||||||
|
- **Workaround:** mover la quilla en varias estaciones sucesivas para distribuir el cambio.
|
||||||
|
- **Fix definitivo:** wizard de redistribución de LdA + más puntos de control de quilla.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 2 — Visores 2D Interactivos
|
||||||
|
|
||||||
|
**Archivo clave:** `arshipdesign/ui/widgets/viewer_lines.py`
|
||||||
|
|
||||||
|
### Clases principales
|
||||||
|
|
||||||
|
```
|
||||||
|
_BaseViewer — zoom, paneo, drag de nodos, hit-test, HUD, fairness, selección de curva
|
||||||
|
├── BodyPlanViewer — secciones transversales Y-Z (cuadernas)
|
||||||
|
├── ProfileViewer — vista lateral X-Z (quilla, cubierta, roda, espejo)
|
||||||
|
└── PlanViewer — vista de planta X-Y (líneas de agua desde arriba)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estado 🔧
|
||||||
|
|
||||||
|
- **Drag de nodos**: todos los nodos arrastrables, sin restricciones (respeta Regla de Ejes).
|
||||||
|
- **Selección de nodo** (clic): nodo se vuelve dorado; panel `NodeInfoPanel` muestra X/Y/Z
|
||||||
|
y checkbox de esquina. Enter aplica el valor editado manualmente.
|
||||||
|
- **Selección de curva** (Shift+clic): detecta arista de la malla NURBS más cercana.
|
||||||
|
La curva completa se resalta en verde menta `#00FFB0` con 2.5 px.
|
||||||
|
- Body Plan: Shift+clic → sección completa keel→LdA→sheer (estación i)
|
||||||
|
- Perfil: Shift+clic → quilla o cubierta (curva longitudinal)
|
||||||
|
- Planta: Shift+clic → línea de agua j completa
|
||||||
|
- **Peines de curvatura** `[C]`: pelos perpendiculares a la curva.
|
||||||
|
Normalizados por max|κ| → siempre visibles aunque la curva sea casi recta.
|
||||||
|
Solo en la curva seleccionada (Shift+clic) o en todas si no hay selección.
|
||||||
|
Pelo invertido al lado opuesto = inflexión (cambio de signo de curvatura).
|
||||||
|
- **Coloreo de equidad** `[F]`: nodos coloreados verde→amarillo→rojo por |d²Y/dX²|.
|
||||||
|
- **Suavizado local** `[S]`: Laplaciano 1 paso en el nodo seleccionado.
|
||||||
|
- **Zoom**: rueda del ratón. Doble clic: fit-to-view.
|
||||||
|
- **Paneo**: botón medio o derecho + arrastrar.
|
||||||
|
- **HUD** (esquina inferior derecha): estado de [C]/[F]/[S] y nombre de la curva activa.
|
||||||
|
- **Sincronización entre vistas** (en vivo): `offsets_dragging` durante el drag,
|
||||||
|
`offsets_edited` al soltar.
|
||||||
|
- **Menú contextual** (clic derecho): insertar LdA, estación, roda, espejo, esquina.
|
||||||
|
|
||||||
|
### Historial de correcciones
|
||||||
|
|
||||||
|
| Fecha | Problema | Causa raíz | Fix aplicado |
|
||||||
|
|-------|----------|------------|--------------|
|
||||||
|
| 2026-05-28 | Nodos de borde no arrastrables en X | `_hit_test` de ProfileViewer excluía i=0 e i=n-1 para LdA normales | Revertido: loop incluye todos los nodos sin excepción |
|
||||||
|
| 2026-05-28 | Peines de curvatura invisibles | `scale = beam × 0.20` → κ≈0.02 → pelo de 2cm, invisible a escala normal | Normalizado: todos los κ ÷ max\|κ\| antes de escalar |
|
||||||
|
| 2026-05-28 | `self._selected` no existe | Nombre incorrecto del atributo | Corregido a `self._selected_idx` |
|
||||||
|
|
||||||
|
### Pendiente 📋
|
||||||
|
|
||||||
|
- Peines de curvatura en keel/sheer desde el ProfileViewer (actualmente solo en quilla/cubierta como curvas, no como Z).
|
||||||
|
- Suavizado 2D (Laplaciano transversal dentro de la cuaderna).
|
||||||
|
- Tests automatizados para fairness coloring y suavizado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 3 — Visor 3D
|
||||||
|
|
||||||
|
**Archivo clave:** `arshipdesign/ui/widgets/viewer_3d.py`
|
||||||
|
|
||||||
|
### Estado 🔧
|
||||||
|
|
||||||
|
- **Motor**: PyVista + pyvistaqt (`QtInteractor` embebido).
|
||||||
|
- **Degradación sin PyVista**: muestra `QLabel` en lugar de crashear (permite que CI pase).
|
||||||
|
- **Carga diferida**: `QtInteractor` se crea 500 ms después del arranque (evita conflicto OpenGL).
|
||||||
|
- **Tema oscuro**: fondo `#1a1d30`, casco `#3a6080`, aristas `#4da8ff`, plano de flotación `#4da8ff` al 15%.
|
||||||
|
- **Toggle mallas** (botón `⬡ Mallas` en barra superior del visor): apagado por defecto.
|
||||||
|
Llama `GetProperty().EdgeVisibilityOn/Off()` sobre el actor VTK → sin re-render.
|
||||||
|
|
||||||
|
### Historial de correcciones
|
||||||
|
|
||||||
|
| Fecha | Problema | Fix |
|
||||||
|
|-------|----------|-----|
|
||||||
|
| 2026-05-29 | Mallas siempre visibles, sin forma de apagarlas | Añadido botón toggle + `_show_edges=False` por defecto |
|
||||||
|
|
||||||
|
### Pendiente 📋
|
||||||
|
|
||||||
|
- Caras invertidas: detectar y colorear diferente (rojo/azul), comando flip.
|
||||||
|
- Capas de visualización: buttocks, waterlines, sections como actores independientes.
|
||||||
|
- Cierre de malla en AP para transom stern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 4 — Guardado y Cargado de Proyectos
|
||||||
|
|
||||||
|
**Archivos:** `arshipdesign/core/project.py`, `arshipdesign/core/hull.py`
|
||||||
|
|
||||||
|
### Formato `.arsd`
|
||||||
|
|
||||||
|
Archivo ZIP que contiene `hull.json` con formato `hull_v1`.
|
||||||
|
Incluye todos los arrays de offsets, control curves, y metadatos del buque.
|
||||||
|
|
||||||
|
### Estado ✅
|
||||||
|
|
||||||
|
- **Persistencia exacta**: todos los arrays se guardan y restauran fielmente.
|
||||||
|
- **Sin snap en carga**: `from_dict` no llama `snap_boundary_nodes_to_contours`.
|
||||||
|
|
||||||
|
### Historial de correcciones
|
||||||
|
|
||||||
|
| Fecha | Problema | Causa | Fix |
|
||||||
|
|-------|----------|-------|-----|
|
||||||
|
| 2026-05-28 | Forma diferente al recargar | `snap_boundary_nodes_to_contours` en `from_dict` recalculaba `x_offsets` | Eliminado de `from_dict` |
|
||||||
|
| 2026-05-28 | Nodos saltaban al soltar | `snap` en `_on_offsets_edited_from_viewer` sobreescribía la posición del usuario | Eliminado del handler |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 5 — Hidrostáticos
|
||||||
|
|
||||||
|
**Archivos:** `arshipdesign/core/hydrostatics.py`
|
||||||
|
|
||||||
|
### Estado ✅
|
||||||
|
|
||||||
|
- Cálculo en tiempo real al modificar cualquier nodo.
|
||||||
|
- Métricas: Δ, LCB, TCB, KB, BM, GM, Cb, Cm, Cp, Cw, AWP.
|
||||||
|
- Validado contra casco analítico Wigley (IACS Rec.34 §4). Tests: 315/315 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 6 — Estabilidad
|
||||||
|
|
||||||
|
**Archivo:** `arshipdesign/core/stability.py`
|
||||||
|
|
||||||
|
### Estado ✅
|
||||||
|
|
||||||
|
- Curva GZ por planos de inclinación.
|
||||||
|
- Criterios IMO IS Code 2008 verificados.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 7 — Generadores Paramétricos
|
||||||
|
|
||||||
|
**Archivos:** `arshipdesign/parametric/wizard_*.py`
|
||||||
|
|
||||||
|
### Familias disponibles ✅
|
||||||
|
|
||||||
|
| Familia | Archivo | Estado |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| Workboat (buque de trabajo) | `wizard_workboat.py` | ✅ |
|
||||||
|
| Velero | `wizard_sailing.py` | ✅ |
|
||||||
|
| Lancha rápida | `wizard_fast.py` | ✅ |
|
||||||
|
| Remolcador | `wizard_tug.py` | ✅ |
|
||||||
|
| Ferry / pasaje | `wizard_ferry.py` | ✅ |
|
||||||
|
|
||||||
|
- **Arrufo parabólico**: `sheer_z[i] = sheer_base + camber × (1 − (2x/L − 1)²)`
|
||||||
|
- Snap de nodos de contorno se aplica **una sola vez** al crear el proyecto.
|
||||||
|
|
||||||
|
### Pendiente 📋
|
||||||
|
|
||||||
|
- Opción transom stern en el wizard (`has_transom: bool`, `transom_angle: float`).
|
||||||
|
- Wizard de estaciones/LdA/buttocks: definir manualmente posiciones antes de generar la malla.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 8 — UI / Layout / Ribbon
|
||||||
|
|
||||||
|
**Archivos:** `arshipdesign/ui/main_window.py`, widgets varios
|
||||||
|
|
||||||
|
### Estado 🔧
|
||||||
|
|
||||||
|
- **Layout 4 viewports**: QSplitters anidados. Arriba: 3D+Perfil. Abajo: FrontalI+Planta.
|
||||||
|
- **Maximizar viewport** (botón `⬜`/`❎` o doble clic en barra de título):
|
||||||
|
oculta viewport compañero y fila opuesta. Restaurar vuelve a 50/50.
|
||||||
|
- **Ribbon**: tabs Geometría, Hidrostáticos, Estabilidad, Estructural.
|
||||||
|
Grupo "Suavizado" con botones Curvatura, Equidad, Suavizar.
|
||||||
|
- **NodeInfoPanel**: flotante, coordenadas X/Y/Z editables + checkbox esquina.
|
||||||
|
|
||||||
|
### Historial de correcciones
|
||||||
|
|
||||||
|
| Fecha | Problema | Fix |
|
||||||
|
|-------|----------|-----|
|
||||||
|
| 2026-05-28 | Enter en NodeInfoPanel no aplicaba cambio | Señal `coord_edited` no conectada | Conectada en `__init__` |
|
||||||
|
| 2026-05-29 | `QPushButton` no importado | Faltaba en bloque de imports | Añadido |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 9 — Herramientas de Fairness (Equidad)
|
||||||
|
|
||||||
|
**Funciones en** `viewer_lines.py`: `_fairness_color`, `_smooth_selected_node`,
|
||||||
|
`_draw_curvature_comb`, `_curvature_comb_data`, `_dist_to_segment`
|
||||||
|
|
||||||
|
### Peines de curvatura
|
||||||
|
|
||||||
|
```
|
||||||
|
κᵢ = 2 × cross(t₁, t₂) / (l₁ + l₂) — curvatura discreta firmada
|
||||||
|
κ_normalizada = κᵢ / max|κ| — rango [-1, 1]
|
||||||
|
pelo_longitud = κ_normalizada × scale — en unidades de mundo
|
||||||
|
```
|
||||||
|
|
||||||
|
- Pelo al lado contrario de la curva = curvatura positiva (convexa).
|
||||||
|
- Pelo al mismo lado = curvatura negativa (cóncava / inflexión).
|
||||||
|
- Spine = línea que une las puntas → revela continuidad de curvatura.
|
||||||
|
|
||||||
|
### Coloreo de equidad
|
||||||
|
|
||||||
|
```
|
||||||
|
roughness = |Y[i+1] - 2·Y[i] + Y[i-1]| / (Δx²)
|
||||||
|
```
|
||||||
|
- Verde `#22cc66`: roughness < 0.005 m⁻¹
|
||||||
|
- Rojo `#e03030`: roughness > 0.150 m⁻¹
|
||||||
|
|
||||||
|
### Suavizado Laplaciano 1-paso
|
||||||
|
|
||||||
|
```
|
||||||
|
Y_new[i] = (Y[i-1] + Y[i] + Y[i+1]) / 3
|
||||||
|
```
|
||||||
|
Solo nodos interiores. Aplica a Y breadths, keel_z y sheer_z.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 10 — Deshacer / Rehacer (Ctrl+Z / Ctrl+Y)
|
||||||
|
|
||||||
|
**Archivo:** `arshipdesign/ui/main_window.py`
|
||||||
|
|
||||||
|
### Estado 🔧
|
||||||
|
|
||||||
|
- **Mecanismo**: stack de snapshots `hull.to_dict()` — cada estado es una copia completa del casco serializado (arreglos numpy → listas, muy pequeño en memoria).
|
||||||
|
- **Capacidad**: 50 pasos de deshacer (`_MAX_UNDO = 50`).
|
||||||
|
- **Ctrl+Z** (`Editar → Deshacer`): restaura el estado anterior al último drag/edición.
|
||||||
|
- **Ctrl+Y** (`Editar → Rehacer`): rehace el cambio deshecho.
|
||||||
|
- Cada nueva edición **limpia el stack de redo** (rama nueva invalida el futuro).
|
||||||
|
- Al crear o abrir un proyecto, ambos stacks se limpian (`_reset_undo_history`).
|
||||||
|
- Las acciones del menú se habilitan/deshabilitan según haya pasos disponibles.
|
||||||
|
|
||||||
|
### Cómo funciona internamente
|
||||||
|
|
||||||
|
```
|
||||||
|
_last_hull_state = snapshot del hull ANTES del último edit
|
||||||
|
_undo_stack = [estado_0, estado_1, ..., estado_n] ← el más reciente al final
|
||||||
|
_redo_stack = estados deshechados disponibles
|
||||||
|
|
||||||
|
Al recibir offsets_edited:
|
||||||
|
1. push _last_hull_state → _undo_stack
|
||||||
|
2. clear _redo_stack
|
||||||
|
3. _last_hull_state = hull.to_dict() (nuevo estado actual)
|
||||||
|
|
||||||
|
Al hacer Ctrl+Z:
|
||||||
|
1. push hull.to_dict() → _redo_stack
|
||||||
|
2. hull = Hull.from_dict(_undo_stack.pop())
|
||||||
|
3. _load_hull_viewers(hull) — refresca todos los visores + hidrostáticos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qué operaciones son deshaciibles
|
||||||
|
|
||||||
|
| Operación | ¿Deshacible? |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Arrastrar nodo | ✅ |
|
||||||
|
| Suavizar con [S] | ✅ (si emite offsets_edited) |
|
||||||
|
| Editar coordenada en panel | ✅ |
|
||||||
|
| Insertar estación/LdA desde menú contextual | ✅ |
|
||||||
|
| Crear nuevo proyecto | ❌ (limpia el historial) |
|
||||||
|
| Abrir proyecto | ❌ (limpia el historial) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 11 — Iconos de Ribbon (arshipdesign/ui/icons.py)
|
||||||
|
|
||||||
|
**Estado:** 🔧 Implementado — pendiente verificación visual
|
||||||
|
|
||||||
|
### Qué hace
|
||||||
|
|
||||||
|
Nuevo módulo `arshipdesign/ui/icons.py` con **50 iconos programáticos** únicos, uno por cada
|
||||||
|
botón del ribbon. Antes todos compartían el mismo icono genérico del sistema (`SP_FileDialogDetailedView`).
|
||||||
|
|
||||||
|
### Diseño técnico
|
||||||
|
|
||||||
|
- Cada icono se dibuja con `QPainter` sobre un `QPixmap(24×24)` transparente.
|
||||||
|
- Paleta coherente con el tema oscuro:
|
||||||
|
- `#c8d8e8` trazo principal
|
||||||
|
- `#4da8ff` cyan / agua
|
||||||
|
- `#00ffb0` verde mint (selección / OK)
|
||||||
|
- `#ffd060` amarillo / dorado (energía, controles)
|
||||||
|
- `#ff5555` rojo (daño, alerta)
|
||||||
|
- Función pública: `icon("clave") → QIcon` con caché `_CACHE` dict.
|
||||||
|
- Importado en `main_window.py` como `from arshipdesign.ui.icons import icon as _ico`.
|
||||||
|
|
||||||
|
### Iconos implementados por grupo
|
||||||
|
|
||||||
|
| Grupo | Claves |
|
||||||
|
|-------|--------|
|
||||||
|
| HOME / Vistas | `4views`, `lines_plan` |
|
||||||
|
| Geometría / Nuevo | `wizard`, `hull_nurbs`, `appendage` |
|
||||||
|
| Geometría / Edición NURBS | `ctrl_pts`, `extrude`, `mirror`, `lackenby` |
|
||||||
|
| Geometría / Importar | `import_offsets`, `import_dxf` |
|
||||||
|
| Geometría / Exportar | `export_iges`, `export_step`, `export_dxf` |
|
||||||
|
| Geometría / Suavizado | `smooth`, `combs`, `fairness` |
|
||||||
|
| Análisis / Hidrostática | `hydro_calc`, `hydro_curves`, `export_csv` |
|
||||||
|
| Análisis / Estabilidad | `gz_curve`, `imo`, `damage` |
|
||||||
|
| Análisis / Resistencia | `holtrop`, `savitsky`, `vpp` |
|
||||||
|
| Análisis / Seakeeping | `stf`, `spectrum` |
|
||||||
|
| Análisis / Estructura | `iso12215` |
|
||||||
|
| Tanques | `new_tank`, `model_tank`, `load_case`, `sounding`, `calc_kg` |
|
||||||
|
| Sistemas / Eléctrico | `epla` |
|
||||||
|
| Sistemas / Fluidos | `fuel`, `freshwater`, `bilge`, `firefight` |
|
||||||
|
| Sistemas / Routing 3D | `pipes`, `cables` |
|
||||||
|
| Sistemas / Clima | `hvac`, `steering` |
|
||||||
|
| Fabricación / CNC | `materials`, `nesting`, `gcode`, `postproc` |
|
||||||
|
| Fabricación / Moldes FRP | `lofting`, `laminate`, `resin`, `bom` |
|
||||||
|
|
||||||
|
### Decisiones
|
||||||
|
|
||||||
|
- Se mantienen los iconos estándar del sistema para: Nuevo, Abrir, Guardar (Archivo),
|
||||||
|
Deshacer/Rehacer (flechas del sistema), Offsets (vista de lista).
|
||||||
|
- El módulo NO importa Qt en el nivel de módulo — los QIcon solo se crean cuando se llaman,
|
||||||
|
así la importación de `icons.py` es segura antes de que exista `QApplication`.
|
||||||
|
|
||||||
|
### Corrección — Rediseño v2 (2026-05-30 sesión 2)
|
||||||
|
|
||||||
|
**Problema detectado:** La primera versión usaba trazos claros (`#c8d8e8`) sobre fondo
|
||||||
|
transparente. El ribbon de PySide6 tiene fondo blanco → los iconos eran prácticamente
|
||||||
|
invisibles (se veía solo el borde del botón).
|
||||||
|
|
||||||
|
**Solución:** Rediseño completo con estilo "flat icon":
|
||||||
|
- Relleno sólido de color por categoría + contorno oscuro `#1a2535`
|
||||||
|
- Visible en fondos claros Y oscuros
|
||||||
|
- Colores por categoría:
|
||||||
|
|
||||||
|
| Categoría | Color |
|
||||||
|
|-----------|-------|
|
||||||
|
| Geometría / Casco | Azul océano `#2a7fc8` |
|
||||||
|
| Edición NURBS | Índigo `#5548d0` |
|
||||||
|
| Suavizado | Verde vivo `#20a860` |
|
||||||
|
| Peines | Púrpura `#7040c8` + verde mint |
|
||||||
|
| Fairness | Gradiente rojo→verde |
|
||||||
|
| Análisis hidro | Teal `#1898a8` |
|
||||||
|
| Estabilidad | Azul `#2068c0` |
|
||||||
|
| Resistencia | Naranja `#d07020` |
|
||||||
|
| Tanques | Cyan `#18a0c0` |
|
||||||
|
| Sistemas eléctrico | Amarillo sobre negro |
|
||||||
|
| Fabricación | Violeta `#8838b8` |
|
||||||
|
|
||||||
|
**Estado tras rediseño:** 🔧 Pendiente verificar visualmente (requiere `python main.py`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 12 — Peines de Curvatura Mejorados
|
||||||
|
|
||||||
|
**Estado:** 🔧 Implementado — pendiente verificación visual
|
||||||
|
|
||||||
|
### Cambios en `viewer_lines.py`
|
||||||
|
|
||||||
|
**Problema:** Los peines se dibujaban usando los ~10-20 puntos crudos de la tabla de
|
||||||
|
offsets. Resultado: pelos escasos, ángulos bruscos, spine anguloso.
|
||||||
|
|
||||||
|
**Solución:** Nueva función `_resample_curve_smooth(xs, ys, n=80)`:
|
||||||
|
- Parametriza la curva por longitud de arco acumulada
|
||||||
|
- Remuestrea a **80 puntos equidistantes** usando `scipy.interpolate.CubicSpline`
|
||||||
|
- Fallback a `np.interp` (lineal) si scipy no está disponible
|
||||||
|
- Llamada al inicio de `_draw_curvature_comb` antes de calcular κ
|
||||||
|
|
||||||
|
**Resultado esperado:** 80 pelos por curva en lugar de ~10-20, spine suave.
|
||||||
|
|
||||||
|
### Regla
|
||||||
|
No aumentar más de 80 muestras sin medir impacto en FPS — la función se llama en
|
||||||
|
cada `paintEvent` (puede ser frecuente al arrastrar nodos).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 13 — Visor 3D Colores Sólidos
|
||||||
|
|
||||||
|
**Estado:** 🔧 Implementado — pendiente verificación visual
|
||||||
|
|
||||||
|
### Cambios en `viewer_3d.py` — `_render_hull_mesh`
|
||||||
|
|
||||||
|
| Parámetro | Antes | Ahora | Por qué |
|
||||||
|
|-----------|-------|-------|---------|
|
||||||
|
| `smooth_shading` | `True` | `False` | Facetas planas = aspecto sólido, sin blur |
|
||||||
|
| `opacity` | `0.92` | `1.0` | Totalmente opaco = color pleno |
|
||||||
|
| `ambient` | (default ~0.2) | `0.40` | Reduce sombras duras, color más uniforme |
|
||||||
|
| `diffuse` | (default ~0.8) | `0.60` | Equilibrio iluminación |
|
||||||
|
| `specular` | (default ~0.1) | `0.05` | Sin brillos que difuminen |
|
||||||
|
| `color` | `#3a6080` | `#4a8ab0` | Tono más vivo y legible |
|
||||||
|
| `line_width` | `0.3` | `0.6` | Aristas más visibles al activar mallas |
|
||||||
|
|
||||||
|
### Nota
|
||||||
|
Si en el futuro se quiere smooth shading selectivo (solo en alta resolución),
|
||||||
|
usar `mesh.compute_normals()` primero y luego `smooth_shading=True`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo 14 — Fix Freeze Curva GZ (QThread)
|
||||||
|
|
||||||
|
**Estado:** 🔧 Implementado — pendiente verificación
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
|
||||||
|
`_on_show_stability` → `_compute_and_show_gz` → `compute_gz_wall_sided` →
|
||||||
|
`compute_upright` (integración hidrostática pesada) → **bloqueaba el UI thread de Qt**
|
||||||
|
indefinidamente ("se trabó el programa").
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
|
||||||
|
Nueva clase `_GZWorker(QObject)` con señales `finished` / `error`.
|
||||||
|
El cálculo se mueve a un `QThread`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[UI thread] botón → _compute_and_show_gz()
|
||||||
|
↓ lanza QThread
|
||||||
|
[Hilo GZ] _GZWorker.run()
|
||||||
|
↓ emite finished(gz_curve, imo_result)
|
||||||
|
[UI thread] _on_gz_done() → actualiza widget + statusBar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Guarda doble
|
||||||
|
|
||||||
|
Si el usuario hace clic dos veces seguidas, el segundo clic se ignora mientras el
|
||||||
|
hilo anterior sigue corriendo (`if self._gz_thread.isRunning(): return`).
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---------|--------|
|
||||||
|
| `main_window.py` | Import `QThread, QObject`; clase `_GZWorker`; `_compute_and_show_gz` refactorizado; nuevo slot `_on_gz_done` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap Global
|
||||||
|
|
||||||
|
| Prioridad | Feature | Módulo | Estado |
|
||||||
|
|-----------|---------|--------|--------|
|
||||||
|
| 🔴 Alta | Wizard de estaciones / LdA / buttocks | Geometría | 📋 |
|
||||||
|
| 🔴 Alta | Transom stern (popa espejo) | Geometría + 3D | 📋 |
|
||||||
|
| 🟡 Media | Verificar iconos ribbon visualmente | UI | 🔧 |
|
||||||
|
| 🟡 Media | Verificar peines densidad visual | Fairness | 🔧 |
|
||||||
|
| 🟡 Media | Verificar colores sólidos 3D visual | Visor 3D | 🔧 |
|
||||||
|
| 🟡 Media | Caras invertidas 3D + flip | Visor 3D | 📋 |
|
||||||
|
| 🟡 Media | Peines de curvatura en keel/sheer (Z) | Fairness | 📋 |
|
||||||
|
| 🟡 Media | Suavizado 2D (Laplaciano transversal) | Fairness | 📋 |
|
||||||
|
| 🟢 Baja | Tests de fairness automatizados | Tests | 📋 |
|
||||||
|
| 🟢 Baja | Exportar DXF / offsets CSV | Exportación | 📋 |
|
||||||
|
| 🟢 Baja | Importar offsets desde tabla manual | Importación | 📋 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests y Entorno
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ejecutar suite completa
|
||||||
|
cd "D:\Proyectos Software\AR-Shipdesign"
|
||||||
|
python -m pytest tests/ -x -q
|
||||||
|
|
||||||
|
# Lanzar la aplicación
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estado de tests:** 315/315 ✅ — Última verificación: 2026-05-30
|
||||||
|
⚠️ Tests no actualizados para GZWorker (QThread) — agregar en próxima sesión.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Última actualización: 2026-05-30 (sesión 2)*
|
||||||
|
*Mantener este archivo actualizado al final de cada sesión de trabajo.*
|
||||||
Reference in New Issue
Block a user