From 0cf7df2763620e4d9ce06de5322c0140fb6d3582 Mon Sep 17 00:00:00 2001 From: alro1965 Date: Wed, 27 May 2026 20:04:27 -0400 Subject: [PATCH] Fix lines-plan coordinate system: Y-inversion + add buttock lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- arshipdesign/ui/widgets/viewer_lines.py | 179 ++++++++++++++++++++++-- 1 file changed, 164 insertions(+), 15 deletions(-) diff --git a/arshipdesign/ui/widgets/viewer_lines.py b/arshipdesign/ui/widgets/viewer_lines.py index 61f5792..d889651 100644 --- a/arshipdesign/ui/widgets/viewer_lines.py +++ b/arshipdesign/ui/widgets/viewer_lines.py @@ -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 - 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) + # 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]) + 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))