"""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)