0f85935fc8
- gz_integrator.py: GZCurve, GZPoint, compute_gz_wall_sided (fórmula pared lateral), compute_gz_direct (integración Sutherland-Hodgman) - imo_is2008.py: IMOCriterion, IMOResult, check_imo_is2008 — 6 criterios A.2.1.1–A.2.1.6 del IS Code 2008 Cap.2 - gz_curve_widget.py: GZCurveWidget QPainter — curva cian, áreas sombreadas, líneas IMO, marcador AVS, tabla PASS/FAIL integrada - main_window.py: GZCurveWidget en MOD_STABILITY, _compute_and_show_gz, _on_show_stability conectado al ribbon - dark.qss: estilos GZCurveWidget - test_module3_stability.py: 33 tests S-01..S-28 (315 total, todos pasan) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
618 lines
24 KiB
Python
618 lines
24 KiB
Python
"""
|
||
gz_curve_widget.py — Visualización de la curva de brazos adrizantes (GZ).
|
||
|
||
Características:
|
||
- Curva GZ principal en cian eléctrico (#00FFCC)
|
||
- Áreas bajo la curva sombreadas:
|
||
0–30° verde (#00FF88 con alfa 0x20)
|
||
30–40° ámbar (#FFB300 con alfa 0x20)
|
||
>40° gris (#FFFFFF con alfa 0x10)
|
||
- Líneas de referencia IMO (GZ=0.20 m a 30°, GZ=0 a 40°)
|
||
- Tabla PASS/FAIL de criterios IS Code 2008 integrada (30%)
|
||
- Indicador del ángulo de estabilidad nula (AVS) en rojo
|
||
- Línea de GM₀ tangente al origen (pendiente = GM en rad/m)
|
||
- Barra de información de escora activa (hover)
|
||
|
||
Paleta oscura marinera:
|
||
Fondo: #0A0E1A Curva: #00FFCC IMO ref: #FFB300
|
||
PASS: #00FF88 FAIL: #FF4444
|
||
|
||
Autor: Álvaro Romero
|
||
Módulo 3 — AR-ShipDesign
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from typing import Optional
|
||
|
||
import numpy as np
|
||
from PySide6.QtCore import Qt, QRect, QPoint, Signal
|
||
from PySide6.QtGui import (
|
||
QColor, QFont, QFontMetrics, QPainter, QPen, QBrush,
|
||
QLinearGradient, QPixmap, QPolygonF,
|
||
)
|
||
from PySide6.QtCore import QPointF
|
||
from PySide6.QtWidgets import QWidget, QSizePolicy, QToolTip
|
||
|
||
from arshipdesign.stability.gz_integrator import GZCurve
|
||
from arshipdesign.stability.imo_is2008 import IMOResult
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Paleta de colores
|
||
# ---------------------------------------------------------------------------
|
||
_BG_CHART = QColor("#0A0E1A")
|
||
_BG_TABLE = QColor("#0D1525")
|
||
_GRID = QColor(40, 55, 90, 100)
|
||
_AXIS = QColor(60, 80, 120)
|
||
_CURVE = QColor("#00FFCC")
|
||
_GM_LINE = QColor(60, 130, 200, 160)
|
||
_IMO_REF = QColor("#FFB300")
|
||
_AVS_COLOR = QColor("#FF4444")
|
||
_GZ_ZERO = QColor(200, 200, 200, 80)
|
||
_TEXT_DIM = QColor(100, 120, 160)
|
||
_TEXT_BRIGHT = QColor(200, 220, 240)
|
||
_HEADER_COL = QColor("#FFB300")
|
||
_PASS_COL = QColor("#00FF88")
|
||
_FAIL_COL = QColor("#FF4444")
|
||
|
||
# Áreas sombreadas
|
||
_FILL_030 = QColor(0, 255, 136, 20)
|
||
_FILL_3040 = QColor(255, 179, 0, 20)
|
||
_FILL_40 = QColor(255, 255, 255, 10)
|
||
|
||
# Tipografía
|
||
_FONT_MONO = "Consolas"
|
||
_FONT_LABELS = "Segoe UI"
|
||
|
||
|
||
class GZCurveWidget(QWidget):
|
||
"""Widget QPainter para la curva GZ con tabla IMO integrada.
|
||
|
||
Layout vertical:
|
||
- 70% superior: gráfico de la curva GZ
|
||
- 30% inferior: tabla de 6 criterios IMO IS Code 2008
|
||
|
||
Signals
|
||
-------
|
||
angle_hovered : Signal(float)
|
||
Ángulo en grados cuando el ratón está sobre el área del gráfico.
|
||
"""
|
||
|
||
angle_hovered: Signal = Signal(float)
|
||
|
||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||
super().__init__(parent)
|
||
self._gz: Optional[GZCurve] = None
|
||
self._imo: Optional[IMOResult] = None
|
||
self._active_angle: float = -1.0
|
||
self._hover_angle: float = -1.0
|
||
self._chart_cache: Optional[QPixmap] = None
|
||
self._cache_valid = False
|
||
|
||
self.setMouseTracking(True)
|
||
self.setMinimumSize(500, 380)
|
||
self.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Expanding,
|
||
)
|
||
self.setObjectName("GZCurveWidget")
|
||
|
||
# ------------------------------------------------------------------
|
||
# API pública
|
||
# ------------------------------------------------------------------
|
||
|
||
def set_curve(self, gz: GZCurve, imo: IMOResult) -> None:
|
||
"""Establece la curva GZ y el resultado IMO y fuerza un repintado."""
|
||
self._gz = gz
|
||
self._imo = imo
|
||
self._cache_valid = False
|
||
self.update()
|
||
|
||
def set_active_angle(self, phi_deg: float) -> None:
|
||
"""Resalta una escora con un marcador vertical."""
|
||
self._active_angle = float(phi_deg)
|
||
self.update()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Eventos de ratón
|
||
# ------------------------------------------------------------------
|
||
|
||
def mouseMoveEvent(self, event) -> None:
|
||
if self._gz is None:
|
||
return
|
||
chart_rect = self._chart_rect()
|
||
pos = event.position() if hasattr(event, "position") else event.pos()
|
||
x = pos.x() if hasattr(pos, "x") else pos.x()
|
||
if chart_rect.contains(int(x), int(pos.y() if hasattr(pos, "y") else pos.y())):
|
||
# Convertir x-pixel a ángulo
|
||
plot_x0 = chart_rect.left() + self._margin_l
|
||
plot_w = chart_rect.width() - self._margin_l - self._margin_r
|
||
if plot_w > 0:
|
||
phi = (x - plot_x0) / plot_w * self._phi_max
|
||
phi = max(0.0, min(self._phi_max, phi))
|
||
self._hover_angle = phi
|
||
self.angle_hovered.emit(phi)
|
||
self.update()
|
||
else:
|
||
self._hover_angle = -1.0
|
||
|
||
def leaveEvent(self, event) -> None:
|
||
self._hover_angle = -1.0
|
||
self.update()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Pintado principal
|
||
# ------------------------------------------------------------------
|
||
|
||
def paintEvent(self, event) -> None:
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
|
||
w, h = self.width(), self.height()
|
||
chart_h = int(h * 0.70)
|
||
table_h = h - chart_h
|
||
|
||
# Fondos
|
||
painter.fillRect(0, 0, w, chart_h, _BG_CHART)
|
||
painter.fillRect(0, chart_h, w, table_h, _BG_TABLE)
|
||
|
||
# Separador
|
||
painter.setPen(QPen(QColor(30, 50, 90), 1))
|
||
painter.drawLine(0, chart_h, w, chart_h)
|
||
|
||
chart_rect = QRect(0, 0, w, chart_h)
|
||
table_rect = QRect(0, chart_h, w, table_h)
|
||
|
||
self._draw_chart(painter, chart_rect)
|
||
self._draw_table(painter, table_rect)
|
||
|
||
painter.end()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Zona del gráfico
|
||
# ------------------------------------------------------------------
|
||
|
||
# Márgenes internos del plot (dentro del chart_rect)
|
||
_margin_l = 62 # izquierda (etiquetas GZ)
|
||
_margin_r = 20 # derecha
|
||
_margin_t = 32 # arriba (título)
|
||
_margin_b = 38 # abajo (etiquetas ángulo)
|
||
_phi_max = 90.0
|
||
|
||
def _chart_rect(self) -> QRect:
|
||
h = int(self.height() * 0.70)
|
||
return QRect(0, 0, self.width(), h)
|
||
|
||
def _plot_rect(self, chart_rect: QRect) -> QRect:
|
||
return QRect(
|
||
chart_rect.left() + self._margin_l,
|
||
chart_rect.top() + self._margin_t,
|
||
chart_rect.width() - self._margin_l - self._margin_r,
|
||
chart_rect.height() - self._margin_t - self._margin_b,
|
||
)
|
||
|
||
def _to_px(self, plot_rect: QRect, phi: float, gz: float,
|
||
gz_min: float, gz_max_val: float) -> QPointF:
|
||
"""Convierte (phi_deg, gz) a coordenadas de píxel."""
|
||
rel_x = phi / self._phi_max
|
||
gz_range = gz_max_val - gz_min
|
||
if gz_range < 1e-9:
|
||
gz_range = 1.0
|
||
rel_y = 1.0 - (gz - gz_min) / gz_range
|
||
px = plot_rect.left() + rel_x * plot_rect.width()
|
||
py = plot_rect.top() + rel_y * plot_rect.height()
|
||
return QPointF(px, py)
|
||
|
||
def _draw_chart(self, painter: QPainter, chart_rect: QRect) -> None:
|
||
plot_rect = self._plot_rect(chart_rect)
|
||
|
||
gz = self._gz
|
||
|
||
# ── Título ────────────────────────────────────────────────────
|
||
font_title = QFont(_FONT_LABELS, 9)
|
||
font_title.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 1.2)
|
||
font_title.setWeight(QFont.Weight.DemiBold)
|
||
painter.setFont(font_title)
|
||
painter.setPen(QPen(_CURVE))
|
||
title_text = "CURVA GZ — ESTABILIDAD ESTÁTICA"
|
||
painter.drawText(
|
||
chart_rect.left() + self._margin_l,
|
||
chart_rect.top() + 18,
|
||
title_text,
|
||
)
|
||
|
||
if gz is None:
|
||
# Placeholder si no hay datos
|
||
font_ph = QFont(_FONT_MONO, 11)
|
||
painter.setFont(font_ph)
|
||
painter.setPen(QPen(QColor(40, 60, 100)))
|
||
painter.drawText(
|
||
plot_rect,
|
||
Qt.AlignmentFlag.AlignCenter,
|
||
"Sin datos — calcule la curva GZ primero",
|
||
)
|
||
return
|
||
|
||
# ── Determinar rango de GZ ────────────────────────────────────
|
||
gz_vals = gz.gz_values
|
||
gz_min_data = float(np.min(gz_vals))
|
||
gz_max_data = float(np.max(gz_vals))
|
||
|
||
# Añadir margen y asegurar que gz=0 está visible
|
||
margin_gz = max(0.05, (gz_max_data - gz_min_data) * 0.12)
|
||
gz_plot_min = min(gz_min_data - margin_gz, -0.05)
|
||
gz_plot_max = gz_max_data + margin_gz
|
||
|
||
# Redondear a 0.1m
|
||
gz_plot_min = math.floor(gz_plot_min * 10) / 10.0
|
||
gz_plot_max = math.ceil(gz_plot_max * 10) / 10.0
|
||
if gz_plot_max <= gz_plot_min:
|
||
gz_plot_max = gz_plot_min + 0.1
|
||
|
||
gz_range = gz_plot_max - gz_plot_min
|
||
|
||
def to_px(phi: float, gz_val: float) -> QPointF:
|
||
rel_x = phi / self._phi_max
|
||
rel_y = 1.0 - (gz_val - gz_plot_min) / gz_range
|
||
return QPointF(
|
||
plot_rect.left() + rel_x * plot_rect.width(),
|
||
plot_rect.top() + rel_y * plot_rect.height(),
|
||
)
|
||
|
||
def phi_to_px_x(phi: float) -> float:
|
||
return plot_rect.left() + (phi / self._phi_max) * plot_rect.width()
|
||
|
||
def gz_to_px_y(gz_val: float) -> float:
|
||
return plot_rect.top() + (1.0 - (gz_val - gz_plot_min) / gz_range) * plot_rect.height()
|
||
|
||
gz_zero_y = gz_to_px_y(0.0)
|
||
|
||
# ── Fondo del plot ────────────────────────────────────────────
|
||
painter.fillRect(plot_rect, _BG_CHART)
|
||
|
||
# ── Grid vertical (cada 10°) ──────────────────────────────────
|
||
pen_grid = QPen(_GRID, 1, Qt.PenStyle.SolidLine)
|
||
painter.setPen(pen_grid)
|
||
for phi in range(0, 91, 10):
|
||
x = phi_to_px_x(float(phi))
|
||
painter.drawLine(QPointF(x, plot_rect.top()), QPointF(x, plot_rect.bottom()))
|
||
|
||
# ── Grid horizontal (cada 0.1 m) ──────────────────────────────
|
||
gz_tick = gz_plot_min
|
||
while gz_tick <= gz_plot_max + 1e-6:
|
||
y = gz_to_px_y(gz_tick)
|
||
if plot_rect.top() <= y <= plot_rect.bottom():
|
||
painter.drawLine(QPointF(plot_rect.left(), y), QPointF(plot_rect.right(), y))
|
||
gz_tick = round(gz_tick + 0.1, 10)
|
||
|
||
# ── Línea GZ = 0 (blanca tenue) ───────────────────────────────
|
||
pen_zero = QPen(_GZ_ZERO, 1, Qt.PenStyle.DashLine)
|
||
painter.setPen(pen_zero)
|
||
if plot_rect.top() <= gz_zero_y <= plot_rect.bottom():
|
||
painter.drawLine(
|
||
QPointF(plot_rect.left(), gz_zero_y),
|
||
QPointF(plot_rect.right(), gz_zero_y),
|
||
)
|
||
|
||
# ── Áreas sombreadas ─────────────────────────────────────────
|
||
phi_deg = gz.angles_deg
|
||
|
||
def _fill_area(phi_start: float, phi_end: float, color: QColor) -> None:
|
||
mask = (phi_deg >= phi_start) & (phi_deg <= phi_end)
|
||
pts_phi = np.concatenate([[phi_start], phi_deg[mask], [phi_end]])
|
||
pts_gz = np.concatenate([
|
||
[float(np.interp(phi_start, phi_deg, gz_vals))],
|
||
gz_vals[mask],
|
||
[float(np.interp(phi_end, phi_deg, gz_vals))],
|
||
])
|
||
# Base: polígono cerrado por y=0
|
||
poly = QPolygonF()
|
||
# Puntos superiores (curva)
|
||
for i in range(len(pts_phi)):
|
||
poly.append(to_px(pts_phi[i], pts_gz[i]))
|
||
# Bajar hasta y=0 y volver
|
||
poly.append(to_px(pts_phi[-1], 0.0))
|
||
poly.append(to_px(pts_phi[0], 0.0))
|
||
painter.setBrush(QBrush(color))
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.drawPolygon(poly)
|
||
|
||
_fill_area(0.0, 30.0, _FILL_030)
|
||
_fill_area(30.0, 40.0, _FILL_3040)
|
||
_fill_area(40.0, min(self._phi_max, float(np.max(phi_deg))), _FILL_40)
|
||
|
||
# ── Línea IMO GZ=0.20 m a 30° ────────────────────────────────
|
||
pen_imo = QPen(_IMO_REF, 1, Qt.PenStyle.DashLine)
|
||
painter.setPen(pen_imo)
|
||
|
||
# Horizontal IMO 0.20m
|
||
y_imo_020 = gz_to_px_y(0.20)
|
||
if plot_rect.top() <= y_imo_020 <= plot_rect.bottom():
|
||
painter.drawLine(
|
||
QPointF(plot_rect.left(), y_imo_020),
|
||
QPointF(phi_to_px_x(30.0), y_imo_020),
|
||
)
|
||
# Etiqueta
|
||
font_tiny = QFont(_FONT_MONO, 7)
|
||
painter.setFont(font_tiny)
|
||
painter.setPen(QPen(_IMO_REF))
|
||
painter.drawText(
|
||
int(plot_rect.left() + 2),
|
||
int(y_imo_020 - 2),
|
||
"IMO 0.20m",
|
||
)
|
||
|
||
# Vertical en 30°
|
||
x_30 = phi_to_px_x(30.0)
|
||
painter.setPen(pen_imo)
|
||
painter.drawLine(
|
||
QPointF(x_30, plot_rect.top()),
|
||
QPointF(x_30, plot_rect.bottom()),
|
||
)
|
||
font_tiny = QFont(_FONT_MONO, 7)
|
||
painter.setFont(font_tiny)
|
||
painter.setPen(QPen(_IMO_REF))
|
||
painter.drawText(int(x_30) + 2, int(plot_rect.top()) + 10, "30°")
|
||
|
||
# ── Línea GM₀ tangente al origen ─────────────────────────────
|
||
# GZ ≈ GM · sin(φ) → para pequeños φ: GZ ≈ GM · φ_rad
|
||
# Tangente desde φ=0,GZ=0 hasta φ=30°, GZ=GM·sin(30°)
|
||
pen_gm = QPen(_GM_LINE, 1, Qt.PenStyle.DotLine)
|
||
painter.setPen(pen_gm)
|
||
phi_end_gm = 25.0
|
||
gz_end_gm = gz.gm * math.sin(math.radians(phi_end_gm))
|
||
painter.drawLine(
|
||
to_px(0.0, 0.0),
|
||
to_px(phi_end_gm, gz_end_gm),
|
||
)
|
||
font_tiny = QFont(_FONT_MONO, 7)
|
||
painter.setFont(font_tiny)
|
||
painter.setPen(QPen(_GM_LINE))
|
||
p_gm = to_px(phi_end_gm, gz_end_gm)
|
||
painter.drawText(int(p_gm.x()) + 3, int(p_gm.y()) - 2,
|
||
f"GM={gz.gm:.3f}m")
|
||
|
||
# ── Curva GZ principal ────────────────────────────────────────
|
||
pen_curve = QPen(_CURVE, 2, Qt.PenStyle.SolidLine)
|
||
pen_curve.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
pen_curve.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||
painter.setPen(pen_curve)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
|
||
path_pts = [to_px(float(phi_deg[i]), float(gz_vals[i]))
|
||
for i in range(len(phi_deg))]
|
||
for i in range(len(path_pts) - 1):
|
||
painter.drawLine(path_pts[i], path_pts[i + 1])
|
||
|
||
# ── Marcador AVS ──────────────────────────────────────────────
|
||
if gz.avs < 90.0:
|
||
pen_avs = QPen(_AVS_COLOR, 1, Qt.PenStyle.DashDotLine)
|
||
painter.setPen(pen_avs)
|
||
x_avs = phi_to_px_x(gz.avs)
|
||
painter.drawLine(
|
||
QPointF(x_avs, plot_rect.top()),
|
||
QPointF(x_avs, plot_rect.bottom()),
|
||
)
|
||
font_avs = QFont(_FONT_MONO, 7)
|
||
painter.setFont(font_avs)
|
||
painter.setPen(QPen(_AVS_COLOR))
|
||
painter.drawText(int(x_avs) + 2, int(plot_rect.top()) + 22,
|
||
f"AVS {gz.avs:.0f}°")
|
||
|
||
# ── Marcador de escora activa ─────────────────────────────────
|
||
if 0.0 <= self._active_angle <= 90.0:
|
||
pen_active = QPen(QColor(255, 200, 0, 180), 1, Qt.PenStyle.SolidLine)
|
||
painter.setPen(pen_active)
|
||
x_act = phi_to_px_x(self._active_angle)
|
||
painter.drawLine(
|
||
QPointF(x_act, plot_rect.top()),
|
||
QPointF(x_act, plot_rect.bottom()),
|
||
)
|
||
|
||
# ── Marcador de hover ─────────────────────────────────────────
|
||
if 0.0 <= self._hover_angle <= 90.0:
|
||
pen_hov = QPen(QColor(200, 220, 255, 120), 1, Qt.PenStyle.DashLine)
|
||
painter.setPen(pen_hov)
|
||
x_hov = phi_to_px_x(self._hover_angle)
|
||
painter.drawLine(
|
||
QPointF(x_hov, plot_rect.top()),
|
||
QPointF(x_hov, plot_rect.bottom()),
|
||
)
|
||
# Valor GZ en hover
|
||
gz_hov = float(np.interp(self._hover_angle, phi_deg, gz_vals))
|
||
font_hov = QFont(_FONT_MONO, 8)
|
||
painter.setFont(font_hov)
|
||
painter.setPen(QPen(QColor(200, 220, 255)))
|
||
hov_text = f"φ={self._hover_angle:.1f}° GZ={gz_hov:.3f}m"
|
||
painter.drawText(int(x_hov) + 4, int(plot_rect.top()) + 14, hov_text)
|
||
|
||
# ── Ejes y etiquetas ──────────────────────────────────────────
|
||
pen_axis = QPen(_AXIS, 1)
|
||
painter.setPen(pen_axis)
|
||
# Marco del plot
|
||
painter.drawRect(plot_rect)
|
||
|
||
font_tick = QFont(_FONT_MONO, 8)
|
||
painter.setFont(font_tick)
|
||
painter.setPen(QPen(_TEXT_DIM))
|
||
|
||
# Etiquetas X (ángulos, cada 10°)
|
||
for phi in range(0, 91, 10):
|
||
x = phi_to_px_x(float(phi))
|
||
y_base = plot_rect.bottom() + 14
|
||
painter.drawText(
|
||
int(x) - 8, y_base,
|
||
f"{phi}°",
|
||
)
|
||
|
||
# Etiquetas Y (GZ, cada 0.1m)
|
||
gz_tick2 = gz_plot_min
|
||
while gz_tick2 <= gz_plot_max + 1e-6:
|
||
y = gz_to_px_y(gz_tick2)
|
||
if plot_rect.top() - 2 <= y <= plot_rect.bottom() + 2:
|
||
lbl = f"{gz_tick2:.1f}"
|
||
fm = QFontMetrics(font_tick)
|
||
lbl_w = fm.horizontalAdvance(lbl)
|
||
painter.drawText(
|
||
int(plot_rect.left()) - lbl_w - 4,
|
||
int(y) + 4,
|
||
lbl,
|
||
)
|
||
gz_tick2 = round(gz_tick2 + 0.1, 10)
|
||
|
||
# Etiqueta eje Y
|
||
painter.save()
|
||
font_axis_lbl = QFont(_FONT_LABELS, 8)
|
||
painter.setFont(font_axis_lbl)
|
||
painter.setPen(QPen(_TEXT_DIM))
|
||
painter.translate(
|
||
chart_rect.left() + 10,
|
||
int(plot_rect.top() + plot_rect.height() / 2),
|
||
)
|
||
painter.rotate(-90)
|
||
painter.drawText(-24, 0, "GZ [m]")
|
||
painter.restore()
|
||
|
||
# Etiqueta eje X
|
||
painter.setPen(QPen(_TEXT_DIM))
|
||
painter.drawText(
|
||
int(plot_rect.left() + plot_rect.width() / 2) - 28,
|
||
chart_rect.bottom() - 4,
|
||
"Ángulo de escora φ [°]",
|
||
)
|
||
|
||
# ── Info resumen (esquina superior derecha) ───────────────────
|
||
font_info = QFont(_FONT_MONO, 8)
|
||
painter.setFont(font_info)
|
||
painter.setPen(QPen(QColor(80, 100, 140)))
|
||
info_lines = [
|
||
f"Hull: {gz.hull_name[:20]}",
|
||
f"T={gz.draft:.2f}m KG={gz.kg:.2f}m",
|
||
f"GM={gz.gm:.3f}m BM={gz.bmt:.3f}m",
|
||
f"GZmax={gz.gz_max:.3f}m @ {gz.phi_gz_max:.0f}°",
|
||
f"AVS={gz.avs:.0f}°",
|
||
]
|
||
fm_info = QFontMetrics(font_info)
|
||
x_info = chart_rect.right() - self._margin_r - 130
|
||
y_info = chart_rect.top() + self._margin_t
|
||
for line in info_lines:
|
||
painter.drawText(x_info, y_info, line)
|
||
y_info += fm_info.height() + 2
|
||
|
||
# ------------------------------------------------------------------
|
||
# Zona de la tabla IMO
|
||
# ------------------------------------------------------------------
|
||
|
||
def _draw_table(self, painter: QPainter, table_rect: QRect) -> None:
|
||
imo = self._imo
|
||
if imo is None:
|
||
painter.setPen(QPen(QColor(40, 60, 100)))
|
||
font_ph = QFont(_FONT_MONO, 10)
|
||
painter.setFont(font_ph)
|
||
painter.drawText(
|
||
table_rect,
|
||
Qt.AlignmentFlag.AlignCenter,
|
||
"IMO IS Code 2008 — sin datos",
|
||
)
|
||
return
|
||
|
||
rows = imo.table_rows()
|
||
n_rows = len(rows)
|
||
n_total = n_rows + 1 # +1 cabecera
|
||
|
||
# Alturas de fila
|
||
row_h = max(16, table_rect.height() // n_total)
|
||
# Anchos de columna (proporcional al ancho total)
|
||
w = table_rect.width()
|
||
col_w = [
|
||
int(w * 0.11), # code
|
||
int(w * 0.22), # description
|
||
int(w * 0.24), # required
|
||
int(w * 0.24), # achieved
|
||
int(w * 0.12), # PASS/FAIL
|
||
]
|
||
# Ajustar el último para que cubra todo
|
||
col_w[-1] = w - sum(col_w[:-1])
|
||
|
||
font_hdr = QFont(_FONT_MONO, 8)
|
||
font_hdr.setBold(True)
|
||
font_cell = QFont(_FONT_MONO, 8)
|
||
|
||
# ── Cabecera ────────────────────────────────────────────────
|
||
y_row = table_rect.top()
|
||
painter.fillRect(
|
||
table_rect.left(), y_row,
|
||
w, row_h,
|
||
QColor(12, 20, 40),
|
||
)
|
||
headers = ["Código", "Descripción", "Requerido", "Obtenido", "Resultado"]
|
||
painter.setFont(font_hdr)
|
||
painter.setPen(QPen(_HEADER_COL))
|
||
x_col = table_rect.left() + 4
|
||
for i, hdr in enumerate(headers):
|
||
painter.drawText(
|
||
int(x_col), int(y_row + row_h - 4),
|
||
hdr,
|
||
)
|
||
x_col += col_w[i]
|
||
y_row += row_h
|
||
|
||
# ── Filas de criterios ───────────────────────────────────────
|
||
for row_idx, (code, desc, req_str, ach_str, passed) in enumerate(rows):
|
||
bg = QColor(10, 18, 35) if row_idx % 2 == 0 else QColor(13, 22, 42)
|
||
painter.fillRect(
|
||
table_rect.left(), y_row,
|
||
w, row_h,
|
||
bg,
|
||
)
|
||
|
||
status_color = _PASS_COL if passed else _FAIL_COL
|
||
status_text = "PASS" if passed else "FAIL"
|
||
|
||
painter.setFont(font_cell)
|
||
texts = [code, desc, req_str, ach_str, ""]
|
||
colors = [_TEXT_DIM, _TEXT_BRIGHT, _TEXT_DIM, _TEXT_BRIGHT, status_color]
|
||
|
||
x_col = table_rect.left() + 4
|
||
for i, txt in enumerate(texts):
|
||
painter.setPen(QPen(colors[i]))
|
||
painter.drawText(
|
||
int(x_col), int(y_row + row_h - 4),
|
||
txt,
|
||
)
|
||
x_col += col_w[i]
|
||
|
||
# PASS/FAIL con color propio
|
||
painter.setPen(QPen(status_color))
|
||
font_status = QFont(_FONT_MONO, 8)
|
||
font_status.setBold(True)
|
||
painter.setFont(font_status)
|
||
painter.drawText(
|
||
int(table_rect.left() + sum(col_w[:-1]) + 4),
|
||
int(y_row + row_h - 4),
|
||
status_text,
|
||
)
|
||
y_row += row_h
|
||
|
||
# ── Línea de resultado global ─────────────────────────────────
|
||
if y_row < table_rect.bottom():
|
||
overall_bg = QColor(0, 40, 20) if imo.overall_passed else QColor(40, 10, 10)
|
||
painter.fillRect(
|
||
table_rect.left(), y_row,
|
||
w, table_rect.bottom() - y_row,
|
||
overall_bg,
|
||
)
|
||
overall_txt = "CUMPLE TODOS LOS CRITERIOS IMO IS CODE 2008" if imo.overall_passed \
|
||
else "FALLA CRITERIOS IMO IS CODE 2008"
|
||
overall_col = _PASS_COL if imo.overall_passed else _FAIL_COL
|
||
font_overall = QFont(_FONT_LABELS, 9)
|
||
font_overall.setBold(True)
|
||
painter.setFont(font_overall)
|
||
painter.setPen(QPen(overall_col))
|
||
painter.drawText(
|
||
table_rect.left() + 8,
|
||
y_row + max(16, (table_rect.bottom() - y_row) - 4),
|
||
overall_txt,
|
||
)
|