Files

610 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)