diff --git a/.sync_aed30ff16c65.db b/.sync_aed30ff16c65.db new file mode 100644 index 0000000..a99c648 Binary files /dev/null and b/.sync_aed30ff16c65.db differ diff --git a/docs/brand/favicon.svg b/docs/brand/favicon.svg index 4f20a4d..5f151ce 100644 --- a/docs/brand/favicon.svg +++ b/docs/brand/favicon.svg @@ -5,13 +5,19 @@ + + + + - - - - + + + + + + + + - - diff --git a/docs/brand/logo-mark.svg b/docs/brand/logo-mark.svg index fa4903f..cead07e 100644 --- a/docs/brand/logo-mark.svg +++ b/docs/brand/logo-mark.svg @@ -1,32 +1,106 @@ - + - - + + + + + + + + + + + - + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - diff --git a/docs/brand/logo-mono.svg b/docs/brand/logo-mono.svg index 9241eb4..c00593a 100644 --- a/docs/brand/logo-mono.svg +++ b/docs/brand/logo-mono.svg @@ -1,18 +1,27 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - VMS · Sailor - VESSEL · MANAGEMENT · SYSTEM + VMS · Sailor + VESSEL · MANAGEMENT · SYSTEM diff --git a/docs/brand/logo.svg b/docs/brand/logo.svg index 86d2a59..f2b1a0e 100644 --- a/docs/brand/logo.svg +++ b/docs/brand/logo.svg @@ -1,52 +1,85 @@ - + - + - + - - + + + - - + + + + + + - - - - - - + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - VMS - · - Sailor - + + + VMS + · + Sailor + VESSEL · MANAGEMENT · SYSTEM diff --git a/docs/mockups/runtime_overview.html b/docs/mockups/runtime_overview.html index b96d2f2..2bcda99 100644 --- a/docs/mockups/runtime_overview.html +++ b/docs/mockups/runtime_overview.html @@ -357,13 +357,18 @@ left: 0; right: 0; bottom: 0; background: linear-gradient(180deg, #1B7FB5 0%, #00D9FF 100%); } + /* Surface highlight per fill color via currentColor on the fill */ + .tank-fill { color: rgba(0,217,255,0.55); } + .tank-fill.warn { background: linear-gradient(180deg, #C0760F 0%, #FFB020 100%); color: rgba(255,176,32,0.55); } + .tank-fill.water { background: linear-gradient(180deg, #007F4E 0%, #00E08A 100%); color: rgba(0,224,138,0.55); } + .tank-fill.black { background: linear-gradient(180deg, #5A6B7F 0%, #94A3B8 100%); color: rgba(148,162,177,0.55); } .tank-fill::before { content: ""; position: absolute; - top: -3px; left: 0; right: 0; height: 6px; - background: rgba(0,217,255,0.5); - filter: blur(2px); + top: 0; left: 0; right: 0; height: 4px; + background: currentColor; + box-shadow: 0 0 6px currentColor; + pointer-events: none; } - .tank-fill.warn { background: linear-gradient(180deg, #C0760F 0%, #FFB020 100%); } .tank-label { font-family: var(--f-mono); font-size: 11px; color: var(--c-fog); } .tank-pct { font-family: var(--f-mono); @@ -684,7 +689,7 @@
-
+
91%
WATER
@@ -698,7 +703,7 @@
-
+
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...
+ +
+ VMS-Sailor Runtime · v{__version__} + Para conectar el cliente desktop: uv run python runtime_client_main.py +
+
+ + + +""" + 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"