28%
BLACK
diff --git a/vmssailor/runtime/server/api.py b/vmssailor/runtime/server/api.py
index cedc144..8255b26 100644
--- a/vmssailor/runtime/server/api.py
+++ b/vmssailor/runtime/server/api.py
@@ -13,6 +13,7 @@ from datetime import datetime
from typing import Any
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
+from fastapi.responses import HTMLResponse, Response
from vmssailor.runtime.server.runtime_app import RuntimeApp
from vmssailor.version import __version__
@@ -39,6 +40,18 @@ def create_app(runtime: RuntimeApp) -> FastAPI:
)
app.state.runtime = runtime
+ # ----- Root + favicon (UI amigable de bienvenida) -----------------
+
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
+ def root() -> str:
+ return _RENDER_ROOT_HTML(runtime)
+
+ @app.get("/favicon.ico", include_in_schema=False)
+ def favicon() -> Response:
+ # SVG inline para evitar dependencias en disco
+ svg = _FAVICON_SVG.encode("utf-8")
+ return Response(content=svg, media_type="image/svg+xml")
+
# ----- Health ------------------------------------------------------
@app.get("/health")
@@ -124,8 +137,11 @@ def create_app(runtime: RuntimeApp) -> FastAPI:
) -> list[dict[str, Any]]:
if tag_id not in runtime.tag_store:
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.")
- since_dt = datetime.fromisoformat(since) if since else None
- until_dt = datetime.fromisoformat(until) if until else None
+ try:
+ since_dt = datetime.fromisoformat(since) if since else None
+ until_dt = datetime.fromisoformat(until) if until else None
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Formato de fecha inválido. Use ISO 8601 (ej: 2024-01-15T10:00:00).")
return runtime.historian.query(tag_id, since=since_dt, until=until_dt, limit=limit)
# ----- Alarms ------------------------------------------------------
@@ -156,9 +172,15 @@ def create_app(runtime: RuntimeApp) -> FastAPI:
) -> list[dict[str, Any]]:
from vmssailor.runtime.server.logbook import LogEntryKind
- kind_enum = LogEntryKind(kind) if kind else None
- since_dt = datetime.fromisoformat(since) if since else None
- until_dt = datetime.fromisoformat(until) if until else None
+ try:
+ kind_enum = LogEntryKind(kind) if kind else None
+ except ValueError:
+ raise HTTPException(status_code=400, detail=f"Kind inválido: {kind}")
+ try:
+ since_dt = datetime.fromisoformat(since) if since else None
+ until_dt = datetime.fromisoformat(until) if until else None
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Formato de fecha inválido. Use ISO 8601 (ej: 2024-01-15T10:00:00).")
entries = runtime.logbook.query(
kind=kind_enum, since=since_dt, until=until_dt, limit=limit
)
@@ -232,3 +254,304 @@ def create_app(runtime: RuntimeApp) -> FastAPI:
await ws.close()
return app
+
+
+# ===========================================================================
+# Root HTML (UI amigable de bienvenida con dashboard vivo)
+# ===========================================================================
+
+
+_FAVICON_SVG = """
+
"""
+
+
+def _RENDER_ROOT_HTML(runtime: RuntimeApp) -> str:
+ project = runtime.project
+ tag_stats = runtime.tag_store.stats()
+ active_alarms = len(runtime.alarm_engine.active_alarms())
+ return f"""
+
+
+
+
+
VMS-Sailor Runtime · {project.name}
+
+
+
+
+
+
+
+
+
+
VMS · Sailor
+
Vessel · Management · System
+
+ {project.vessel.name} · {project.vessel.length_overall_m:.1f} m × {project.vessel.beam_max_m:.1f} m ·
+ proyecto: {project.id}
+
+
+
RUNTIME ACTIVO
+
+
+
+
+
Tags activos
+
{tag_stats['total_tags']}
+
{tag_stats['by_quality'].get('good', 0)} good · simulator vivo
+
+
+
Alarmas activas
+
{active_alarms}
+
{'requieren ACK' if active_alarms else 'sin alarmas'}
+
+
+
Sistemas
+
{len(project.systems_enabled)}
+
habilitados
+
+
+
Equipos
+
{len(project.equipment)}
+
en el proyecto
+
+
+
+
API Endpoints
+
+
+
Tags en vivo (actualizando cada 1 s)
+
Cargando...
+
+
+
+
+
+
+"""
+
diff --git a/vmssailor/studio/editors/mimic_editor.py b/vmssailor/studio/editors/mimic_editor.py
index 12d8dce..6e2abde 100644
--- a/vmssailor/studio/editors/mimic_editor.py
+++ b/vmssailor/studio/editors/mimic_editor.py
@@ -66,6 +66,7 @@ class MimicEditor(QWidget):
# Almacén in-memory de mímicos por sistema:
# { system_id_value : [SymbolSpec, ...] }
self._mimics: dict[str, list[SymbolSpec]] = {}
+ self._empty_state_active: bool = True
outer = QVBoxLayout(self)
outer.setContentsMargins(12, 12, 12, 12)
@@ -227,15 +228,46 @@ class MimicEditor(QWidget):
self.mimicChanged.emit()
def _on_palette_double_click(self, item: QListWidgetItem) -> None:
+ """Agrega un símbolo nuevo SIN reconstruir la escena.
+
+ Si ya hay símbolos arrastrados por el usuario, conservan posición.
+ El nuevo se coloca en el siguiente slot libre de una grilla 6 × N.
+ """
sys = self._current_system()
if sys is None:
return
kind = item.data(Qt.ItemDataRole.UserRole)
- new_spec = SymbolSpec(kind=kind, x=100, y=100, label="")
- self._mimics.setdefault(sys, []).append(new_spec)
- self._render_current()
+ existing = self._mimics.setdefault(sys, [])
+ x, y = self._next_free_position(existing)
+ new_spec = SymbolSpec(kind=kind, x=x, y=y, label="")
+ existing.append(new_spec)
+
+ if self._empty_state_active:
+ # En empty state hay un label central; render completo para limpiarlo
+ self._render_current()
+ else:
+ # Solo agrega el nuevo item — preserva posiciones arrastradas
+ new_item = make_symbol(new_spec)
+ self._scene.addItem(new_item)
+ new_item.setSelected(True)
+ self._symbol_count.setText(f"{len(existing)} símbolos")
self.mimicChanged.emit()
+ def _next_free_position(self, specs: list[SymbolSpec]) -> tuple[float, float]:
+ """Devuelve (x, y) en una grilla 6 × N evitando solapamientos."""
+ col_w, row_h = 110, 90
+ cols = 6
+ used = set()
+ for s in specs:
+ col = int(round((s.x - 40) / col_w))
+ row = int(round((s.y - 40) / row_h))
+ used.add((col, row))
+ for row in range(30):
+ for col in range(cols):
+ if (col, row) not in used:
+ return 40.0 + col * col_w, 40.0 + row * row_h
+ return 40.0, 40.0
+
def _render_current(self) -> None:
sys = self._current_system()
self._scene.clear()
@@ -250,12 +282,14 @@ class MimicEditor(QWidget):
)
self._symbol_count.setText("0 símbolos")
return
+ self._empty_state_active = False
self._draw_grid()
for spec in specs:
self._scene.addItem(make_symbol(spec))
self._symbol_count.setText(f"{len(specs)} símbolos")
def _draw_empty_state(self, msg: str) -> None:
+ self._empty_state_active = True
self._draw_grid()
text = self._scene.addText(msg, ui_font(11))
text.setDefaultTextColor(QColor(C_FOG))
diff --git a/vmssailor/studio/editors/symbols.py b/vmssailor/studio/editors/symbols.py
index 0630bdd..c148e51 100644
--- a/vmssailor/studio/editors/symbols.py
+++ b/vmssailor/studio/editors/symbols.py
@@ -73,22 +73,37 @@ class SymbolSpec:
class _BaseSymbol(QGraphicsItemGroup):
- """Símbolo base. Subclases dibujan en `_build()`."""
+ """Símbolo base. Subclases dibujan en `_build()`.
+
+ Cuando el usuario arrastra el símbolo, `itemChange` propaga la nueva
+ posición al `SymbolSpec` para que sobreviva a re-renders del editor.
+ """
KIND: ClassVar[str] = "base"
def __init__(self, spec: SymbolSpec) -> None:
super().__init__()
+ # NOTA: asignar self.spec ANTES de setPos para que itemChange tenga
+ # acceso al spec cuando Qt dispare ItemPositionHasChanged durante init.
+ self.spec = spec
self.setFlag(QGraphicsItem.ItemIsMovable, True)
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)
self.setPos(spec.x, spec.y)
self.setRotation(spec.rotation_deg)
- self.spec = spec
self._build()
if spec.label:
self._add_label(spec.label)
+ def itemChange(self, change, value): # type: ignore[override]
+ """Propaga la posición arrastrada al SymbolSpec en vivo."""
+ if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
+ spec = getattr(self, "spec", None)
+ if spec is not None:
+ spec.x = float(self.pos().x())
+ spec.y = float(self.pos().y())
+ return super().itemChange(change, value)
+
def _build(self) -> None:
raise NotImplementedError
diff --git a/vmssailor/studio/main_window.py b/vmssailor/studio/main_window.py
index 632ebd4..6f2dad6 100644
--- a/vmssailor/studio/main_window.py
+++ b/vmssailor/studio/main_window.py
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
QSplitter,
+ QStackedWidget,
QStatusBar,
QTabWidget,
QToolBar,
@@ -42,6 +43,7 @@ from vmssailor.studio.theme import (
)
from vmssailor.studio.widgets.system_sidebar import SystemSidebar
from vmssailor.studio.widgets.vessel_canvas import VesselCanvas
+from vmssailor.studio.widgets.welcome_screen import WelcomeScreen
from vmssailor.studio.wizard.wizard import VesselWizard
logger = logging.getLogger(__name__)
@@ -119,7 +121,10 @@ class MainWindow(QMainWindow):
self._topbar = bar
def _build_central(self) -> None:
- # Splitter horizontal: sidebar | canvas
+ # Welcome screen (visible al arrancar sin proyecto)
+ self._welcome = WelcomeScreen()
+
+ # Workspace splitter (visible cuando hay proyecto cargado)
self._splitter = QSplitter(Qt.Horizontal)
self._splitter.setHandleWidth(1)
self._splitter.setChildrenCollapsible(False)
@@ -129,7 +134,6 @@ class MainWindow(QMainWindow):
self._sidebar.setMinimumWidth(260)
self._sidebar.setMaximumWidth(380)
- # Right pane: vertical splitter with canvas on top + editor tabs below
self._canvas = VesselCanvas()
self._equipment_editor = EquipmentEditor()
self._mimic_editor = MimicEditor()
@@ -153,13 +157,18 @@ class MainWindow(QMainWindow):
self._splitter.addWidget(right_splitter)
self._splitter.setSizes([280, 1160])
- # Compose central widget: topbar (top) + splitter (rest)
+ # StackedWidget conmuta entre welcome y workspace
+ self._main_stack = QStackedWidget()
+ self._main_stack.addWidget(self._welcome) # index 0
+ self._main_stack.addWidget(self._splitter) # index 1
+
+ # Compose central widget: topbar (top) + stack (rest)
wrapper = QWidget()
outer = QVBoxLayout(wrapper)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
outer.addWidget(self._topbar)
- outer.addWidget(self._splitter, 1)
+ outer.addWidget(self._main_stack, 1)
self.setCentralWidget(wrapper)
def _build_statusbar(self) -> None:
@@ -254,6 +263,9 @@ class MainWindow(QMainWindow):
self._equipment_editor.projectMutated.connect(self._on_project_mutated)
self._tag_editor.projectMutated.connect(self._on_project_mutated)
self._alarm_editor.projectMutated.connect(self._on_project_mutated)
+ # Welcome screen CTAs disparan las mismas acciones que el menú
+ self._welcome.newProjectRequested.connect(self.on_new_wizard)
+ self._welcome.openProjectRequested.connect(self.on_open)
# ----- Slots --------------------------------------------------------
@@ -360,6 +372,8 @@ class MainWindow(QMainWindow):
self._btn_save_top.setEnabled(has_project)
self._btn_validate.setEnabled(has_project)
self._btn_compile.setEnabled(has_project)
+ # Conmuta entre welcome screen (0) y workspace (1)
+ self._main_stack.setCurrentIndex(1 if has_project else 0)
self._update_window_title()
self._update_stats()
if project is not None:
diff --git a/vmssailor/studio/widgets/vessel_canvas.py b/vmssailor/studio/widgets/vessel_canvas.py
index 61f2fd0..62d8d6c 100644
--- a/vmssailor/studio/widgets/vessel_canvas.py
+++ b/vmssailor/studio/widgets/vessel_canvas.py
@@ -214,37 +214,106 @@ class VesselCanvas(QWidget):
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
- poly = QPolygonF(
- [
- # Stern (popa) — square-ish
- QPointF(0, -b2 * 0.85),
- QPointF(0, b2 * 0.85),
- # Mid-stern out to full beam
- QPointF(L * 0.18, b2),
- # Parallel mid-body
- QPointF(L * 0.70, b2),
- # Bow taper
- QPointF(L * 0.92, b2 * 0.55),
- QPointF(L, 0),
- QPointF(L * 0.92, -b2 * 0.55),
- QPointF(L * 0.70, -b2),
- QPointF(L * 0.18, -b2),
- ]
- )
+ # ----- 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(C_SAND))
- grad.setColorAt(0.5, QColor("#94A3B8"))
- grad.setColorAt(1.0, QColor(C_FOG))
- brush = QBrush(grad)
+ 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.addPolygon(poly, pen, brush)
+ 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
@@ -258,39 +327,234 @@ class VesselCanvas(QWidget):
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)
-
- # color por sistema (simplificado: motor cyan, genset amber, otros sand)
sys_v = eq.system_id.value
- if sys_v == "main_engine":
- color = QColor(C_CYAN)
- elif sys_v == "genset":
- color = QColor(C_WARN)
- else:
- color = QColor(C_SAND)
- # Halo (the returned QGraphicsItem stays owned by the scene)
+ 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(
- center.x() - 14,
- center.y() - 14,
- 28,
- 28,
- QPen(color, 1.2, Qt.SolidLine),
- QBrush(QColor(color.red(), color.green(), color.blue(), 40)),
+ cx - w / 2 - 4, cy - 4, 5, 5, turbo_pen, QBrush(QColor("#94A3B8"))
)
- # Dot
+ # 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(
- center.x() - 6,
- center.y() - 6,
- 12,
- 12,
- QPen(QColor(C_ABYSS), 1.5),
- QBrush(color),
+ cx - w / 2 + 3, cy - 2, 4, 4, pen, QBrush(QColor("#5A6B7F"))
)
- # Label
- text = self._scene.addText(eq.tag_prefix, ui_font(8))
- text.setDefaultTextColor(QColor(C_FOAM))
- text.setPos(center.x() - text.boundingRect().width() / 2, center.y() + 12)
+ # 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
diff --git a/vmssailor/studio/widgets/welcome_screen.py b/vmssailor/studio/widgets/welcome_screen.py
new file mode 100644
index 0000000..7a65d4d
--- /dev/null
+++ b/vmssailor/studio/widgets/welcome_screen.py
@@ -0,0 +1,231 @@
+"""Welcome screen del Studio.
+
+Pantalla inicial cuando no hay proyecto abierto. Muestra al usuario las dos
+acciones principales (crear nuevo / abrir existente) como botones grandes,
+más una lista de proyectos recientes y enlaces a docs.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtGui import QIcon, QPixmap
+from PySide6.QtWidgets import (
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from vmssailor.studio.theme import (
+ C_ABYSS,
+ C_CYAN,
+ C_CYAN_DEEP,
+ C_FOAM,
+ C_FOG,
+ C_HORIZON,
+ C_MIDNIGHT,
+ C_SAND,
+ C_STEEL,
+ display_font,
+ mono_font,
+ ui_font,
+)
+
+
+BRAND_ROOT = Path(__file__).resolve().parents[2] / "docs" / "brand"
+
+
+class WelcomeScreen(QWidget):
+ """Pantalla de bienvenida con CTAs grandes."""
+
+ newProjectRequested = Signal()
+ openProjectRequested = Signal()
+ openRecentRequested = Signal(str) # path
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("welcomeScreen")
+ self.setStyleSheet(
+ f"#welcomeScreen {{ background: {C_ABYSS}; }}"
+ )
+
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(0, 0, 0, 0)
+ outer.setSpacing(0)
+ outer.addStretch(1)
+
+ center = QWidget()
+ center.setMaximumWidth(880)
+ cl = QVBoxLayout(center)
+ cl.setSpacing(0)
+ cl.setContentsMargins(48, 0, 48, 0)
+
+ # Hero with logo + title
+ hero = QHBoxLayout()
+ hero.setSpacing(24)
+
+ logo_path = BRAND_ROOT / "logo-mark.svg"
+ if logo_path.exists():
+ logo = QLabel()
+ pix = QIcon(str(logo_path)).pixmap(120, 120)
+ logo.setPixmap(pix)
+ hero.addWidget(logo)
+
+ title_box = QVBoxLayout()
+ title_box.setSpacing(4)
+ title = QLabel("VMS-Sailor Studio")
+ title.setFont(display_font(36, 700))
+ title.setStyleSheet(f"color: {C_FOAM};")
+ title_box.addWidget(title)
+ sub = QLabel("VESSEL · MANAGEMENT · SYSTEM")
+ sub.setFont(ui_font(11))
+ sub.setStyleSheet(f"color: {C_HORIZON}; letter-spacing: 4px;")
+ title_box.addWidget(sub)
+ body = QLabel(
+ "Herramienta de ingeniería para configurar el VMS-Sailor de cada buque. "
+ "Crea un nuevo proyecto desde el wizard o abre un .vmsproj existente."
+ )
+ body.setWordWrap(True)
+ body.setFont(ui_font(11))
+ body.setStyleSheet(f"color: {C_SAND}; margin-top: 12px;")
+ title_box.addWidget(body)
+ title_box.addStretch(1)
+ hero.addLayout(title_box, 1)
+ cl.addLayout(hero)
+ cl.addSpacing(32)
+
+ # Two big CTAs
+ ctas = QHBoxLayout()
+ ctas.setSpacing(16)
+ ctas.addWidget(
+ self._make_cta(
+ title="Nuevo desde wizard",
+ subtitle="Wizard de 8 pasos: tipo de buque, plantilla, dimensiones,\n"
+ "sistemas, equipos, topología, confirmación.",
+ signal=self.newProjectRequested,
+ primary=True,
+ shortcut="Ctrl+N",
+ ),
+ 1,
+ )
+ ctas.addWidget(
+ self._make_cta(
+ title="Abrir proyecto",
+ subtitle="Carga un archivo .vmsproj existente desde disco.\n"
+ "Edita equipos, mímicos, tags y alarmas.",
+ signal=self.openProjectRequested,
+ primary=False,
+ shortcut="Ctrl+O",
+ ),
+ 1,
+ )
+ cl.addLayout(ctas)
+ cl.addSpacing(28)
+
+ # Quick links / docs
+ links_label = QLabel("DOCUMENTACIÓN")
+ links_label.setFont(ui_font(9))
+ links_label.setStyleSheet(
+ f"color: {C_FOG}; letter-spacing: 2.5px; margin-bottom: 8px;"
+ )
+ cl.addWidget(links_label)
+
+ links_row = QHBoxLayout()
+ links_row.setSpacing(8)
+ for txt, hint in (
+ ("Sprint 0 — Fundaciones", "docs/architecture.md"),
+ ("Sistema de coordenadas", "docs/coords.md"),
+ ("Design System", "docs/design_system.md"),
+ ("Mockups visuales", "docs/mockups/index.html"),
+ ):
+ chip = QLabel(f" {txt} ")
+ chip.setFont(mono_font(9))
+ chip.setStyleSheet(
+ f"color: {C_SAND}; background: {C_MIDNIGHT}; "
+ f"border: 1px solid {C_STEEL}; border-radius: 12px; padding: 4px 10px;"
+ )
+ chip.setToolTip(hint)
+ links_row.addWidget(chip)
+ links_row.addStretch(1)
+ cl.addLayout(links_row)
+
+ outer.addWidget(center, alignment=Qt.AlignmentFlag.AlignHCenter)
+ outer.addStretch(2)
+
+ footer = QLabel(
+ "Propiedad intelectual de Álvaro · El Studio no se distribuye al cliente"
+ )
+ footer.setFont(mono_font(9))
+ footer.setStyleSheet(f"color: {C_FOG}; padding: 16px;")
+ footer.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ outer.addWidget(footer)
+
+ def _make_cta(
+ self,
+ title: str,
+ subtitle: str,
+ signal,
+ primary: bool,
+ shortcut: str = "",
+ ) -> QFrame:
+ frame = QFrame()
+ frame.setCursor(Qt.CursorShape.PointingHandCursor)
+ bg_hover = C_CYAN_DEEP if primary else C_STEEL
+ border = C_CYAN if primary else C_IRON_FALLBACK
+ bg = C_MIDNIGHT if not primary else "rgba(0,217,255,0.08)"
+ frame.setStyleSheet(
+ f"""
+ QFrame {{
+ background: {bg};
+ border: 1px solid {border};
+ border-radius: 14px;
+ min-height: 180px;
+ }}
+ QFrame:hover {{
+ background: {bg_hover};
+ border-color: {C_CYAN};
+ }}
+ """
+ )
+ lay = QVBoxLayout(frame)
+ lay.setContentsMargins(24, 22, 24, 22)
+ lay.setSpacing(8)
+
+ t = QLabel(title)
+ t.setFont(display_font(22, 700))
+ t.setStyleSheet(f"color: {C_FOAM};")
+ lay.addWidget(t)
+
+ s = QLabel(subtitle)
+ s.setFont(ui_font(10))
+ s.setStyleSheet(f"color: {C_FOG};")
+ s.setWordWrap(True)
+ lay.addWidget(s)
+
+ lay.addStretch(1)
+ kbd_row = QHBoxLayout()
+ kbd_row.addStretch(1)
+ if shortcut:
+ kbd = QLabel(shortcut)
+ kbd.setFont(mono_font(9))
+ kbd.setStyleSheet(
+ f"color: {C_FOG}; background: {C_ABYSS}; "
+ f"border: 1px solid {C_STEEL}; border-radius: 4px; padding: 2px 8px;"
+ )
+ kbd_row.addWidget(kbd)
+ arrow = QLabel("→")
+ arrow.setFont(display_font(22, 700))
+ arrow.setStyleSheet(f"color: {C_CYAN}; margin-left: 8px;")
+ kbd_row.addWidget(arrow)
+ lay.addLayout(kbd_row)
+
+ frame.mousePressEvent = lambda _ev: signal.emit() # type: ignore[method-assign]
+ return frame
+
+
+# Fallback color para borde no-primary (no exportado en theme aún si quieres)
+C_IRON_FALLBACK = "#2C3E5C"