Fix lines-plan coordinate system: Y-inversion + add buttock lines

Root cause: base _w2s mapped world-Z directly to screen-Y (which
increases downward), placing the keel at the TOP of every viewer.

BodyPlanViewer:
- Override _w2s/_s2w/_fit_to_view to invert Y axis (keel now at bottom)
- Fix section drawing: remove keel-appended B-spline closure that
  distorted the curve; close section with a straight lineTo(0,0) instead

ProfileViewer:
- Same Y-inversion overrides as BodyPlanViewer
- Add _compute_buttock_pts() helper (interpolates Z at constant Y_b
  across all stations) and draw 3 buttock lines (B/4, B/2, 3B/4)
  as smooth curves in the profile view

Buttocks, stations and waterlines now appear in their correct views:
  Body Plan   → station cross-section curves, keel at bottom
  Profile     → buttock curves (vertical long. sections), keel at bottom
  Plan View   → waterline contours (unchanged)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:04:27 -04:00
parent 58228080e8
commit 0cf7df2763
+163 -14
View File
@@ -365,6 +365,46 @@ def _draw_cnet_planview(p: QPainter, ot, w2s_fn) -> None:
p.drawPath(path)
def _compute_buttock_pts(
ot, y_b: float
) -> list[tuple[float, float]]:
"""Calcula los puntos (x_est, z) de una línea de pantoque (buttock) a semi-manga y_b.
Una línea de pantoque es la intersección del casco con el plano vertical
paralelo al plano de crujía a distancia y_b del eje (Y = cte).
Para cada estación i, se busca la altura z a la que la semi-manga del
casco iguala y_b, interpolando linealmente entre líneas de agua adyacentes.
El resultado se dibuja en la vista de perfil como una curva (x, z).
Parámetros
----------
ot : OffsetsTable
y_b : float
Semi-manga de la pantoque [m].
Retorna
-------
Lista de tuplas (x_station, z_interp) ordenadas de AP a FP.
"""
pts: list[tuple[float, float]] = []
for i in range(ot.n_stations):
hb = ot.data[i, :] # semi-mangas en cada LdA para estación i
zz = ot.z_waterlines
if y_b > float(hb.max()):
continue # la pantoque no alcanza esta estación
# Buscar primer cruce ascendente (quilla → cubierta)
for j in range(len(hb) - 1):
h0, h1 = float(hb[j]), float(hb[j + 1])
if h0 <= y_b <= h1:
dh = h1 - h0
t = (y_b - h0) / dh if abs(dh) > 1e-9 else 0.0
z_interp = float(zz[j]) + t * (float(zz[j + 1]) - float(zz[j]))
pts.append((float(ot.x_stations[i]), z_interp))
break
return pts
def _smooth_pts(pts_2d: np.ndarray, n: int = 60) -> np.ndarray:
"""Muestrea n puntos de una B-spline interpolada a través de pts_2d.
@@ -391,14 +431,51 @@ def _smooth_pts(pts_2d: np.ndarray, n: int = 60) -> np.ndarray:
class BodyPlanViewer(_BaseViewer):
"""Vista de cuadernas (body plan).
Espacio de mundo: x = semi-manga [m] (derecha +), y = z altura [m] (arriba +).
Mitad de proa → estribor (derecha, verde).
Mitad de popa → babor (izquierda, azul).
Espacio de mundo: x = semi-manga [m] (CL=0, estribor +, babor ),
y = altura sobre quilla [m] (Z, positivo arriba).
Convención: estaciones de proa (i ≥ n//2) en semiplano derecho (verde),
estaciones de popa (i < n//2) en semiplano izquierdo (azul).
Edición: arrastra cualquier punto de control (y[i][j], z[j]) en x para
cambiar la semi-manga en esa estación y línea de agua.
La pantalla Qt tiene Y creciente hacia abajo. Para que la quilla quede
abajo y la cubierta arriba se invierte el eje Y en _w2s/_s2w/_fit_to_view.
"""
# ── Inversión del eje Y: quilla abajo, cubierta arriba ───────────────
def _w2s(self, wx: float, wy: float) -> QPointF:
"""Mundo → pantalla con Y invertido (Z=0 queda en el borde inferior)."""
return QPointF(
wx * self._scale + self._offset.x(),
-wy * self._scale + self._offset.y(), # negado
)
def _s2w(self, sx: float, sy: float) -> tuple[float, float]:
return (
(sx - self._offset.x()) / self._scale,
-(sy - self._offset.y()) / self._scale, # negado
)
def _fit_to_view(self) -> None:
if self._hull is None:
return
bbox = self._world_bbox()
if bbox is None:
return
wx0, wy0, wx1, wy1 = bbox
ww, wh = wx1 - wx0, wy1 - wy0
if ww < 1e-6 or wh < 1e-6:
return
pw, ph = max(self.width(), 100), max(self.height(), 100)
margin = 0.08
self._scale = min(
pw * (1 - margin * 2) / ww,
ph * (1 - margin * 2) / wh,
)
cx = pw / 2 - (wx0 + ww / 2) * self._scale
# Con Y invertido: centro_mundo_y → centro_pantalla requiere + en vez de
cy = ph / 2 + (wy0 + wh / 2) * self._scale
self._offset = QPointF(cx, cy)
def _world_bbox(self) -> Optional[tuple]:
if self._hull is None:
return None
@@ -494,12 +571,11 @@ class BodyPlanViewer(_BaseViewer):
z_arr = ot.z_waterlines
sign = 1.0 if is_fwd else -1.0
# Smooth B-spline through the section data points
# B-spline suave desde la quilla hasta la cubierta.
# NO se cierra la spline con (0,0) ya que eso distorsiona la curva;
# en su lugar se añade un segmento recto de cierre a la quilla.
raw = np.column_stack([y_arr * sign, z_arr])
# Close to keel: append (0, 0) as the last interpolation point
keel_row = np.array([[0.0, 0.0]])
raw_closed = np.vstack([raw, keel_row])
smooth = _smooth_pts(raw_closed, n=80)
smooth = _smooth_pts(raw, n=80)
path = QPainterPath()
for k_pt in range(len(smooth)):
@@ -508,6 +584,8 @@ class BodyPlanViewer(_BaseViewer):
path.moveTo(pt)
else:
path.lineTo(pt)
# Cierre recto al punto de quilla en crujía (0, 0)
path.lineTo(self._w2s(0.0, 0.0))
p.drawPath(path)
# Flotación de diseño (encima de todo lo anterior)
@@ -545,13 +623,57 @@ class BodyPlanViewer(_BaseViewer):
# ─────────────────────────────────────────────────────────────────────────────
class ProfileViewer(_BaseViewer):
"""Vista lateral del casco (perfil).
"""Vista lateral del casco (perfil / sheer plan).
Mundo: x = posición longitudinal [m] (AP izquierda), y = z altura [m].
Muestra líneas de agua, perfil de cubierta y quilla.
No es editable (las z son constantes en la OffsetsTable).
Mundo: x = posición longitudinal [m] (AP izquierda, FP derecha),
y = altura sobre quilla [m] (Z, positivo arriba).
Muestra:
• Líneas de pantoque (buttocks): secciones verticales a Y = cte,
curvas longitudinales que revelan la forma del casco en perfil.
• Líneas de agua de referencia (horizontales).
• Línea de cubierta (sheer line).
• Línea de quilla.
• Marcas de estación (verticales).
Vista de sólo lectura (no editable directamente).
Igual que en BodyPlanViewer, se invierte el eje Y.
"""
# ── Inversión del eje Y: quilla abajo, cubierta arriba ───────────────
def _w2s(self, wx: float, wy: float) -> QPointF:
return QPointF(
wx * self._scale + self._offset.x(),
-wy * self._scale + self._offset.y(),
)
def _s2w(self, sx: float, sy: float) -> tuple[float, float]:
return (
(sx - self._offset.x()) / self._scale,
-(sy - self._offset.y()) / self._scale,
)
def _fit_to_view(self) -> None:
if self._hull is None:
return
bbox = self._world_bbox()
if bbox is None:
return
wx0, wy0, wx1, wy1 = bbox
ww, wh = wx1 - wx0, wy1 - wy0
if ww < 1e-6 or wh < 1e-6:
return
pw, ph = max(self.width(), 100), max(self.height(), 100)
margin = 0.08
self._scale = min(
pw * (1 - margin * 2) / ww,
ph * (1 - margin * 2) / wh,
)
cx = pw / 2 - (wx0 + ww / 2) * self._scale
cy = ph / 2 + (wy0 + wh / 2) * self._scale
self._offset = QPointF(cx, cy)
def _world_bbox(self) -> Optional[tuple]:
if self._hull is None:
return None
@@ -608,6 +730,33 @@ class ProfileViewer(_BaseViewer):
p.setPen(QPen(_KEEL, 2.0))
p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 0))
# ── Líneas de pantoque (buttock lines) ────────────────────────
# Planos verticales a Y = B/4, B/2, 3B/4 del eje de crujía.
# Cada pantoque es una curva (x, z) que muestra el run del casco
# a una distancia lateral constante del plano de crujía.
y_max = ot.max_half_breadth
_N_BUTT = 3
for b_idx in range(1, _N_BUTT + 1):
y_b = y_max * b_idx / (_N_BUTT + 1)
pts = _compute_buttock_pts(ot, y_b)
if len(pts) < 2:
continue
arr = np.array(pts, dtype=float)
smooth = _smooth_pts(arr, n=80)
frac = b_idx / _N_BUTT
col = QColor(_WATERLINE)
col.setAlphaF(0.38 + 0.45 * frac)
p.setPen(QPen(col, 1.1))
p.setBrush(Qt.BrushStyle.NoBrush)
path = QPainterPath()
for k_pt in range(len(smooth)):
pt = self._w2s(float(smooth[k_pt, 0]), float(smooth[k_pt, 1]))
if k_pt == 0:
path.moveTo(pt)
else:
path.lineTo(pt)
p.drawPath(path)
# ── Perpendiculares AP / FP ────────────────────────────────────
p.setPen(QPen(_AXIS, 1.5))
p.drawLine(self._w2s(0, -T * 0.05), self._w2s(0, self._hull.depth * 1.05))