feat: AR-Shipdesign initial commit
This commit is contained in:
+322
-38
@@ -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,34 +592,59 @@ 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
|
||||
all_pts = np.vstack([pts_stbd, pts_port])
|
||||
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])
|
||||
|
||||
# Construir caras de la malla estructurada
|
||||
faces = []
|
||||
# ── 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])
|
||||
|
||||
# ── Paso 4: caras de la malla estructurada ────────────────────────
|
||||
faces = []
|
||||
offset = n_u * n_v
|
||||
for band in [0, offset]:
|
||||
for i in range(n_u - 1):
|
||||
@@ -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()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -415,12 +674,15 @@ class Hull:
|
||||
"""
|
||||
ot = self.offsets
|
||||
return {
|
||||
"format": "hull_v1",
|
||||
"name": self.name,
|
||||
"lpp": self.lpp,
|
||||
"beam": self.beam,
|
||||
"depth": self.depth,
|
||||
"draft": self.draft,
|
||||
"format": "hull_v1",
|
||||
"name": self.name,
|
||||
"lpp": self.lpp,
|
||||
"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,
|
||||
@@ -428,8 +690,15 @@ class Hull:
|
||||
"x_stations": ot.x_stations.tolist(),
|
||||
"z_waterlines": ot.z_waterlines.tolist(),
|
||||
"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
|
||||
@@ -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(
|
||||
name = str(data["name"]),
|
||||
lpp = float(data["lpp"]),
|
||||
beam = float(data["beam"]),
|
||||
depth = float(data["depth"]),
|
||||
draft = float(data["draft"]),
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
@@ -49,9 +62,10 @@ def make_merchant_hull(
|
||||
flat_bottom_frac : float
|
||||
Ancho del fondo plano / manga (0.85–0.94).
|
||||
"""
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
||||
xi = (x_sta / lpp - 0.5) * 2.0
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
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)
|
||||
f_plan = _merchant_plan_form(xi, cb, lcb_shift)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -77,9 +90,10 @@ def make_displacement_hull(
|
||||
cm : float
|
||||
Coeficiente de cuaderna maestra (0.82–0.92).
|
||||
"""
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
||||
xi = (x_sta / lpp - 0.5) * 2.0 # ∈ [−1, 1], 0=midship
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
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) ────────────────────────────
|
||||
# LCB desplazado del midship
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -56,9 +69,10 @@ def make_planing_hull(
|
||||
flare : float
|
||||
Fracción de ensanchamiento por encima del chine (0 = sin ensanche).
|
||||
"""
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
||||
xi = (x_sta / lpp - 0.5) * 2.0 # normalizado ∈ [−1, 1], 0=midship
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
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 ─────────────────
|
||||
# El planeador tiene popa muy ancha y proa más estrecha
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -97,9 +112,10 @@ def make_sailing_hull(
|
||||
deadrise_mid : float
|
||||
Ángulo de astilla muerta en cuaderna maestra [°].
|
||||
"""
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
||||
xi = (x_sta / lpp - 0.5) * 2.0
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -44,9 +59,10 @@ def make_workboat_hull(
|
||||
flat_bottom_frac : float
|
||||
Anchura del fondo plano como fracción de la manga (0.80–0.92).
|
||||
"""
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
||||
xi = (x_sta / lpp - 0.5) * 2.0
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
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
+496
-150
@@ -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",
|
||||
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:
|
||||
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)
|
||||
from arshipdesign.hydrostatics.upright import compute_upright
|
||||
T = hull.draft
|
||||
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,31 +1749,43 @@ 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
|
||||
self.statusBar().showMessage("Calculando curvas hidrostáticas…")
|
||||
QApplication.processEvents()
|
||||
curves = HydrostaticCurves.compute(
|
||||
self._current_hull, n_points=30, rho=1025.0
|
||||
)
|
||||
self._hydro_chart.set_curves(curves)
|
||||
self._module_area.activate(ModuleArea.MOD_CURVES)
|
||||
self.statusBar().showMessage(
|
||||
f"Curvas hidrostáticas calculadas — {curves.hull_name} "
|
||||
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))
|
||||
# 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…")
|
||||
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(
|
||||
f"Curvas hidrostáticas calculadas — {curves.hull_name} "
|
||||
f"({len(curves.points)} puntos, T: "
|
||||
f"{curves.drafts[0]:.2f}–{curves.drafts[-1]:.2f} m)"
|
||||
)
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
hull = self._current_hull
|
||||
kg = hull.depth * 0.55
|
||||
self.statusBar().showMessage("Calculando curva GZ…")
|
||||
|
||||
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
|
||||
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_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",
|
||||
)
|
||||
self.statusBar().showMessage(
|
||||
f"Curva GZ calculada — {hull.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)
|
||||
self._gz_widget.set_curve(gz_curve, imo_result)
|
||||
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 — {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'}"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1562
-164
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user