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
+322 -38
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,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
+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: