610 lines
23 KiB
Python
610 lines
23 KiB
Python
"""VesselCanvas — QGraphicsView con grilla naval y silueta del buque.
|
||
|
||
Sistema de coordenadas:
|
||
- ShipCoord (x_pp, y_cl, z_bl) en metros
|
||
- Transformación a pixels: 1 m → 20 px (configurable vía zoom)
|
||
- Origen Pp (popa) en la izquierda, proa a la derecha
|
||
|
||
Sprint 1: muestra eslora con grilla, marcadores de mamparos y equipos.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||
from PySide6.QtGui import (
|
||
QBrush,
|
||
QColor,
|
||
QLinearGradient,
|
||
QPainter,
|
||
QPen,
|
||
QPolygonF,
|
||
)
|
||
from PySide6.QtWidgets import (
|
||
QFrame,
|
||
QGraphicsScene,
|
||
QGraphicsView,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from vmssailor.core.coords import ShipCoord
|
||
from vmssailor.core.project import Project
|
||
from vmssailor.studio.theme import (
|
||
C_ABYSS,
|
||
C_CYAN,
|
||
C_CYAN_DEEP,
|
||
C_FOAM,
|
||
C_FOG,
|
||
C_MIDNIGHT,
|
||
C_SAND,
|
||
C_STEEL,
|
||
C_WARN,
|
||
mono_font,
|
||
ui_font,
|
||
)
|
||
|
||
# Mapeo metros -> pixels por defecto
|
||
PX_PER_M_DEFAULT = 20.0
|
||
|
||
|
||
def ship_to_scene(coord: ShipCoord, px_per_m: float = PX_PER_M_DEFAULT) -> QPointF:
|
||
"""Transformación de coordenadas navales a coordenadas de la escena.
|
||
|
||
En la escena: X crece a la derecha (= x_pp positivo hacia proa),
|
||
Y crece HACIA ABAJO en Qt — por eso invertimos y_cl.
|
||
|
||
El centro vertical de la escena (y=0) corresponde a la línea de crujía.
|
||
"""
|
||
x = coord.x_pp * px_per_m
|
||
y = -coord.y_cl * px_per_m # estribor abajo, babor arriba en pantalla
|
||
return QPointF(x, y)
|
||
|
||
|
||
class VesselCanvas(QWidget):
|
||
"""Canvas central del Studio."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self._project: Project | None = None
|
||
self._px_per_m = PX_PER_M_DEFAULT
|
||
|
||
# Hero header
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
|
||
header = QWidget()
|
||
header.setFixedHeight(56)
|
||
header.setStyleSheet(f"background: {C_MIDNIGHT}; border-bottom: 1px solid {C_STEEL};")
|
||
h_lay = QHBoxLayout(header)
|
||
h_lay.setContentsMargins(24, 0, 24, 0)
|
||
self._title = QLabel("Topología del buque")
|
||
self._title.setObjectName("title")
|
||
self._title.setFont(ui_font(13))
|
||
self._title.setStyleSheet(f"color: {C_FOAM}; font-weight: 600;")
|
||
h_lay.addWidget(self._title)
|
||
h_lay.addStretch(1)
|
||
self._zoom_label = QLabel("Zoom: 100%")
|
||
self._zoom_label.setFont(mono_font(9))
|
||
self._zoom_label.setStyleSheet(f"color: {C_FOG};")
|
||
h_lay.addWidget(self._zoom_label)
|
||
layout.addWidget(header)
|
||
|
||
# Scene + view
|
||
self._scene = QGraphicsScene(self)
|
||
self._view = _VesselGraphicsView(self._scene, self)
|
||
self._view.setFrameShape(QFrame.NoFrame)
|
||
self._view.setRenderHint(QPainter.Antialiasing)
|
||
self._view.setRenderHint(QPainter.SmoothPixmapTransform)
|
||
self._view.setBackgroundBrush(self._background_brush())
|
||
self._view.setDragMode(QGraphicsView.ScrollHandDrag)
|
||
self._view.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
||
self._view.zoomChanged.connect(self._on_zoom_changed)
|
||
layout.addWidget(self._view, 1)
|
||
|
||
self._render_empty_state()
|
||
|
||
# ----- Background ---------------------------------------------------
|
||
|
||
def _background_brush(self) -> QBrush:
|
||
grad = QLinearGradient(0, 0, 0, 1)
|
||
grad.setCoordinateMode(QLinearGradient.ObjectBoundingMode)
|
||
grad.setColorAt(0.0, QColor(C_ABYSS))
|
||
grad.setColorAt(1.0, QColor(C_MIDNIGHT))
|
||
return QBrush(grad)
|
||
|
||
# ----- Public -------------------------------------------------------
|
||
|
||
def set_project(self, project: Project | None) -> None:
|
||
self._project = project
|
||
if project is None:
|
||
self._render_empty_state()
|
||
else:
|
||
self._render_project()
|
||
|
||
def fit_in_view(self) -> None:
|
||
if not self._scene.items():
|
||
return
|
||
self._view.fitInView(self._scene.itemsBoundingRect().adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
|
||
|
||
# ----- Rendering ----------------------------------------------------
|
||
|
||
def _render_empty_state(self) -> None:
|
||
self._scene.clear()
|
||
self._scene.setSceneRect(QRectF(-200, -200, 1200, 600))
|
||
# Grid
|
||
self._draw_grid(self._scene.sceneRect(), grid_m=1.0)
|
||
# Center label
|
||
msg = self._scene.addText(
|
||
"Abre un proyecto existente (.vmsproj) o crea uno con\n"
|
||
"Proyecto › Nuevo desde wizard…",
|
||
ui_font(11),
|
||
)
|
||
msg.setDefaultTextColor(QColor(C_FOG))
|
||
rect = msg.boundingRect()
|
||
msg.setPos(-rect.width() / 2 + 400, -rect.height() / 2 + 100)
|
||
self._title.setText("Topología del buque · sin proyecto")
|
||
|
||
def _render_project(self) -> None:
|
||
assert self._project is not None
|
||
self._scene.clear()
|
||
v = self._project.vessel
|
||
|
||
# Compute scene rect
|
||
margin_m = 5.0
|
||
rect_m = QRectF(
|
||
-margin_m * self._px_per_m,
|
||
-(v.beam_max_m / 2 + margin_m) * self._px_per_m,
|
||
(v.length_overall_m + 2 * margin_m) * self._px_per_m,
|
||
(v.beam_max_m + 2 * margin_m) * self._px_per_m,
|
||
)
|
||
self._scene.setSceneRect(rect_m)
|
||
|
||
# Grid
|
||
self._draw_grid(rect_m, grid_m=1.0)
|
||
|
||
# Silhouette (plan view, simplified): a hull-shaped polygon
|
||
self._draw_vessel_plan(v)
|
||
|
||
# Bulkheads
|
||
for b in v.bulkheads:
|
||
x_px = b.x_pp * self._px_per_m
|
||
self._draw_bulkhead(x_px, v.beam_max_m, b.name)
|
||
|
||
# Equipment markers
|
||
for eq in self._project.equipment:
|
||
self._draw_equipment(eq)
|
||
|
||
# Axes
|
||
self._draw_axes(v.length_overall_m, v.beam_max_m)
|
||
|
||
self._title.setText(
|
||
f"Topología del buque · {v.name} · "
|
||
f"{v.length_overall_m:.1f}×{v.beam_max_m:.1f}×{v.draft_m:.1f} m"
|
||
)
|
||
|
||
# Fit
|
||
self.fit_in_view()
|
||
|
||
def _draw_grid(self, rect: QRectF, grid_m: float = 1.0) -> None:
|
||
step = grid_m * self._px_per_m
|
||
pen = QPen(QColor(C_STEEL))
|
||
pen.setWidthF(0.5)
|
||
pen.setCosmetic(True)
|
||
|
||
x = rect.left() - (rect.left() % step)
|
||
while x <= rect.right():
|
||
self._scene.addLine(x, rect.top(), x, rect.bottom(), pen)
|
||
x += step
|
||
y = rect.top() - (rect.top() % step)
|
||
while y <= rect.bottom():
|
||
self._scene.addLine(rect.left(), y, rect.right(), y, pen)
|
||
y += step
|
||
|
||
# Centerline (y_cl = 0) — más visible
|
||
cl_pen = QPen(QColor(C_CYAN_DEEP))
|
||
cl_pen.setWidthF(0.8)
|
||
cl_pen.setStyle(Qt.DashLine)
|
||
cl_pen.setCosmetic(True)
|
||
self._scene.addLine(rect.left(), 0, rect.right(), 0, cl_pen)
|
||
|
||
# Pp (x=0) — más visible
|
||
self._scene.addLine(0, rect.top(), 0, rect.bottom(), cl_pen)
|
||
|
||
def _draw_vessel_plan(self, vessel) -> None:
|
||
"""Silueta del buque en vista de planta.
|
||
|
||
Eje X de escena = x_pp (popa en 0, proa positiva).
|
||
Eje Y de escena = -y_cl (estribor en y negativo, babor en y positivo).
|
||
|
||
Forma realista de yacht motor planeo:
|
||
- Popa cuadrada (transom)
|
||
- Sección paralela media (max beam de ~25% a ~70% LOA)
|
||
- Proa puntiaguda con curva tipo bow flare
|
||
- Casco simétrico babor/estribor
|
||
"""
|
||
from PySide6.QtGui import QPainterPath
|
||
|
||
L = vessel.length_overall_m * self._px_per_m
|
||
B = vessel.beam_max_m * self._px_per_m
|
||
b2 = B / 2
|
||
|
||
# ----- Casco (hull outline) usando bezier para curvas suaves -----
|
||
path = QPainterPath()
|
||
# Stern transom (esquina popa estribor)
|
||
path.moveTo(0, -b2 * 0.90)
|
||
# Quarter aft (popa-estribor) — beam crece rápido
|
||
path.cubicTo(L * 0.05, -b2 * 0.96, L * 0.12, -b2, L * 0.22, -b2)
|
||
# Sección paralela estribor
|
||
path.lineTo(L * 0.62, -b2)
|
||
# Forward shoulder (transición a la proa)
|
||
path.cubicTo(L * 0.78, -b2 * 0.95, L * 0.90, -b2 * 0.60, L * 0.97, -b2 * 0.18)
|
||
# Punta de proa (curvatura aguda)
|
||
path.cubicTo(L * 1.005, -b2 * 0.05, L * 1.005, b2 * 0.05, L * 0.97, b2 * 0.18)
|
||
# Forward shoulder babor (espejo)
|
||
path.cubicTo(L * 0.90, b2 * 0.60, L * 0.78, b2 * 0.95, L * 0.62, b2)
|
||
# Sección paralela babor
|
||
path.lineTo(L * 0.22, b2)
|
||
# Quarter aft babor
|
||
path.cubicTo(L * 0.12, b2, L * 0.05, b2 * 0.96, 0, b2 * 0.90)
|
||
# Transom (popa rectangular)
|
||
path.lineTo(0, -b2 * 0.90)
|
||
path.closeSubpath()
|
||
|
||
# Casco gris claro con gradiente top-down (azul claro centro)
|
||
grad = QLinearGradient(0, -b2, 0, b2)
|
||
grad.setColorAt(0.0, QColor("#D4DBE5"))
|
||
grad.setColorAt(0.5, QColor("#E6EAF0"))
|
||
grad.setColorAt(1.0, QColor("#D4DBE5"))
|
||
pen = QPen(QColor(C_ABYSS))
|
||
pen.setWidthF(2)
|
||
pen.setCosmetic(True)
|
||
self._scene.addPath(path, pen, QBrush(grad))
|
||
|
||
# ----- Superestructura (visible desde planta) -----
|
||
super_path = QPainterPath()
|
||
# Cabina principal (main deck), centrada longitudinalmente
|
||
sx0 = L * 0.30
|
||
sx1 = L * 0.72
|
||
sb = b2 * 0.55
|
||
super_path.moveTo(sx0, -sb)
|
||
super_path.lineTo(sx1 - 8, -sb)
|
||
super_path.cubicTo(sx1, -sb, sx1 + 6, -sb * 0.5, sx1 + 6, 0)
|
||
super_path.cubicTo(sx1 + 6, sb * 0.5, sx1, sb, sx1 - 8, sb)
|
||
super_path.lineTo(sx0, sb)
|
||
super_path.cubicTo(sx0 - 6, sb, sx0 - 8, sb * 0.5, sx0 - 8, 0)
|
||
super_path.cubicTo(sx0 - 8, -sb * 0.5, sx0 - 6, -sb, sx0, -sb)
|
||
super_path.closeSubpath()
|
||
super_brush = QBrush(QColor("#F2F5F9"))
|
||
super_pen = QPen(QColor(C_ABYSS))
|
||
super_pen.setWidthF(1.2)
|
||
super_pen.setCosmetic(True)
|
||
self._scene.addPath(super_path, super_pen, super_brush)
|
||
|
||
# ----- Flybridge superior (más estrecho) -----
|
||
fb_path = QPainterPath()
|
||
fbx0 = L * 0.42
|
||
fbx1 = L * 0.66
|
||
fbb = b2 * 0.38
|
||
fb_path.addRoundedRect(fbx0, -fbb, fbx1 - fbx0, 2 * fbb, 6, 6)
|
||
fb_pen = QPen(QColor(C_ABYSS))
|
||
fb_pen.setWidthF(1.0)
|
||
fb_pen.setCosmetic(True)
|
||
self._scene.addPath(fb_path, fb_pen, QBrush(QColor("#FFFFFF")))
|
||
|
||
# Mástil / radar arch
|
||
mast = self._scene.addEllipse(
|
||
L * 0.54 - 3, -3, 6, 6,
|
||
QPen(QColor(C_CYAN), 1.5), QBrush(QColor(C_CYAN))
|
||
)
|
||
_ = mast
|
||
|
||
# ----- Ventanas / windscreen (línea cyan brillante) -----
|
||
win_pen = QPen(QColor(C_CYAN))
|
||
win_pen.setWidthF(1.5)
|
||
win_pen.setCosmetic(True)
|
||
self._scene.addLine(sx1 - 6, -sb * 0.3, sx1 + 4, 0, win_pen)
|
||
self._scene.addLine(sx1 + 4, 0, sx1 - 6, sb * 0.3, win_pen)
|
||
|
||
# ----- Centerline (línea de crujía, referencia) -----
|
||
cl_pen = QPen(QColor(C_CYAN_DEEP))
|
||
cl_pen.setWidthF(0.7)
|
||
cl_pen.setStyle(Qt.DashLine)
|
||
cl_pen.setCosmetic(True)
|
||
self._scene.addLine(0, 0, L, 0, cl_pen)
|
||
|
||
def _draw_bulkhead(self, x_px: float, beam_m: float, name: str) -> None:
|
||
b2 = beam_m * self._px_per_m / 2
|
||
pen = QPen(QColor(C_CYAN))
|
||
pen.setWidthF(1.5)
|
||
pen.setStyle(Qt.DashLine)
|
||
pen.setCosmetic(True)
|
||
self._scene.addLine(x_px, -b2, x_px, b2, pen)
|
||
text = self._scene.addText(name, ui_font(8))
|
||
text.setDefaultTextColor(QColor(C_CYAN))
|
||
text.setPos(x_px - text.boundingRect().width() / 2, b2 + 6)
|
||
|
||
def _draw_equipment(self, eq) -> None:
|
||
"""Dibuja un equipo con silueta característica según su sistema."""
|
||
center = ship_to_scene(eq.location, self._px_per_m)
|
||
sys_v = eq.system_id.value
|
||
|
||
if sys_v == "main_engine":
|
||
self._draw_main_engine_icon(center, eq.tag_prefix)
|
||
elif sys_v == "genset":
|
||
self._draw_genset_icon(center, eq.tag_prefix)
|
||
elif sys_v in ("fuel_tanks", "water_tanks", "fuel", "potable_water", "grey_black_tanks"):
|
||
self._draw_tank_icon(center, eq.tag_prefix)
|
||
elif sys_v in ("bilge", "fw_cooling", "sw_cooling", "lube_oil", "hydraulic_oil"):
|
||
self._draw_pump_icon(center, eq.tag_prefix)
|
||
elif sys_v == "thruster":
|
||
self._draw_thruster_icon(center, eq.tag_prefix)
|
||
elif sys_v in ("hvac", "engine_vent", "refrigeration", "heating"):
|
||
self._draw_hvac_icon(center, eq.tag_prefix)
|
||
else:
|
||
self._draw_generic_icon(center, eq.tag_prefix, C_SAND)
|
||
|
||
# ----- Iconos de equipos --------------------------------------------
|
||
|
||
def _label_below(self, cx: float, cy: float, text: str, dy: float = 18.0) -> None:
|
||
t = self._scene.addText(text, ui_font(8))
|
||
t.setDefaultTextColor(QColor(C_FOAM))
|
||
rect = t.boundingRect()
|
||
t.setPos(cx - rect.width() / 2, cy + dy)
|
||
|
||
def _draw_main_engine_icon(self, center, prefix: str) -> None:
|
||
"""Motor diésel marino visto desde arriba: bloque + 2 turbos + alternador."""
|
||
from PySide6.QtCore import QRectF
|
||
from PySide6.QtGui import QPainterPath
|
||
cx, cy = center.x(), center.y()
|
||
w, h = 34.0, 18.0
|
||
# Block (cuerpo principal)
|
||
block_pen = QPen(QColor(C_ABYSS))
|
||
block_pen.setWidthF(1.5)
|
||
block_pen.setCosmetic(True)
|
||
grad = QLinearGradient(0, cy - h / 2, 0, cy + h / 2)
|
||
grad.setColorAt(0.0, QColor(C_CYAN))
|
||
grad.setColorAt(1.0, QColor(C_CYAN_DEEP))
|
||
block_path = QPainterPath()
|
||
block_path.addRoundedRect(cx - w / 2, cy - h / 2, w, h, 3, 3)
|
||
self._scene.addPath(block_path, block_pen, QBrush(grad))
|
||
# Cilindros (V12: 6 puntos cada lado)
|
||
cyl_pen = QPen(QColor(C_ABYSS))
|
||
cyl_pen.setWidthF(0.8)
|
||
cyl_pen.setCosmetic(True)
|
||
cyl_brush = QBrush(QColor(C_FOAM))
|
||
for i in range(6):
|
||
x = cx - w / 2 + 3 + i * (w - 6) / 5
|
||
# cilindros estribor (arriba)
|
||
self._scene.addEllipse(x - 1.5, cy - h / 2 + 2, 3, 3, cyl_pen, cyl_brush)
|
||
# cilindros babor (abajo)
|
||
self._scene.addEllipse(x - 1.5, cy + h / 2 - 5, 3, 3, cyl_pen, cyl_brush)
|
||
# Turbos (popa = lado izq, x menor)
|
||
turbo_pen = QPen(QColor(C_ABYSS))
|
||
turbo_pen.setWidthF(1.0)
|
||
turbo_pen.setCosmetic(True)
|
||
self._scene.addEllipse(
|
||
cx - w / 2 - 4, cy - 4, 5, 5, turbo_pen, QBrush(QColor("#94A3B8"))
|
||
)
|
||
# Alternador (proa, lado derecho)
|
||
self._scene.addRect(
|
||
cx + w / 2 - 1, cy - 3, 5, 6, turbo_pen, QBrush(QColor("#94A3B8"))
|
||
)
|
||
# Halo de selección sutil
|
||
halo_pen = QPen(QColor(C_CYAN))
|
||
halo_pen.setWidthF(0.6)
|
||
halo_pen.setStyle(Qt.DashLine)
|
||
halo_pen.setCosmetic(True)
|
||
halo_path = QPainterPath()
|
||
halo_path.addRoundedRect(cx - w / 2 - 6, cy - h / 2 - 4, w + 12, h + 8, 6, 6)
|
||
self._scene.addPath(halo_path, halo_pen, QBrush(Qt.NoBrush))
|
||
self._label_below(cx, cy, prefix, dy=h / 2 + 8)
|
||
|
||
def _draw_genset_icon(self, center, prefix: str) -> None:
|
||
"""Genset con cabina silenciosa: rectángulo grande + ventilación + chimenea."""
|
||
from PySide6.QtGui import QPainterPath
|
||
cx, cy = center.x(), center.y()
|
||
w, h = 28.0, 16.0
|
||
pen = QPen(QColor(C_ABYSS))
|
||
pen.setWidthF(1.5)
|
||
pen.setCosmetic(True)
|
||
grad = QLinearGradient(0, cy - h / 2, 0, cy + h / 2)
|
||
grad.setColorAt(0.0, QColor(C_WARN))
|
||
grad.setColorAt(1.0, QColor("#C0760F"))
|
||
gp = QPainterPath()
|
||
gp.addRoundedRect(cx - w / 2, cy - h / 2, w, h, 3, 3)
|
||
self._scene.addPath(gp, pen, QBrush(grad))
|
||
# Rejilla de ventilación (líneas verticales)
|
||
vent_pen = QPen(QColor(C_ABYSS))
|
||
vent_pen.setWidthF(0.6)
|
||
vent_pen.setCosmetic(True)
|
||
for i in range(5):
|
||
x = cx + 2 + i * 3
|
||
self._scene.addLine(x, cy - h / 2 + 3, x, cy + h / 2 - 3, vent_pen)
|
||
# Chimenea (escape) — círculo pequeño en proa
|
||
self._scene.addEllipse(
|
||
cx - w / 2 + 3, cy - 2, 4, 4, pen, QBrush(QColor("#5A6B7F"))
|
||
)
|
||
# Símbolo de generación (rayo) en el centro
|
||
from PySide6.QtGui import QPainterPath
|
||
bolt = QPainterPath()
|
||
bolt.moveTo(cx - 2, cy - 4)
|
||
bolt.lineTo(cx + 1, cy - 1)
|
||
bolt.lineTo(cx - 1, cy - 1)
|
||
bolt.lineTo(cx + 2, cy + 4)
|
||
bolt_pen = QPen(QColor(C_FOAM))
|
||
bolt_pen.setWidthF(1.5)
|
||
bolt_pen.setCosmetic(True)
|
||
self._scene.addPath(bolt, bolt_pen, QBrush(Qt.NoBrush))
|
||
self._label_below(cx, cy, prefix, dy=h / 2 + 8)
|
||
|
||
def _draw_pump_icon(self, center, prefix: str) -> None:
|
||
"""Bomba centrífuga: círculo + voluta + descarga."""
|
||
cx, cy = center.x(), center.y()
|
||
r = 8.0
|
||
pen = QPen(QColor(C_ABYSS))
|
||
pen.setWidthF(1.5)
|
||
pen.setCosmetic(True)
|
||
self._scene.addEllipse(
|
||
cx - r, cy - r, 2 * r, 2 * r, pen, QBrush(QColor("#5BC0EB"))
|
||
)
|
||
# Aspas en cruz
|
||
aspas_pen = QPen(QColor(C_ABYSS))
|
||
aspas_pen.setWidthF(1.5)
|
||
aspas_pen.setCosmetic(True)
|
||
self._scene.addLine(cx - r * 0.7, cy, cx + r * 0.7, cy, aspas_pen)
|
||
self._scene.addLine(cx, cy - r * 0.7, cx, cy + r * 0.7, aspas_pen)
|
||
# Tubería de descarga (sale por arriba)
|
||
self._scene.addRect(cx - 2, cy - r - 4, 4, 4, pen, QBrush(QColor("#94A3B8")))
|
||
self._label_below(cx, cy, prefix, dy=r + 4)
|
||
|
||
def _draw_tank_icon(self, center, prefix: str) -> None:
|
||
"""Tanque visto desde planta: rectángulo redondeado con sub-divisiones."""
|
||
from PySide6.QtGui import QPainterPath
|
||
cx, cy = center.x(), center.y()
|
||
w, h = 22.0, 14.0
|
||
pen = QPen(QColor(C_ABYSS))
|
||
pen.setWidthF(1.5)
|
||
pen.setCosmetic(True)
|
||
grad = QLinearGradient(0, cy - h / 2, 0, cy + h / 2)
|
||
grad.setColorAt(0.0, QColor("#5BC0EB"))
|
||
grad.setColorAt(1.0, QColor("#1B7FB5"))
|
||
tp = QPainterPath()
|
||
tp.addRoundedRect(cx - w / 2, cy - h / 2, w, h, 2, 2)
|
||
self._scene.addPath(tp, pen, QBrush(grad))
|
||
# Diaphragm interno
|
||
diaph_pen = QPen(QColor(C_ABYSS))
|
||
diaph_pen.setWidthF(0.6)
|
||
diaph_pen.setStyle(Qt.DashLine)
|
||
diaph_pen.setCosmetic(True)
|
||
self._scene.addLine(cx, cy - h / 2 + 2, cx, cy + h / 2 - 2, diaph_pen)
|
||
# Manhole circle (top)
|
||
self._scene.addEllipse(cx - 2, cy - h / 2 - 1, 4, 4, pen, QBrush(QColor("#F2F5F9")))
|
||
self._label_below(cx, cy, prefix, dy=h / 2 + 6)
|
||
|
||
def _draw_thruster_icon(self, center, prefix: str) -> None:
|
||
"""Hélice de proa/popa: círculo con aspas radiales."""
|
||
cx, cy = center.x(), center.y()
|
||
r = 9.0
|
||
pen = QPen(QColor(C_ABYSS))
|
||
pen.setWidthF(1.5)
|
||
pen.setCosmetic(True)
|
||
self._scene.addEllipse(
|
||
cx - r, cy - r, 2 * r, 2 * r, pen, QBrush(QColor("#3A6BA8"))
|
||
)
|
||
# 4 aspas radiales
|
||
from PySide6.QtGui import QPainterPath
|
||
aspas = QPainterPath()
|
||
for ang in (0, 90, 180, 270):
|
||
import math
|
||
a = math.radians(ang)
|
||
aspas.moveTo(cx, cy)
|
||
aspas.lineTo(cx + r * 0.75 * math.cos(a), cy + r * 0.75 * math.sin(a))
|
||
aspas_pen = QPen(QColor(C_FOAM))
|
||
aspas_pen.setWidthF(1.6)
|
||
aspas_pen.setCosmetic(True)
|
||
self._scene.addPath(aspas, aspas_pen, QBrush(Qt.NoBrush))
|
||
self._scene.addEllipse(cx - 2, cy - 2, 4, 4, pen, QBrush(QColor(C_FOAM)))
|
||
self._label_below(cx, cy, prefix, dy=r + 4)
|
||
|
||
def _draw_hvac_icon(self, center, prefix: str) -> None:
|
||
"""Unidad HVAC: rectángulo + aspas de ventilador en frente."""
|
||
from PySide6.QtGui import QPainterPath
|
||
cx, cy = center.x(), center.y()
|
||
w, h = 18.0, 14.0
|
||
pen = QPen(QColor(C_ABYSS))
|
||
pen.setWidthF(1.5)
|
||
pen.setCosmetic(True)
|
||
hp = QPainterPath()
|
||
hp.addRoundedRect(cx - w / 2, cy - h / 2, w, h, 2, 2)
|
||
self._scene.addPath(hp, pen, QBrush(QColor("#B8C2D1")))
|
||
# Ventilador (círculo con 3 aspas curvas)
|
||
r = 5
|
||
self._scene.addEllipse(
|
||
cx - r, cy - r, 2 * r, 2 * r, pen, QBrush(QColor("#5A6B7F"))
|
||
)
|
||
aspas_pen = QPen(QColor(C_FOAM))
|
||
aspas_pen.setWidthF(1.2)
|
||
aspas_pen.setCosmetic(True)
|
||
from PySide6.QtGui import QPainterPath
|
||
for ang in (0, 120, 240):
|
||
import math
|
||
a = math.radians(ang)
|
||
asp = QPainterPath()
|
||
asp.moveTo(cx, cy)
|
||
asp.quadTo(
|
||
cx + r * 0.7 * math.cos(a + 0.6),
|
||
cy + r * 0.7 * math.sin(a + 0.6),
|
||
cx + r * 0.85 * math.cos(a),
|
||
cy + r * 0.85 * math.sin(a),
|
||
)
|
||
self._scene.addPath(asp, aspas_pen, QBrush(Qt.NoBrush))
|
||
self._label_below(cx, cy, prefix, dy=h / 2 + 5)
|
||
|
||
def _draw_generic_icon(self, center, prefix: str, color: str) -> None:
|
||
"""Fallback genérico (rectángulo neutro) para sistemas no contemplados."""
|
||
from PySide6.QtGui import QPainterPath
|
||
cx, cy = center.x(), center.y()
|
||
w, h = 14.0, 14.0
|
||
pen = QPen(QColor(C_ABYSS))
|
||
pen.setWidthF(1.2)
|
||
pen.setCosmetic(True)
|
||
gp = QPainterPath()
|
||
gp.addRoundedRect(cx - w / 2, cy - h / 2, w, h, 3, 3)
|
||
self._scene.addPath(gp, pen, QBrush(QColor(color)))
|
||
self._label_below(cx, cy, prefix, dy=h / 2 + 4)
|
||
|
||
def _draw_axes(self, length_m: float, beam_m: float) -> None:
|
||
# Ruler X
|
||
pen = QPen(QColor(C_FOG))
|
||
pen.setCosmetic(True)
|
||
ruler_y = (beam_m / 2 + 1.5) * self._px_per_m
|
||
L = length_m * self._px_per_m
|
||
self._scene.addLine(0, ruler_y, L, ruler_y, pen)
|
||
for m in range(0, int(length_m) + 1, 5):
|
||
x = m * self._px_per_m
|
||
self._scene.addLine(x, ruler_y - 4, x, ruler_y + 4, pen)
|
||
t = self._scene.addText(f"{m} m", mono_font(7))
|
||
t.setDefaultTextColor(QColor(C_FOG))
|
||
t.setPos(x - t.boundingRect().width() / 2, ruler_y + 6)
|
||
label = self._scene.addText("x_pp →", mono_font(8))
|
||
label.setDefaultTextColor(QColor(C_CYAN))
|
||
label.setPos(L + 12, ruler_y - 8)
|
||
|
||
# Pp arrow / origin
|
||
origin_label = self._scene.addText("Pp", mono_font(9))
|
||
origin_label.setDefaultTextColor(QColor(C_CYAN))
|
||
origin_label.setPos(-20, ruler_y - 8)
|
||
|
||
# ----- Slots --------------------------------------------------------
|
||
|
||
def _on_zoom_changed(self, scale: float) -> None:
|
||
self._zoom_label.setText(f"Zoom: {scale * 100:.0f}%")
|
||
|
||
|
||
class _VesselGraphicsView(QGraphicsView):
|
||
"""QGraphicsView con zoom por wheel + scroll-pan."""
|
||
|
||
from PySide6.QtCore import Signal # local import to avoid double importing
|
||
|
||
zoomChanged = Signal(float)
|
||
|
||
def __init__(self, scene: QGraphicsScene, parent: QWidget | None = None) -> None:
|
||
super().__init__(scene, parent)
|
||
self._scale = 1.0
|
||
self.setMouseTracking(True)
|
||
|
||
def wheelEvent(self, event) -> None: # type: ignore[override]
|
||
delta = event.angleDelta().y()
|
||
if delta == 0:
|
||
return
|
||
factor = 1.15 if delta > 0 else 1 / 1.15
|
||
new_scale = self._scale * factor
|
||
if not (0.1 <= new_scale <= 10.0):
|
||
return
|
||
self._scale = new_scale
|
||
self.scale(factor, factor)
|
||
self.zoomChanged.emit(self._scale)
|