feat: AR-Shipdesign initial commit

This commit is contained in:
2026-07-03 12:23:25 -04:00
parent 588735ea64
commit 9a08526361
16 changed files with 4431 additions and 394 deletions
+26 -1
View File
@@ -9,7 +9,32 @@
"Bash(sort -t/ -k1)",
"Bash(xargs -I{} wc -l {})",
"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
View File
@@ -1,9 +1,9 @@
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
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
uso comercial sin autorización expresa y por escrito del titular.
+1 -1
View File
@@ -45,5 +45,5 @@ python main.py
## Licencia
Copyright © 2025 Álvaro Rodríguez. Todos los derechos reservados.
Copyright © 2025 Álvaro Romero. Todos los derechos reservados.
Ver `LICENSE.txt` para detalles.
+308 -24
View File
@@ -48,6 +48,28 @@ class Hull:
draft: float
offsets: OffsetsTable
_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
@@ -98,6 +120,149 @@ class Hull:
self._surface = self._build_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:
"""Invalida la caché de la superficie NURBS.
@@ -108,13 +273,81 @@ class Hull:
"""
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:
sections_data = []
u_arr = self.offsets.x_stations / self.lpp # normalizar a [0,1]
for i, u in enumerate(u_arr):
pts = np.column_stack([
self.offsets.data[i, :],
self.offsets.z_waterlines,
self.offsets.z_waterlines + self.offsets.z_offsets[i, :],
])
sections_data.append((float(u), pts))
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":
"""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.
"""
try:
@@ -357,33 +592,58 @@ class Hull:
except ImportError as exc:
raise ImportError("PyVista no está instalado") from exc
surf = self.surface
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)
from arshipdesign.geometry.nurbs_curve import BSplineCurve
# Evaluar (y, z) en la malla
y_mat = surf._spline_y(u_arr, v_arr) # (n_u, n_v)
z_mat = surf._spline_z(u_arr, v_arr) # (n_u, n_v)
ot = self.offsets
sheer = self.get_sheer_z()
# x real desde parámetro u
x_mat = uu * self.lpp
# ── Paso 1: perfiles (y, z) a las estaciones originales ──────────
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)
pts_stbd = np.stack([
x_mat.ravel(), y_mat.ravel(), z_mat.ravel()
], axis=1)
n_sta = ot.n_stations
profiles = np.array([_section_yz(i, n_v) for i in range(n_sta)])
# profiles shape: (n_sta, n_v, 2) — (y, z) at each station
# Banda de babor (y < 0)
pts_port = np.stack([
x_mat.ravel(), -y_mat.ravel(), z_mat.ravel()
], axis=1)
# ── Paso 2: interpolar a n_u estaciones uniformes ────────────────
x_orig = ot.x_stations / self.lpp # normalized [0,1]
x_new = np.linspace(0.0, 1.0, n_u)
# 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])
# Construir caras de la malla estructurada
# ── Paso 4: caras de la malla estructurada ────────────────────────
faces = []
offset = n_u * n_v
for band in [0, offset]:
@@ -395,8 +655,7 @@ class Hull:
p3 = band + i * n_v + (j + 1)
faces.extend([4, p0, p1, p2, p3])
faces_arr = np.array(faces, dtype=int)
mesh = pv.PolyData(all_pts, faces_arr)
mesh = pv.PolyData(all_pts, np.array(faces, dtype=int))
return mesh.triangulate()
# ------------------------------------------------------------------
@@ -421,6 +680,9 @@ class Hull:
"beam": self.beam,
"depth": self.depth,
"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": {
"lpp": ot.lpp,
"beam": ot.beam,
@@ -429,7 +691,14 @@ class Hull:
"z_waterlines": ot.z_waterlines.tolist(),
"station_labels": list(ot.station_labels),
"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
@@ -459,15 +728,30 @@ class Hull:
lpp = float(od["lpp"]),
beam = float(od["beam"]),
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"]),
lpp = float(data["lpp"]),
beam = float(data["beam"]),
depth = float(data["depth"]),
draft = float(data["draft"]),
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
+32 -6
View File
@@ -47,11 +47,27 @@ class OffsetsTable:
lpp: float = 0.0
beam: 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:
self.x_stations = np.asarray(self.x_stations, dtype=float)
self.z_waterlines = np.asarray(self.z_waterlines, 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_wl = len(self.z_waterlines)
@@ -61,6 +77,15 @@ class OffsetsTable:
)
if not self.station_labels:
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
@@ -127,7 +152,7 @@ class OffsetsTable:
station=sta,
x=float(x),
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",
)
sections.append(sec)
@@ -139,13 +164,14 @@ class OffsetsTable:
def half_breadth(self, x: float, z: float) -> float:
"""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([
float(np.interp(x, self.x_stations, self.data[:, j]))
for j in range(len(self.z_waterlines))
float(np.interp(z, self.z_waterlines + self.z_offsets[i, :], self.data[i, :]))
for i in range(len(self.x_stations))
])
# Interpolar en z
return float(np.interp(z, self.z_waterlines, col_y))
# Interpolar el resultado en x
return float(np.interp(x, self.x_stations, col_y))
@property
def n_stations(self) -> int:
+17 -2
View File
@@ -22,6 +22,19 @@ from arshipdesign.core.hull import Hull
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(
name: str = "Buque Mercante / Supply",
lpp: float = 20.0,
@@ -50,7 +63,8 @@ def make_merchant_hull(
Ancho del fondo plano / manga (0.850.94).
"""
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
lcb_shift = 2.0 * (lcb_frac - 0.5)
@@ -91,7 +105,8 @@ def make_merchant_hull(
lpp=lpp, beam=beam, draft=draft,
)
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,
)
+17 -2
View File
@@ -23,6 +23,19 @@ from arshipdesign.core.hull import Hull
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
# ---------------------------------------------------------------------------
@@ -78,7 +91,8 @@ def make_displacement_hull(
Coeficiente de cuaderna maestra (0.820.92).
"""
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
# ── Plan form (semi-manga en flotación) ────────────────────────────
@@ -111,7 +125,8 @@ def make_displacement_hull(
lpp=lpp, beam=beam, draft=draft,
)
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,
)
+17 -2
View File
@@ -23,6 +23,19 @@ from arshipdesign.core.hull import Hull
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
# ---------------------------------------------------------------------------
@@ -57,7 +70,8 @@ def make_planing_hull(
Fracción de ensanchamiento por encima del chine (0 = sin ensanche).
"""
x_sta = np.linspace(0.0, lpp, n_stations)
z_wl = np.linspace(0.0, draft, n_waterlines)
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
# ── 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,
)
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,
)
+19 -2
View File
@@ -24,6 +24,19 @@ from arshipdesign.core.hull import Hull
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)
# ---------------------------------------------------------------------------
@@ -45,6 +58,8 @@ def _sailing_section(
"""
if y_wl < 1e-9 or T < 1e-9:
return 0.0
if z >= T:
return y_wl # plumb topside above design waterline
t_full = min(1.0, max(0.0, z / T))
if t_full < 1e-12:
return 0.0
@@ -98,7 +113,8 @@ def make_sailing_hull(
Ángulo de astilla muerta en cuaderna maestra [°].
"""
x_sta = np.linspace(0.0, lpp, n_stations)
z_wl = np.linspace(0.0, draft, n_waterlines)
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
lcb_shift = 2.0 * (lcb_frac - 0.5)
@@ -131,7 +147,8 @@ def make_sailing_hull(
lpp=lpp, beam=beam, draft=draft,
)
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,
)
+19 -2
View File
@@ -22,6 +22,21 @@ from arshipdesign.core.hull import Hull
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(
name: str = "Workboat / Supply",
lpp: float = 15.0,
@@ -45,7 +60,8 @@ def make_workboat_hull(
Anchura del fondo plano como fracción de la manga (0.800.92).
"""
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
lcb_shift = 2.0 * (lcb_frac - 0.5)
@@ -91,7 +107,8 @@ def make_workboat_hull(
lpp=lpp, beam=beam, draft=draft,
)
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
View File
@@ -21,7 +21,7 @@ import json
from pathlib import Path
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.QtWidgets import (
QApplication,
@@ -37,6 +37,7 @@ from PySide6.QtWidgets import (
QSplitter,
QStackedWidget,
QToolBar,
QPushButton,
QToolButton,
QVBoxLayout,
QWidget,
@@ -46,6 +47,7 @@ from PySide6.QtWidgets import (
from arshipdesign import __version__
from arshipdesign.core.project import Project
from arshipdesign.ui.icons import icon as _ico
from arshipdesign.utils.logger import get_logger
from arshipdesign.stability import compute_gz_wall_sided, GZCurve, check_imo_is2008
from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget
@@ -184,12 +186,15 @@ _VIEW_LABELS: dict[str, str] = {
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:
super().__init__(parent)
self.view_type = view_type
self.setObjectName("viewportFrame")
self._maximized = False
self._build_ui()
def _build_ui(self) -> None:
@@ -197,19 +202,30 @@ class ViewportFrame(QFrame):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── Barra de título (objectName único por vista) ──────────
# ── Barra de título ───────────────────────────────────────
title_bar = QWidget()
# p.ej. "viewportTitleBar_perspective", "viewportTitleBar_profile"…
title_bar.setObjectName(f"viewportTitleBar_{self.view_type}")
title_bar.setFixedHeight(24)
title_bar.setCursor(Qt.CursorShape.PointingHandCursor)
tbl = QHBoxLayout(title_bar)
tbl.setContentsMargins(10, 0, 4, 0)
tbl.setSpacing(0)
lbl = QLabel(_VIEW_LABELS.get(self.view_type, self.view_type).upper())
lbl.setObjectName(f"viewportTitle_{self.view_type}")
tbl.addWidget(lbl)
self._title_lbl = QLabel(_VIEW_LABELS.get(self.view_type, self.view_type).upper())
self._title_lbl.setObjectName(f"viewportTitle_{self.view_type}")
tbl.addWidget(self._title_lbl)
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)
# ── Área de dibujo (placeholder Sprint 0) ────────────────
@@ -224,6 +240,14 @@ class ViewportFrame(QFrame):
cl.addWidget(ph)
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:
"""Sprint 1: sustituye el placeholder por el widget 3D / 2D real."""
lo = self.layout()
@@ -249,6 +273,7 @@ class FourViewport(QWidget):
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setObjectName("fourViewport")
self._maximized_view: Optional[str] = None
self._build_ui()
def _build_ui(self) -> None:
@@ -256,34 +281,39 @@ class FourViewport(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
v_split = QSplitter(Qt.Orientation.Vertical)
v_split.setObjectName("viewportSplitter")
v_split.setHandleWidth(5)
self._v_split = QSplitter(Qt.Orientation.Vertical)
self._v_split.setObjectName("viewportSplitter")
self._v_split.setHandleWidth(5)
top_split = QSplitter(Qt.Orientation.Horizontal)
top_split.setObjectName("viewportSplitter")
top_split.setHandleWidth(5)
self._top_split = QSplitter(Qt.Orientation.Horizontal)
self._top_split.setObjectName("viewportSplitter")
self._top_split.setHandleWidth(5)
self._vp_perspective = ViewportFrame("perspective")
self._vp_profile = ViewportFrame("profile")
top_split.addWidget(self._vp_perspective)
top_split.addWidget(self._vp_profile)
top_split.setSizes([600, 600])
self._top_split.addWidget(self._vp_perspective)
self._top_split.addWidget(self._vp_profile)
self._top_split.setSizes([600, 600])
bot_split = QSplitter(Qt.Orientation.Horizontal)
bot_split.setObjectName("viewportSplitter")
bot_split.setHandleWidth(5)
self._bot_split = QSplitter(Qt.Orientation.Horizontal)
self._bot_split.setObjectName("viewportSplitter")
self._bot_split.setHandleWidth(5)
self._vp_bodyplan = ViewportFrame("bodyplan")
self._vp_plan = ViewportFrame("plan")
bot_split.addWidget(self._vp_bodyplan)
bot_split.addWidget(self._vp_plan)
bot_split.setSizes([600, 600])
self._bot_split.addWidget(self._vp_bodyplan)
self._bot_split.addWidget(self._vp_plan)
self._bot_split.setSizes([600, 600])
v_split.addWidget(top_split)
v_split.addWidget(bot_split)
v_split.setSizes([400, 400])
layout.addWidget(v_split)
self._v_split.addWidget(self._top_split)
self._v_split.addWidget(self._bot_split)
self._v_split.setSizes([400, 400])
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 {
"perspective": self._vp_perspective,
"profile": self._vp_profile,
@@ -291,6 +321,59 @@ class FourViewport(QWidget):
"plan": self._vp_plan,
}.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)
@@ -792,6 +875,54 @@ class RibbonBar(QWidget):
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
# ─────────────────────────────────────────────────────────────────────────────
@@ -816,6 +947,15 @@ class MainWindow(QMainWindow):
self._project: Optional[Project] = None
self._current_hull = None # Hull activo en todos los visores
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._strings = _load_i18n(self._lang)
self._setup_ui()
@@ -866,10 +1006,17 @@ class MainWindow(QMainWindow):
# Edición live durante drag → actualizar vistas cruzadas sin resetear zoom
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)
# Fin del drag → persistir + actualizar 3D + hidrostáticos
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)
# 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)
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 = 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))
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))
g.add_button(_spi(sp.SP_FileDialogListView), "Offsets", "Tabla de Offsets F4",
lambda: self._module_area.activate(M.MOD_OFFSETS))
# ── Tab GEOMETRÍA ─────────────────────────────────────────
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(_spi(sp.SP_FileIcon), "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("wizard"), "Asistente", "Asistente de embarcación", enabled=False)
g.add_button(_ico("hull_nurbs"), "Casco NURBS", "Nuevo casco NURBS", 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.add_button(_spi(sp.SP_FileDialogDetailedView), "Pts. Ctrl.", "Editar puntos de control", enabled=False)
g.add_button(_spi(sp.SP_FileDialogDetailedView), "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(_spi(sp.SP_FileDialogDetailedView), "Lackenby", "Transformación de Lackenby", enabled=False)
g.add_button(_ico("ctrl_pts"), "Pts. Ctrl.", "Editar puntos de control", enabled=False)
g.add_button(_ico("extrude"), "Extruir", "Extruir sección", enabled=False)
g.add_button(_ico("mirror"), "Espejo", "Espejo por eje de crujía", enabled=False)
g.add_button(_ico("lackenby"), "Lackenby", "Transformación de Lackenby", enabled=False)
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(_spi(sp.SP_DirOpenIcon), "DXF", "Importar plano DXF", enabled=False)
g.add_button(_ico("import_offsets"), "Offsets", "Importar tabla de offsets (.txt / .csv)", enabled=False)
g.add_button(_ico("import_dxf"), "DXF", "Importar plano DXF", enabled=False)
g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Exportar")
g.add_button(_spi(sp.SP_DialogSaveButton), "IGES", "Exportar IGES", enabled=False)
g.add_button(_spi(sp.SP_DialogSaveButton), "STEP", "Exportar STEP", enabled=False)
g.add_button(_spi(sp.SP_DialogSaveButton), "DXF", "Exportar DXF", enabled=False)
g.add_button(_ico("export_iges"), "IGES", "Exportar IGES", enabled=False)
g.add_button(_ico("export_step"), "STEP", "Exportar STEP", 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 ──────────────────────────────────────────
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)
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)
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)
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)
g.add_button(_spi(sp.SP_FileDialogDetailedView), "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("imo"), "IMO IS2008", "Criterios IMO IS Code 2008", 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.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)
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Savitsky", "Savitsky (planeo)", enabled=False)
g.add_button(_spi(sp.SP_FileDialogDetailedView), "VPP", "VPP Velero / DSYHS",
g.add_button(_ico("savitsky"), "Savitsky", "Savitsky (planeo)", enabled=False)
g.add_button(_ico("vpp"), "VPP", "VPP Velero / DSYHS",
lambda: self._module_area.activate(M.MOD_VPP), False)
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)
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.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)
# ── Tab 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)
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.add_button(_spi(sp.SP_FileDialogDetailedView), "Nuevo caso", "Definir caso de carga", enabled=False)
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Sondeos", "Tablas de sondeo",
g.add_button(_ico("load_case"), "Nuevo caso", "Definir caso de carga", enabled=False)
g.add_button(_ico("sounding"), "Sondeos", "Tablas de sondeo",
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 ──────────────────────────────────────────
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)
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)
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)
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)
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)
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)
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)
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)
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 ───────────────────────────────────────
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(_spi(sp.SP_FileDialogContentsView), "Nesting", "Optimización de cortes (nesting)",
g.add_button(_ico("materials"), "Materiales", "Estimación de materiales", enabled=False)
g.add_button(_ico("nesting"), "Nesting", "Optimización de cortes (nesting)",
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(_spi(sp.SP_FileDialogContentsView), "Post-Proc.", "Configurar post-procesador CNC", enabled=False)
g.add_button(_ico("gcode"), "G-code", "Generar G-code", 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.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)
g.add_button(_spi(sp.SP_FileDialogContentsView), "Laminado", "Schedule de laminado", enabled=False)
g.add_button(_spi(sp.SP_FileDialogContentsView), "Resina", "Calculadora de resina", enabled=False)
g.add_button(_spi(sp.SP_FileDialogContentsView), "BOM", "BOM de materiales", enabled=False)
g.add_button(_ico("laminate"), "Laminado", "Schedule de laminado", enabled=False)
g.add_button(_ico("resin"), "Resina", "Calculadora de resina", enabled=False)
g.add_button(_ico("bom"), "BOM", "BOM de materiales", enabled=False)
def _setup_menu(self) -> None:
mb = self.menuBar()
@@ -1082,8 +1243,10 @@ class MainWindow(QMainWindow):
# ── EDITAR ─────────────────────────────────────────────────
m = mb.addMenu("Editar")
self._act_undo = self._add_action(m, "Deshacer", QKeySequence.StandardKey.Undo, enabled=False)
self._act_redo = self._add_action(m, "Rehacer", QKeySequence.StandardKey.Redo, enabled=False)
self._act_undo = self._add_action(m, "Deshacer", QKeySequence.StandardKey.Undo,
slot=self._undo, enabled=False)
self._act_redo = self._add_action(m, "Rehacer", QKeySequence.StandardKey.Redo,
slot=self._redo, enabled=False)
m.addSeparator()
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._on_project_loaded()
if hull is not None:
hull.snap_boundary_nodes_to_contours()
self._current_hull = hull
self._project.set_hull(hull) # persistir en ship_data
self._load_hull_viewers(hull)
self._reset_undo_history(hull)
self.statusBar().showMessage(
f"Nuevo proyecto: {self._project.name}"
)
@@ -1273,7 +1438,9 @@ class MainWindow(QMainWindow):
self._on_project_loaded()
self.statusBar().showMessage(f"Abierto: {path}")
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:
if not self._project:
@@ -1286,7 +1453,9 @@ class MainWindow(QMainWindow):
self._update_title()
self.statusBar().showMessage(f"Guardado: {self._project.path}")
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:
if not self._project:
@@ -1305,7 +1474,9 @@ class MainWindow(QMainWindow):
self._update_title()
self.statusBar().showMessage(f"Guardado como: {path}")
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:
self._update_title()
@@ -1315,6 +1486,7 @@ class MainWindow(QMainWindow):
if hull is not None:
self._current_hull = hull
self._load_hull_viewers(hull)
self._reset_undo_history(hull)
logger.info("Hull '%s' restaurado desde proyecto", hull.name)
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
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_profile.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 ─────────────────────────────────────
if not _skip_offsets_editor:
self._offsets_editor.set_hull(hull)
@@ -1376,7 +1549,14 @@ class MainWindow(QMainWindow):
hull = self._current_hull
if hull is None:
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
# offsets editados y no devuelva la geometría anterior.
hull.invalidate()
@@ -1396,38 +1576,170 @@ class MainWindow(QMainWindow):
logger.warning("Error al actualizar visor 3D: %s", exc)
# Barra de hidrostáticos
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}")
# ── 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:
"""Calcula hidrostáticos al calado de diseño y actualiza la barra inferior.
Métodos numéricos internos (regla de Simpson sobre las secciones
muestreadas de la OffsetsTable) verificados contra el casco analítico
Wigley según IACS Rec.34 §4.3.
Usa compute_upright en una sola pasada (una sola integración) en lugar
de llamar a cada método del Hull por separado (que haría 9× to_sections).
"""
try:
from arshipdesign.hydrostatics.upright import compute_upright
T = hull.draft
delta = hull.displacement_tonnes(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)
h = compute_upright(hull, T)
self._hydro.update_values({
"T": f"{T:.2f}",
"Δ": f"{delta:.1f} t",
"LCB": f"{lcb_v:.2f}",
"KB": f"{kb:.2f}",
"KMT": f"{kmt:.2f}",
"GMT": "", # requiere KG del caso de carga
"TPC": f"{tpc:.3f}",
"MCT": f"{mct:.2f}",
"Cb": f"{cb:.3f}",
"Cw": f"{cw:.3f}",
"Cm": f"{cm:.3f}",
"Δ": f"{h.displacement:.1f} t",
"LCB": f"{h.lcb:.2f}",
"KB": f"{h.kb:.2f}",
"KMT": f"{h.kmt:.2f}",
"GMT": "",
"TPC": f"{h.tpc:.3f}",
"MCT": f"{h.mct:.2f}",
"Cb": f"{h.cb:.3f}",
"Cw": f"{h.cw:.3f}",
"Cm": f"{h.cm:.3f}",
})
except Exception as exc:
logger.warning("Error al calcular hidrostáticos: %s", exc)
@@ -1437,20 +1749,36 @@ class MainWindow(QMainWindow):
# ─────────────────────────────────────────────────────────
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:
from PySide6.QtWidgets import QMessageBox
QMessageBox.information(
self, "Sin casco", "Crea o abre un proyecto con un casco definido."
)
return
try:
from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves
# Evitar dos cálculos simultáneos
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…")
QApplication.processEvents()
curves = HydrostaticCurves.compute(
self._current_hull, n_points=30, rho=1025.0
)
worker = _HydroWorker(self._current_hull, n_points=30, rho=1025.0)
thread = QThread(self)
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._module_area.activate(ModuleArea.MOD_CURVES)
self.statusBar().showMessage(
@@ -1458,10 +1786,6 @@ class MainWindow(QMainWindow):
f"({len(curves.points)} puntos, T: "
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:
"""Muestra el módulo de curvas (sin recalcular si ya hay datos)."""
@@ -1494,39 +1818,59 @@ class MainWindow(QMainWindow):
except Exception as exc:
logger.error("Error al exportar CSV: %s", exc)
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
# ─────────────────────────────────────────────────────────
def _compute_and_show_gz(self) -> None:
"""Calcula la curva GZ wall-sided y actualiza el widget de estabilidad."""
if self._current_hull is None:
"""Lanza el cálculo GZ en un hilo separado para no bloquear la UI."""
if self._current_hull is None or self._gz_widget is None:
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
try:
hull = self._current_hull
kg = hull.depth * 0.55
self.statusBar().showMessage("Calculando curva GZ…")
QApplication.processEvents()
gz_curve = compute_gz_wall_sided(hull, hull.draft, kg=kg)
imo_result = check_imo_is2008(gz_curve)
self._gz_worker = _GZWorker(hull, hull.draft, kg)
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)
# Actualizar indicador IMO en la barra de hidrostáticos
self._hydro.set_imo_status(
imo_result.overall_passed,
"" if imo_result.overall_passed else "GZ",
)
hull = self._current_hull
name = hull.name if hull else ""
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"AVS={gz_curve.avs:.0f}° "
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:
"""Muestra el módulo de estabilidad GZ (calcula si hay casco disponible)."""
@@ -1607,7 +1951,9 @@ class MainWindow(QMainWindow):
add_recent_file(path)
self._on_project_loaded()
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
+6 -2
View File
@@ -370,7 +370,9 @@ class OffsetsEditor(QWidget):
Path(path).write_text(buf.getvalue(), encoding="utf-8")
logger.info("Offsets exportados a %s", path)
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:
path, _ = QFileDialog.getOpenFileName(
@@ -424,7 +426,9 @@ class OffsetsEditor(QWidget):
self.hull_changed.emit(new_hull)
logger.info("Offsets importados desde %s", path)
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
+50 -10
View File
@@ -18,7 +18,7 @@ from typing import Optional
import numpy as np
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")
@@ -57,6 +57,9 @@ class Viewer3DWidget(QWidget):
self._plotter: Optional["QtInteractor"] = None
self._ready = False
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()
# ------------------------------------------------------------------
@@ -96,7 +99,26 @@ class Viewer3DWidget(QWidget):
old.hide()
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(
parent=self,
auto_update=False, # sin polling continuo de GPU
@@ -192,15 +214,22 @@ class Viewer3DWidget(QWidget):
return
self._plotter.clear()
# Casco principal — color acero naval
self._plotter.add_mesh(
# Casco principal — color sólido estilo DelftShip
# 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,
color="#3a6080",
smooth_shading=True,
show_edges=True,
edge_color="#4da8ff",
line_width=0.3,
opacity=0.92,
color="#4a8ab0", # azul acero más vivo
smooth_shading=False, # facetado / sólido (no blur)
show_edges=self._show_edges,
edge_color="#90c8f0",
line_width=0.6,
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",
)
@@ -226,6 +255,17 @@ class Viewer3DWidget(QWidget):
self._plotter.view_isometric()
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]
"""Libera el contexto PyVista al cerrar."""
if self._plotter is not None:
File diff suppressed because it is too large Load Diff
+549
View File
@@ -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.*