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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user