feat: AR-VMS-Seaman initial commit — Python FastAPI + PySide6 (runtime server + desktop studio client)

This commit is contained in:
2026-07-03 12:16:31 -04:00
parent 7390d5cd51
commit 2302e963b2
12 changed files with 1144 additions and 136 deletions
+328 -5
View File
@@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#04111F"/>
<circle cx="32" cy="32" r="22" fill="none" stroke="#00D9FF" stroke-width="2.2"/>
<path d="M 32 10 L 30 6 L 34 6 Z" fill="#00D9FF"/>
<g transform="translate(32,33) scale(0.55)">
<path d="M -32 6 C -28 -1, -22 -5, -14 -6 L 22 -6 L 30 -2 L 30 6 L 24 11 L -26 11 Z" fill="#E6EAF0"/>
<path d="M -10 -6 L -4 -14 L 14 -14 L 18 -6 Z" fill="#FFFFFF"/>
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.9" fill="none"/>
</g>
</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"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VMS-Sailor Runtime · {project.name}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
<style>
:root {{
--abyss: #04111F; --midnight: #0A1A2E; --steel: #1A2B42; --iron: #2C3E5C;
--fog: #7C8B9F; --sand: #E6EAF0; --foam: #F2F5F9;
--cyan: #00D9FF; --cyan-deep: #1B7FB5; --horizon: #5BC0EB;
--ok: #00E08A; --warn: #FFB020; --emergency: #FF3B47;
}}
*, *::before, *::after {{ box-sizing: border-box; }}
html, body {{
margin: 0; padding: 0; min-height: 100vh;
background: linear-gradient(135deg, #04111F 0%, #0A1A2E 60%, #1A2B42 100%);
color: var(--sand);
font-family: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
font-size: 14px;
}}
.container {{ max-width: 1280px; margin: 0 auto; padding: 32px; }}
.hero {{
display: flex; align-items: center; gap: 24px;
padding: 24px 32px;
background: linear-gradient(135deg, rgba(0,217,255,0.10), rgba(0,217,255,0.02));
border: 1px solid rgba(0,217,255,0.25);
border-radius: 16px;
margin-bottom: 24px;
}}
.hero-logo {{
width: 84px; height: 84px;
display: flex; align-items: center; justify-content: center;
filter: drop-shadow(0 8px 24px rgba(0,217,255,0.4));
flex-shrink: 0;
}}
.hero h1 {{
margin: 0;
font-size: 32px; font-weight: 700; letter-spacing: -1px;
color: var(--foam);
}}
.hero h1 .accent {{ color: var(--cyan); font-weight: 300; }}
.hero .subtitle {{
color: var(--horizon); margin-top: 4px;
font-size: 13px; letter-spacing: 2px; text-transform: uppercase;
}}
.hero .vessel-info {{
color: var(--sand); margin-top: 8px;
font-family: "JetBrains Mono", Consolas, monospace; font-size: 12px;
}}
.hero .pill {{
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; margin-left: auto;
background: rgba(0,224,138,0.15); border: 1px solid rgba(0,224,138,0.4);
border-radius: 999px; color: var(--ok);
font-size: 12px; font-weight: 700; letter-spacing: 0.5px;
}}
.pill .dot {{
width: 8px; height: 8px; border-radius: 50%;
background: var(--ok); box-shadow: 0 0 10px var(--ok);
animation: pulse 2s ease-in-out infinite;
}}
@keyframes pulse {{ 50% {{ opacity: 0.5; transform: scale(1.4); }} }}
.stats-row {{
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
margin-bottom: 24px;
}}
.stat {{
padding: 16px 18px;
background: var(--midnight); border: 1px solid var(--steel);
border-radius: 10px;
}}
.stat .label {{
font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
color: var(--fog); font-weight: 700;
}}
.stat .value {{
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 28px; font-weight: 600; color: var(--foam);
margin-top: 6px; letter-spacing: -0.5px;
}}
.stat .value.alarm {{ color: var(--emergency); }}
.stat .sub {{ font-size: 11px; color: var(--fog); margin-top: 2px; }}
h2 {{
font-size: 12px; letter-spacing: 2px; text-transform: uppercase;
color: var(--fog); font-weight: 700;
margin: 24px 0 12px 0;
}}
.actions {{
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px; margin-bottom: 24px;
}}
.action {{
display: block; text-decoration: none;
padding: 16px 18px;
background: var(--midnight); border: 1px solid var(--steel);
border-radius: 10px;
transition: all 160ms;
}}
.action:hover {{
border-color: var(--cyan); transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.5), 0 0 16px rgba(0,217,255,0.3);
}}
.action .title {{
color: var(--foam); font-weight: 600; font-size: 14px;
display: flex; align-items: center; gap: 8px;
}}
.action .title::after {{ content: ""; color: var(--cyan); margin-left: auto; }}
.action .desc {{
color: var(--fog); font-size: 12px; margin-top: 4px;
}}
.action .url {{
color: var(--cyan); font-family: "JetBrains Mono", Consolas, monospace;
font-size: 11px; margin-top: 6px;
}}
#tags-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
}}
.tag-tile {{
padding: 10px 12px;
background: var(--midnight); border: 1px solid var(--steel);
border-radius: 8px;
font-family: "JetBrains Mono", Consolas, monospace;
}}
.tag-tile .tid {{ color: var(--cyan); font-size: 10px; }}
.tag-tile .val {{
color: var(--foam); font-size: 18px; font-weight: 600;
margin-top: 4px;
}}
.tag-tile .meta {{
color: var(--fog); font-size: 9px; margin-top: 2px;
}}
footer {{
margin-top: 32px; padding-top: 16px;
border-top: 1px solid var(--steel);
color: var(--fog); font-size: 11px;
font-family: "JetBrains Mono", Consolas, monospace;
display: flex; justify-content: space-between;
}}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<div class="hero-logo">
<svg viewBox="0 0 120 120" width="84" height="84">
<circle cx="60" cy="60" r="46" fill="none" stroke="#00D9FF" stroke-width="3"/>
<path d="M 60 18 L 56 8 L 64 8 Z" fill="#00D9FF"/>
<g transform="translate(60,60)">
<path d="M -32 6 C -28 -1, -22 -5, -14 -6 L 22 -6 L 30 -2 L 30 6 L 24 11 L -26 11 Z"
fill="#E6EAF0" stroke="#04111F" stroke-width="0.8"/>
<path d="M -10 -6 L -4 -14 L 14 -14 L 18 -6 Z" fill="#FFFFFF" stroke="#04111F" stroke-width="0.6"/>
<path d="M -2 -14 L 0 -19 L 10 -19 L 12 -14 Z" fill="#FFFFFF" stroke="#04111F" stroke-width="0.5"/>
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.9" fill="none"/>
</g>
</svg>
</div>
<div>
<h1>VMS<span class="accent"> · </span>Sailor</h1>
<div class="subtitle">Vessel · Management · System</div>
<div class="vessel-info">
{project.vessel.name} · {project.vessel.length_overall_m:.1f} m × {project.vessel.beam_max_m:.1f} m ·
proyecto: {project.id}
</div>
</div>
<span class="pill"><span class="dot"></span>RUNTIME ACTIVO</span>
</div>
<div class="stats-row">
<div class="stat">
<div class="label">Tags activos</div>
<div class="value">{tag_stats['total_tags']}</div>
<div class="sub">{tag_stats['by_quality'].get('good', 0)} good · simulator vivo</div>
</div>
<div class="stat">
<div class="label">Alarmas activas</div>
<div class="value {'alarm' if active_alarms > 0 else ''}">{active_alarms}</div>
<div class="sub">{'requieren ACK' if active_alarms else 'sin alarmas'}</div>
</div>
<div class="stat">
<div class="label">Sistemas</div>
<div class="value">{len(project.systems_enabled)}</div>
<div class="sub">habilitados</div>
</div>
<div class="stat">
<div class="label">Equipos</div>
<div class="value">{len(project.equipment)}</div>
<div class="sub">en el proyecto</div>
</div>
</div>
<h2>API Endpoints</h2>
<div class="actions">
<a class="action" href="/docs">
<div class="title">Documentación interactiva (Swagger UI)</div>
<div class="desc">Probar todos los endpoints de la API en el navegador.</div>
<div class="url">/docs</div>
</a>
<a class="action" href="/health">
<div class="title">Health</div>
<div class="desc">Estado del runtime, tag store, historian, alarmas.</div>
<div class="url">/health</div>
</a>
<a class="action" href="/project">
<div class="title">Project</div>
<div class="desc">Info del proyecto cargado (buque, sistemas, stats).</div>
<div class="url">/project</div>
</a>
<a class="action" href="/tags">
<div class="title">Tags (snapshot)</div>
<div class="desc">Lista completa de tags con valores actuales del simulator.</div>
<div class="url">/tags</div>
</a>
<a class="action" href="/alarms">
<div class="title">Alarms</div>
<div class="desc">Histórico de eventos de alarma desde el arranque.</div>
<div class="url">/alarms</div>
</a>
<a class="action" href="/logbook">
<div class="title">Log Book</div>
<div class="desc">Bitácora naval con arranques de motor, snapshots, alarmas.</div>
<div class="url">/logbook</div>
</a>
</div>
<h2>Tags en vivo (actualizando cada 1 s)</h2>
<div id="tags-grid">Cargando...</div>
<footer>
<span>VMS-Sailor Runtime · v{__version__}</span>
<span>Para conectar el cliente desktop: <code>uv run python runtime_client_main.py</code></span>
</footer>
</div>
<script>
async function refreshTags() {{
try {{
const r = await fetch('/tags');
const data = await r.json();
const grid = document.getElementById('tags-grid');
grid.innerHTML = data.map(t => {{
const val = typeof t.value === 'number' ? t.value.toFixed(2) : String(t.value ?? '');
const unit = t.unit_si && t.unit_si !== 'none' ? ' ' + t.unit_si : '';
const q = t.quality || '';
const color = q === 'good' ? '#00E08A' : '#FFB020';
return `<div class="tag-tile">
<div class="tid">${{t.id}}</div>
<div class="val">${{val}}${{unit}}</div>
<div class="meta" style="color:${{color}}">q=${{q}}</div>
</div>`;
}}).join('');
}} catch (e) {{
const msg = document.createTextNode('Error de conexión: ' + String(e));
const div = document.createElement('div');
div.style.color = 'var(--emergency)';
div.appendChild(msg);
const grid = document.getElementById('tags-grid');
grid.innerHTML = '';
grid.appendChild(div);
}}
}}
refreshTags();
setInterval(refreshTags, 1000);
</script>
</body>
</html>"""
+37 -3
View File
@@ -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))
+17 -2
View File
@@ -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
+18 -4
View File
@@ -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:
+312 -48
View File
@@ -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
+231
View File
@@ -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"