sprint-1: Studio shell PySide6 + wizard 8 pasos
Sprint 1 entrega el shell del Studio operativo. Para correrlo:
uv run vms-studio
Componentes:
vmssailor/studio/theme.py
- Aplica design tokens del Sprint 0 (paleta Deep Ocean) a PySide6
- QSS global completo + QPalette + fuentes Inter/Space Grotesk/JetBrains Mono
vmssailor/studio/app.py
- StudioApp (QApplication) con tema, logo, version
- run_studio() entry point
vmssailor/studio/main_window.py
- Layout: topbar / sidebar / canvas central / statusbar
- Menus: Proyecto (Nuevo wizard, Abrir, Guardar, Guardar como, Salir),
Edicion/Vista (stubs Sprint 2), Ayuda
- Operaciones funcionales: New from wizard, Open .vmsproj, Save, Save As,
Validate (cross-entity), Compile (placeholder)
- Reloj live + statusbar con stats del proyecto
vmssailor/studio/widgets/system_sidebar.py
- Sidebar dinamico que muestra wizard steps + sistemas habilitados + disponibles
- Lee catalogo maestro y proyecto activo
- Senial systemActivated(SystemId) al doble-click
vmssailor/studio/widgets/vessel_canvas.py
- QGraphicsView central con grilla naval (1m por celda)
- Renderiza silueta del buque en planta + mamparos + equipos
- ship_to_scene() transformacion canonica naval -> escena
- Centerline + Pp axis marcados
- Ruler de eslora con marcas cada 5m
- Zoom con wheel + scroll-pan, label de zoom% en header
vmssailor/studio/wizard/ - QWizard 8 pasos
- step_01_vessel_type: tipo + subtipo + nombre proyecto + cliente
- step_02_template: selector con biblioteca curada (Sunseeker, Ferretti, blank)
- step_03_dimensions: LOA/manga/calado/mamparos con pre-fill de plantilla
- step_04_systems: checkboxes agrupados por categoria con pre-select por default_for
- step_57_placeholder: stubs visuales para Sprint 2 (pasos 5, 6, 7)
- step_08_confirm: resumen HTML completo del proyecto a crear
- VesselWizard.build_project() construye un Project valido
Tests (tests/studio/, 11 nuevos, total 110/110):
- pytest-qt offscreen
- Smoke tests del MainWindow, wizard, canvas, sidebar
- test_ship_to_scene_mapping (transformacion naval->escena)
Stack agregado:
- PySide6 6.11.1
- pytest-qt 4.5.0
Decisiones autonomas:
- QFont.setWeight requiere QFont.Weight enum en PySide6 6.11 (no int)
- QFrame.Shape.NoFrame (no QListWidget.NoFrame) para PySide6 6.11
- Pasos 5-7 quedan placeholders explicitos: Sprint 2 implementa rule engine
- Wizard crea Project sin equipment todavia (Sprint 2 los agrega)
Criterios de aceptacion Sprint 1:
- uv run vms-studio: abre ventana operativa
- 110/110 pytest verde
- ruff clean
- Smoke offscreen: MainWindow + Wizard + Canvas + Sidebar OK
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""Wizard de creación de proyecto de buque (Studio).
|
||||
|
||||
Sprint 1: pasos 1-4 funcionales + 5-8 placeholders.
|
||||
Sprint 2: pasos 5-8 implementados con rule_engine.
|
||||
"""
|
||||
|
||||
from vmssailor.studio.wizard.wizard import VesselWizard
|
||||
|
||||
__all__ = ["VesselWizard"]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Paso 1: tipo y subtipo del buque."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QWizardPage,
|
||||
)
|
||||
|
||||
from vmssailor.core.enums import VesselSubtype, VesselType
|
||||
from vmssailor.studio.theme import C_FOG, ui_font
|
||||
|
||||
_SUBTYPES_BY_TYPE = {
|
||||
VesselType.YACHT_MOTOR: [
|
||||
VesselSubtype.PLANING,
|
||||
VesselSubtype.SEMI_PLANING,
|
||||
VesselSubtype.DISPLACEMENT,
|
||||
],
|
||||
VesselType.YACHT_SAIL: [VesselSubtype.DISPLACEMENT],
|
||||
VesselType.FISHING: [
|
||||
VesselSubtype.PURSE_SEINER,
|
||||
VesselSubtype.TRAWLER,
|
||||
VesselSubtype.LONGLINER,
|
||||
],
|
||||
VesselType.PATROL: [VesselSubtype.COASTAL, VesselSubtype.OCEANIC],
|
||||
VesselType.FERRY: [VesselSubtype.PASSENGER, VesselSubtype.ROLL_ON_ROLL_OFF],
|
||||
VesselType.OFFSHORE_SUPPORT: [VesselSubtype.AHTS, VesselSubtype.PSV],
|
||||
}
|
||||
|
||||
|
||||
_TYPE_LABELS = {
|
||||
VesselType.YACHT_MOTOR: "Yate motor",
|
||||
VesselType.YACHT_SAIL: "Yate vela",
|
||||
VesselType.FISHING: "Pesquero",
|
||||
VesselType.PATROL: "Patrullero",
|
||||
VesselType.FERRY: "Ferry",
|
||||
VesselType.OFFSHORE_SUPPORT: "Offshore support",
|
||||
}
|
||||
|
||||
_SUBTYPE_LABELS = {
|
||||
VesselSubtype.PLANING: "Planeo",
|
||||
VesselSubtype.SEMI_PLANING: "Semi-planeo",
|
||||
VesselSubtype.DISPLACEMENT: "Desplazamiento",
|
||||
VesselSubtype.PURSE_SEINER: "Cerquero",
|
||||
VesselSubtype.TRAWLER: "Arrastrero",
|
||||
VesselSubtype.LONGLINER: "Palangrero",
|
||||
VesselSubtype.COASTAL: "Costero",
|
||||
VesselSubtype.OCEANIC: "Oceánico",
|
||||
VesselSubtype.PASSENGER: "Pasajeros",
|
||||
VesselSubtype.ROLL_ON_ROLL_OFF: "Ro-ro",
|
||||
VesselSubtype.AHTS: "AHTS",
|
||||
VesselSubtype.PSV: "PSV",
|
||||
VesselSubtype.OTHER: "Otro",
|
||||
}
|
||||
|
||||
|
||||
class Step01VesselType(QWizardPage):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setTitle("Paso 1 · Tipo de buque")
|
||||
self.setSubTitle(
|
||||
"Elige la categoría principal del buque y su subcategoría. "
|
||||
"Esto filtra las plantillas disponibles en el paso 2."
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(18)
|
||||
|
||||
# Project basics
|
||||
intro = QLabel(
|
||||
"Cada proyecto VMS-Sailor corresponde a UN buque de UN cliente.\n"
|
||||
"Aquí defines lo básico para empezar; el wizard te guiará por los demás pasos."
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
intro.setStyleSheet(f"color: {C_FOG};")
|
||||
intro.setFont(ui_font(10))
|
||||
layout.addWidget(intro)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setHorizontalSpacing(20)
|
||||
form.setVerticalSpacing(12)
|
||||
|
||||
self._project_name = QLineEdit()
|
||||
self._project_name.setPlaceholderText('Ej: M/Y Aurora — Sunseeker 76')
|
||||
form.addRow("Nombre del proyecto:", self._project_name)
|
||||
|
||||
self._customer = QLineEdit()
|
||||
self._customer.setPlaceholderText("Ej: Acme Yachts S.A.")
|
||||
form.addRow("Cliente / Armador:", self._customer)
|
||||
|
||||
self._type_combo = QComboBox()
|
||||
for vt in VesselType:
|
||||
self._type_combo.addItem(_TYPE_LABELS[vt], vt.value)
|
||||
form.addRow("Tipo de buque:", self._type_combo)
|
||||
|
||||
self._subtype_combo = QComboBox()
|
||||
form.addRow("Subcategoría:", self._subtype_combo)
|
||||
|
||||
layout.addLayout(form)
|
||||
layout.addStretch(1)
|
||||
|
||||
# Register fields (no asterisk so they're optional at validation time;
|
||||
# we provide defaults during build_project).
|
||||
from vmssailor.studio.wizard.wizard import (
|
||||
F_CUSTOMER,
|
||||
F_PROJECT_NAME,
|
||||
F_VESSEL_SUBTYPE,
|
||||
F_VESSEL_TYPE,
|
||||
)
|
||||
|
||||
self.registerField(F_PROJECT_NAME + "*", self._project_name)
|
||||
self.registerField(F_CUSTOMER, self._customer)
|
||||
self.registerField(F_VESSEL_TYPE, self._type_combo, "currentData", "currentIndexChanged")
|
||||
self.registerField(
|
||||
F_VESSEL_SUBTYPE, self._subtype_combo, "currentData", "currentIndexChanged"
|
||||
)
|
||||
|
||||
self._type_combo.currentIndexChanged.connect(self._populate_subtypes)
|
||||
self._populate_subtypes()
|
||||
|
||||
def _populate_subtypes(self) -> None:
|
||||
self._subtype_combo.clear()
|
||||
v_type = VesselType(self._type_combo.currentData())
|
||||
subs = _SUBTYPES_BY_TYPE.get(v_type, [VesselSubtype.OTHER])
|
||||
for s in subs:
|
||||
self._subtype_combo.addItem(_SUBTYPE_LABELS[s], s.value)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Paso 2: selección de plantilla de biblioteca curada."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QWizardPage,
|
||||
)
|
||||
|
||||
from vmssailor.library import load_library
|
||||
from vmssailor.studio.theme import C_FOG, mono_font, ui_font
|
||||
|
||||
|
||||
class Step02Template(QWizardPage):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setTitle("Paso 2 · Plantilla de buque")
|
||||
self.setSubTitle(
|
||||
"Elige una plantilla de la biblioteca curada. Las dimensiones del paso 3 "
|
||||
"se pre-rellenarán pero puedes ajustar después."
|
||||
)
|
||||
|
||||
outer = QHBoxLayout(self)
|
||||
outer.setSpacing(20)
|
||||
|
||||
# Left: list of templates filtered by type
|
||||
self._list = QListWidget()
|
||||
self._list.setMinimumWidth(280)
|
||||
outer.addWidget(self._list, 1)
|
||||
|
||||
# Right: details
|
||||
right = QVBoxLayout()
|
||||
outer.addLayout(right, 2)
|
||||
|
||||
self._name_label = QLabel("(Selecciona una plantilla)")
|
||||
self._name_label.setObjectName("title")
|
||||
self._name_label.setFont(ui_font(13))
|
||||
right.addWidget(self._name_label)
|
||||
|
||||
self._meta_label = QLabel("")
|
||||
self._meta_label.setFont(mono_font(10))
|
||||
self._meta_label.setStyleSheet(f"color: {C_FOG};")
|
||||
right.addWidget(self._meta_label)
|
||||
|
||||
self._desc_label = QLabel("")
|
||||
self._desc_label.setWordWrap(True)
|
||||
right.addWidget(self._desc_label)
|
||||
|
||||
right.addStretch(1)
|
||||
|
||||
# Hidden vessel_name field for the next step
|
||||
self._vessel_name = QLineEdit()
|
||||
self._vessel_name.hide()
|
||||
|
||||
from vmssailor.studio.wizard.wizard import F_TEMPLATE_ID, F_VESSEL_NAME
|
||||
|
||||
self.registerField(F_TEMPLATE_ID, self, "selected_template_id", "templateChanged")
|
||||
self.registerField(F_VESSEL_NAME, self._vessel_name)
|
||||
|
||||
self._templates: dict[str, dict] = {}
|
||||
self._selected_id: str | None = None
|
||||
self._load_templates()
|
||||
|
||||
self._list.currentItemChanged.connect(self._on_selection_changed)
|
||||
|
||||
# ----- Property API (for QWizard field) -------------------------------
|
||||
|
||||
from PySide6.QtCore import Property, Signal # type: ignore
|
||||
|
||||
templateChanged = Signal()
|
||||
|
||||
def get_selected_template_id(self) -> str:
|
||||
return self._selected_id or ""
|
||||
|
||||
def set_selected_template_id(self, value: str) -> None:
|
||||
self._selected_id = value
|
||||
self.templateChanged.emit()
|
||||
|
||||
selected_template_id = Property(
|
||||
str, fget=get_selected_template_id, fset=set_selected_template_id, notify=templateChanged
|
||||
)
|
||||
|
||||
# ----- Internals ------------------------------------------------------
|
||||
|
||||
def initializePage(self) -> None:
|
||||
from vmssailor.studio.wizard.wizard import F_VESSEL_TYPE
|
||||
|
||||
type_str = self.field(F_VESSEL_TYPE)
|
||||
self._refresh_list(type_str)
|
||||
|
||||
def _load_templates(self) -> None:
|
||||
try:
|
||||
result = load_library()
|
||||
for v in result.vessels:
|
||||
self._templates[v.id] = {
|
||||
"id": v.id,
|
||||
"name": v.name,
|
||||
"type": v.type.value,
|
||||
"length_overall_m": v.length_overall_m,
|
||||
"beam_max_m": v.beam_max_m,
|
||||
"draft_m": v.draft_m,
|
||||
"description": v.description,
|
||||
}
|
||||
except Exception:
|
||||
# Si la biblioteca falla, no rompemos el wizard.
|
||||
pass
|
||||
|
||||
# Also add a "blank" template
|
||||
self._templates["__blank__"] = {
|
||||
"id": "__blank__",
|
||||
"name": "Plantilla en blanco",
|
||||
"type": "*",
|
||||
"length_overall_m": 24.0,
|
||||
"beam_max_m": 5.5,
|
||||
"draft_m": 1.8,
|
||||
"description": "Empezar desde cero, sin plantilla de buque. Defines todas las dimensiones manualmente.",
|
||||
}
|
||||
|
||||
def _refresh_list(self, type_filter: str | None) -> None:
|
||||
self._list.clear()
|
||||
for tid, t in self._templates.items():
|
||||
if type_filter and t["type"] not in (type_filter, "*"):
|
||||
continue
|
||||
label = (
|
||||
f"{t['name']}\n"
|
||||
f" {t['length_overall_m']:.1f} × {t['beam_max_m']:.1f} m"
|
||||
if tid != "__blank__"
|
||||
else t["name"]
|
||||
)
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.UserRole, tid)
|
||||
self._list.addItem(item)
|
||||
if self._list.count() > 0:
|
||||
self._list.setCurrentRow(0)
|
||||
|
||||
def _on_selection_changed(self, current: QListWidgetItem | None) -> None:
|
||||
if not current:
|
||||
return
|
||||
tid = current.data(Qt.UserRole)
|
||||
t = self._templates.get(tid)
|
||||
if not t:
|
||||
return
|
||||
self._selected_id = tid
|
||||
self._vessel_name.setText(t["name"])
|
||||
self._name_label.setText(t["name"])
|
||||
self._meta_label.setText(
|
||||
f"id: {t['id']} LOA {t['length_overall_m']:.1f} m · "
|
||||
f"manga {t['beam_max_m']:.1f} m · calado {t['draft_m']:.1f} m"
|
||||
)
|
||||
self._desc_label.setText(t.get("description", "") or "(sin descripción)")
|
||||
self.templateChanged.emit()
|
||||
self.completeChanged.emit()
|
||||
|
||||
def isComplete(self) -> bool:
|
||||
return self._selected_id is not None
|
||||
|
||||
def get_template(self) -> dict | None:
|
||||
if self._selected_id:
|
||||
return self._templates.get(self._selected_id)
|
||||
return None
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Paso 3: dimensiones físicas del buque."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QWizardPage,
|
||||
)
|
||||
|
||||
from vmssailor.studio.theme import C_FOG, ui_font
|
||||
|
||||
|
||||
class Step03Dimensions(QWizardPage):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setTitle("Paso 3 · Dimensiones principales")
|
||||
self.setSubTitle(
|
||||
"Edita las dimensiones principales del buque. Los valores se pre-rellenan "
|
||||
"desde la plantilla del paso 2 si elegiste una."
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(18)
|
||||
|
||||
intro = QLabel(
|
||||
"Todas las medidas en metros (sistema internacional). "
|
||||
"Estos valores se usan en core para validar coordenadas de equipos y tarjetas."
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
intro.setStyleSheet(f"color: {C_FOG};")
|
||||
intro.setFont(ui_font(10))
|
||||
layout.addWidget(intro)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setHorizontalSpacing(20)
|
||||
form.setVerticalSpacing(14)
|
||||
|
||||
self._loa = self._make_spin(1.0, 200.0, 24.0, " m", 1)
|
||||
form.addRow("Eslora total (LOA):", self._loa)
|
||||
|
||||
self._beam = self._make_spin(1.0, 30.0, 5.5, " m", 2)
|
||||
form.addRow("Manga máxima:", self._beam)
|
||||
|
||||
self._draft = self._make_spin(0.3, 10.0, 1.8, " m", 2)
|
||||
form.addRow("Calado:", self._draft)
|
||||
|
||||
self._bh_aft = self._make_spin(0.0, 200.0, 4.0, " m desde Pp", 1)
|
||||
form.addRow("Mamparo popa sala máquinas:", self._bh_aft)
|
||||
|
||||
self._bh_fwd = self._make_spin(0.0, 200.0, 8.0, " m desde Pp", 1)
|
||||
form.addRow("Mamparo proa sala máquinas:", self._bh_fwd)
|
||||
|
||||
layout.addLayout(form)
|
||||
layout.addStretch(1)
|
||||
|
||||
from vmssailor.studio.wizard.wizard import (
|
||||
F_BEAM,
|
||||
F_BULKHEAD_AFT,
|
||||
F_BULKHEAD_FWD,
|
||||
F_DRAFT,
|
||||
F_LOA,
|
||||
)
|
||||
|
||||
self.registerField(F_LOA, self._loa, "value", "valueChanged")
|
||||
self.registerField(F_BEAM, self._beam, "value", "valueChanged")
|
||||
self.registerField(F_DRAFT, self._draft, "value", "valueChanged")
|
||||
self.registerField(F_BULKHEAD_AFT, self._bh_aft, "value", "valueChanged")
|
||||
self.registerField(F_BULKHEAD_FWD, self._bh_fwd, "value", "valueChanged")
|
||||
|
||||
def _make_spin(self, lo: float, hi: float, default: float, suffix: str, decimals: int) -> QDoubleSpinBox:
|
||||
s = QDoubleSpinBox()
|
||||
s.setRange(lo, hi)
|
||||
s.setDecimals(decimals)
|
||||
s.setSingleStep(0.1 if decimals >= 1 else 1)
|
||||
s.setSuffix(suffix)
|
||||
s.setValue(default)
|
||||
s.setMinimumWidth(180)
|
||||
return s
|
||||
|
||||
def initializePage(self) -> None:
|
||||
# If the user picked a template in step 2, pre-fill from it.
|
||||
from vmssailor.studio.wizard.wizard import F_TEMPLATE_ID
|
||||
|
||||
wizard = self.wizard()
|
||||
tpl_id = self.field(F_TEMPLATE_ID)
|
||||
if not tpl_id or not wizard:
|
||||
return
|
||||
# Look up the step 2 page to get the template dict
|
||||
from vmssailor.studio.wizard.step_02_template import Step02Template
|
||||
|
||||
for pid in range(8):
|
||||
page = wizard.page(pid)
|
||||
if isinstance(page, Step02Template):
|
||||
tpl = page.get_template()
|
||||
if tpl:
|
||||
self._loa.setValue(tpl["length_overall_m"])
|
||||
self._beam.setValue(tpl["beam_max_m"])
|
||||
self._draft.setValue(tpl["draft_m"])
|
||||
self._bh_aft.setValue(max(2.0, tpl["length_overall_m"] * 0.15))
|
||||
self._bh_fwd.setValue(max(5.0, tpl["length_overall_m"] * 0.30))
|
||||
break
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Paso 4: selección de sistemas habilitados.
|
||||
|
||||
Lee el catálogo maestro de sistemas y muestra checkboxes agrupados por categoría.
|
||||
Pre-selecciona los sistemas marcados como `default_for` el VesselType del paso 1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QWizardPage,
|
||||
)
|
||||
|
||||
from vmssailor.library import load_systems_catalog
|
||||
from vmssailor.studio.theme import C_FOG, ui_font
|
||||
|
||||
|
||||
class Step04Systems(QWizardPage):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setTitle("Paso 4 · Sistemas instalados")
|
||||
self.setSubTitle(
|
||||
"Marca los sistemas que tendrá el buque. Esto define el menú lateral del Runtime y "
|
||||
"qué tags se generarán en sprints siguientes."
|
||||
)
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setSpacing(10)
|
||||
|
||||
header = QLabel(
|
||||
"Pre-selección automática según tipo de buque. Ajusta marcando/desmarcando."
|
||||
)
|
||||
header.setWordWrap(True)
|
||||
header.setStyleSheet(f"color: {C_FOG};")
|
||||
header.setFont(ui_font(10))
|
||||
outer.addWidget(header)
|
||||
|
||||
# Buttons row
|
||||
btn_row = QGridLayout()
|
||||
self._btn_select_defaults = QPushButton("Resetear a defaults del tipo")
|
||||
self._btn_select_all = QPushButton("Marcar todos")
|
||||
self._btn_select_none = QPushButton("Desmarcar todos")
|
||||
btn_row.addWidget(self._btn_select_defaults, 0, 0)
|
||||
btn_row.addWidget(self._btn_select_all, 0, 1)
|
||||
btn_row.addWidget(self._btn_select_none, 0, 2)
|
||||
outer.addLayout(btn_row)
|
||||
|
||||
self._btn_select_defaults.clicked.connect(self._select_defaults)
|
||||
self._btn_select_all.clicked.connect(self._select_all)
|
||||
self._btn_select_none.clicked.connect(self._select_none)
|
||||
|
||||
# Scroll area with category groups
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
outer.addWidget(scroll, 1)
|
||||
|
||||
content = QWidget()
|
||||
self._content_layout = QVBoxLayout(content)
|
||||
self._content_layout.setSpacing(14)
|
||||
scroll.setWidget(content)
|
||||
|
||||
# Build categories
|
||||
self._checkboxes: dict[str, QCheckBox] = {}
|
||||
self._catalog = self._load_catalog_safe()
|
||||
self._build_categories()
|
||||
|
||||
# Register field
|
||||
from vmssailor.studio.wizard.wizard import F_SYSTEMS
|
||||
|
||||
self.registerField(F_SYSTEMS, self, "enabled_systems", "systemsChanged")
|
||||
|
||||
# ----- Property API ------------------------------------------------
|
||||
|
||||
from PySide6.QtCore import Property, Signal
|
||||
|
||||
systemsChanged = Signal()
|
||||
|
||||
def get_enabled_systems(self) -> list[str]:
|
||||
return [sid for sid, cb in self._checkboxes.items() if cb.isChecked()]
|
||||
|
||||
def set_enabled_systems(self, value: list[str]) -> None:
|
||||
for sid, cb in self._checkboxes.items():
|
||||
cb.setChecked(sid in value)
|
||||
self.systemsChanged.emit()
|
||||
|
||||
enabled_systems = Property(
|
||||
list,
|
||||
fget=get_enabled_systems,
|
||||
fset=set_enabled_systems,
|
||||
notify=systemsChanged,
|
||||
)
|
||||
|
||||
# ----- Internals ---------------------------------------------------
|
||||
|
||||
def _load_catalog_safe(self) -> dict:
|
||||
try:
|
||||
return load_systems_catalog()
|
||||
except Exception:
|
||||
return {"categories": []}
|
||||
|
||||
def _build_categories(self) -> None:
|
||||
for cat in self._catalog.get("categories", []):
|
||||
group = QGroupBox(cat.get("name", "—"))
|
||||
grid = QGridLayout(group)
|
||||
grid.setHorizontalSpacing(20)
|
||||
grid.setVerticalSpacing(6)
|
||||
for idx, sys in enumerate(cat.get("systems", [])):
|
||||
cb = QCheckBox(sys.get("name", sys["id"]))
|
||||
cb.setToolTip(f"id: {sys['id']}")
|
||||
cb.stateChanged.connect(self.systemsChanged.emit)
|
||||
self._checkboxes[sys["id"]] = cb
|
||||
grid.addWidget(cb, idx // 3, idx % 3)
|
||||
self._content_layout.addWidget(group)
|
||||
self._content_layout.addStretch(1)
|
||||
|
||||
def _system_default_for(self, sys_id: str) -> list[str]:
|
||||
for cat in self._catalog.get("categories", []):
|
||||
for s in cat.get("systems", []):
|
||||
if s.get("id") == sys_id:
|
||||
return s.get("default_for", [])
|
||||
return []
|
||||
|
||||
def _select_defaults(self) -> None:
|
||||
from vmssailor.studio.wizard.wizard import F_VESSEL_TYPE
|
||||
|
||||
v_type = self.field(F_VESSEL_TYPE)
|
||||
if not v_type:
|
||||
return
|
||||
for sid, cb in self._checkboxes.items():
|
||||
cb.setChecked(v_type in self._system_default_for(sid))
|
||||
|
||||
def _select_all(self) -> None:
|
||||
for cb in self._checkboxes.values():
|
||||
cb.setChecked(True)
|
||||
|
||||
def _select_none(self) -> None:
|
||||
for cb in self._checkboxes.values():
|
||||
cb.setChecked(False)
|
||||
|
||||
def initializePage(self) -> None:
|
||||
# On first entry, pre-select defaults for the chosen vessel type.
|
||||
if not any(cb.isChecked() for cb in self._checkboxes.values()):
|
||||
self._select_defaults()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Paso 8: confirmación final con resumen del proyecto."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget, QWizardPage
|
||||
|
||||
from vmssailor.studio.theme import (
|
||||
C_FOAM,
|
||||
C_FOG,
|
||||
C_MIDNIGHT,
|
||||
C_OK,
|
||||
C_SAND,
|
||||
C_STEEL,
|
||||
ui_font,
|
||||
)
|
||||
|
||||
|
||||
class Step08Confirm(QWizardPage):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setTitle("Paso 8 · Confirmación")
|
||||
self.setSubTitle(
|
||||
"Revisa el resumen del proyecto. Pulsa «Crear proyecto» para abrirlo en el editor."
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
self._summary_card = QLabel()
|
||||
self._summary_card.setWordWrap(True)
|
||||
self._summary_card.setTextFormat(Qt.RichText)
|
||||
self._summary_card.setFont(ui_font(11))
|
||||
self._summary_card.setStyleSheet(
|
||||
f"""
|
||||
background: {C_MIDNIGHT};
|
||||
border: 1px solid {C_STEEL};
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
color: {C_SAND};
|
||||
"""
|
||||
)
|
||||
layout.addWidget(self._summary_card, 1)
|
||||
|
||||
footer = QLabel(
|
||||
"Una vez creado el proyecto se abre en el editor principal del Studio. "
|
||||
"Podrás agregar equipos, tags y tarjetas, y guardar el .vmsproj cuando quieras.\n\n"
|
||||
"Sprint 2 traerá: editor de equipos, motor de reglas, asignación de I/O."
|
||||
)
|
||||
footer.setWordWrap(True)
|
||||
footer.setStyleSheet(f"color: {C_FOG};")
|
||||
footer.setFont(ui_font(9))
|
||||
layout.addWidget(footer)
|
||||
|
||||
def initializePage(self) -> None:
|
||||
from vmssailor.studio.wizard.wizard import (
|
||||
F_BEAM,
|
||||
F_BULKHEAD_AFT,
|
||||
F_BULKHEAD_FWD,
|
||||
F_CUSTOMER,
|
||||
F_DRAFT,
|
||||
F_LOA,
|
||||
F_PROJECT_NAME,
|
||||
F_SYSTEMS,
|
||||
F_TEMPLATE_ID,
|
||||
F_VESSEL_NAME,
|
||||
F_VESSEL_SUBTYPE,
|
||||
F_VESSEL_TYPE,
|
||||
)
|
||||
|
||||
project_name = self.field(F_PROJECT_NAME) or "(sin nombre)"
|
||||
customer = self.field(F_CUSTOMER) or "(sin cliente)"
|
||||
v_type = self.field(F_VESSEL_TYPE) or "—"
|
||||
v_sub = self.field(F_VESSEL_SUBTYPE) or "—"
|
||||
template = self.field(F_TEMPLATE_ID) or "—"
|
||||
v_name = self.field(F_VESSEL_NAME) or "—"
|
||||
loa = self.field(F_LOA) or 0.0
|
||||
beam = self.field(F_BEAM) or 0.0
|
||||
draft = self.field(F_DRAFT) or 0.0
|
||||
bh_aft = self.field(F_BULKHEAD_AFT) or 0.0
|
||||
bh_fwd = self.field(F_BULKHEAD_FWD) or 0.0
|
||||
systems = self.field(F_SYSTEMS) or []
|
||||
|
||||
n_sys = len(systems)
|
||||
systems_str = ", ".join(systems[:10])
|
||||
if n_sys > 10:
|
||||
systems_str += f", … (+{n_sys - 10})"
|
||||
if not systems_str:
|
||||
systems_str = "(ninguno seleccionado)"
|
||||
|
||||
html = f"""
|
||||
<h2 style="color:{C_FOAM}; font-family: sans-serif; margin: 0 0 16px 0;">
|
||||
{project_name}
|
||||
</h2>
|
||||
<p style="color:{C_FOG}; margin: 0 0 18px 0;">
|
||||
Cliente: <span style="color:{C_SAND};">{customer}</span>
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 28px; margin-bottom: 16px;">
|
||||
<div>
|
||||
<div style="color:{C_FOG}; font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 2px;">Buque</div>
|
||||
<div style="color:{C_FOAM}; font-size: 14px; margin-top: 6px;">{v_name}</div>
|
||||
<div style="color:{C_SAND}; font-size: 12px; margin-top: 2px;">
|
||||
{v_type} · {v_sub}
|
||||
</div>
|
||||
<div style="color:{C_FOG}; font-size: 11px; margin-top: 2px;">
|
||||
Plantilla: {template}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table cellpadding="6" style="color:{C_SAND}; font-family: monospace;
|
||||
border-collapse: collapse; margin-top: 12px;">
|
||||
<tr><td style="color:{C_FOG};">Eslora</td><td>{loa:.1f} m</td></tr>
|
||||
<tr><td style="color:{C_FOG};">Manga</td><td>{beam:.1f} m</td></tr>
|
||||
<tr><td style="color:{C_FOG};">Calado</td><td>{draft:.1f} m</td></tr>
|
||||
<tr><td style="color:{C_FOG};">Mamparo popa SM</td><td>x_pp {bh_aft:.1f} m</td></tr>
|
||||
<tr><td style="color:{C_FOG};">Mamparo proa SM</td><td>x_pp {bh_fwd:.1f} m</td></tr>
|
||||
</table>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid {C_STEEL}; margin: 16px 0;">
|
||||
|
||||
<div style="color:{C_FOG}; font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 2px;">Sistemas habilitados ({n_sys})</div>
|
||||
<div style="color:{C_SAND}; font-family: monospace; font-size: 11px;
|
||||
margin-top: 8px;">{systems_str}</div>
|
||||
|
||||
<p style="color:{C_OK}; font-size: 12px; margin-top: 20px;">
|
||||
✓ El proyecto se creará sin equipos. Agrega equipos manualmente o
|
||||
espera Sprint 2 (motor de reglas).
|
||||
</p>
|
||||
"""
|
||||
self._summary_card.setText(html)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Placeholder para pasos 5, 6, 7 — se implementan en Sprint 2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget, QWizardPage
|
||||
|
||||
from vmssailor.studio.theme import C_CYAN, C_FOG, C_SAND, display_font, ui_font
|
||||
|
||||
|
||||
class Step57Placeholder(QWizardPage):
|
||||
"""Placeholder visual con explicación clara de qué viene en Sprint 2."""
|
||||
|
||||
def __init__(self, *, index: int, title: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setTitle(f"Paso {index} · {title}")
|
||||
self.setSubTitle("Este paso se implementa en Sprint 2. Por ahora se omite.")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
layout.addStretch(1)
|
||||
|
||||
big = QLabel(f"Paso {index}")
|
||||
big.setFont(display_font(48, 700))
|
||||
big.setStyleSheet(f"color: {C_CYAN};")
|
||||
big.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(big)
|
||||
|
||||
msg = QLabel(title)
|
||||
msg.setFont(ui_font(14))
|
||||
msg.setStyleSheet(f"color: {C_SAND};")
|
||||
msg.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(msg)
|
||||
|
||||
body = QLabel(
|
||||
"El motor de reglas heurísticas (yacht_motor_planeo.yaml ya está en biblioteca)\n"
|
||||
"propondrá equipos automáticamente para los sistemas habilitados.\n\n"
|
||||
"En Sprint 1 se omite — el proyecto se crea con sistemas pero sin equipos todavía.\n"
|
||||
"Equipos se pueden agregar manualmente desde el editor en Sprint 2."
|
||||
)
|
||||
body.setFont(ui_font(10))
|
||||
body.setStyleSheet(f"color: {C_FOG};")
|
||||
body.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(body)
|
||||
|
||||
layout.addStretch(2)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""QWizard contenedor + builder de Project a partir del estado del wizard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QWizard
|
||||
|
||||
from vmssailor.core import (
|
||||
Project,
|
||||
SystemId,
|
||||
Vessel,
|
||||
VesselSubtype,
|
||||
VesselType,
|
||||
)
|
||||
from vmssailor.core.vessel import Bulkhead, Deck
|
||||
from vmssailor.shared.ids import make_project_id
|
||||
from vmssailor.studio.theme import (
|
||||
C_ABYSS,
|
||||
C_SAND,
|
||||
)
|
||||
from vmssailor.studio.wizard.step_01_vessel_type import Step01VesselType
|
||||
from vmssailor.studio.wizard.step_02_template import Step02Template
|
||||
from vmssailor.studio.wizard.step_03_dimensions import Step03Dimensions
|
||||
from vmssailor.studio.wizard.step_04_systems import Step04Systems
|
||||
from vmssailor.studio.wizard.step_08_confirm import Step08Confirm
|
||||
from vmssailor.studio.wizard.step_57_placeholder import Step57Placeholder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Wizard field keys used across steps
|
||||
F_PROJECT_NAME = "project.name"
|
||||
F_CUSTOMER = "project.customer"
|
||||
F_VESSEL_TYPE = "vessel.type"
|
||||
F_VESSEL_SUBTYPE = "vessel.subtype"
|
||||
F_TEMPLATE_ID = "vessel.template_id"
|
||||
F_VESSEL_NAME = "vessel.name"
|
||||
F_LOA = "vessel.length_overall_m"
|
||||
F_BEAM = "vessel.beam_max_m"
|
||||
F_DRAFT = "vessel.draft_m"
|
||||
F_BULKHEAD_FWD = "vessel.bulkhead_fwd_m"
|
||||
F_BULKHEAD_AFT = "vessel.bulkhead_aft_m"
|
||||
F_SYSTEMS = "systems.enabled"
|
||||
|
||||
|
||||
class VesselWizard(QWizard):
|
||||
"""Wizard 8 pasos para crear un nuevo Project desde cero."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("VMS-Sailor · Nuevo proyecto")
|
||||
self.setMinimumSize(900, 640)
|
||||
self.setWizardStyle(QWizard.ModernStyle)
|
||||
self.setOption(QWizard.NoBackButtonOnStartPage, True)
|
||||
self.setOption(QWizard.HaveHelpButton, False)
|
||||
self.setOption(QWizard.IndependentPages, False)
|
||||
|
||||
# Add pages
|
||||
self.addPage(Step01VesselType())
|
||||
self.addPage(Step02Template())
|
||||
self.addPage(Step03Dimensions())
|
||||
self.addPage(Step04Systems())
|
||||
self.addPage(Step57Placeholder(index=5, title="Equipos sugeridos (Sprint 2)"))
|
||||
self.addPage(Step57Placeholder(index=6, title="Refinamiento manual (Sprint 2)"))
|
||||
self.addPage(Step57Placeholder(index=7, title="Topología I/O AR-NMEA-IO (Sprint 2)"))
|
||||
self.addPage(Step08Confirm())
|
||||
|
||||
# Style
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QWizard {{ background: {C_ABYSS}; }}
|
||||
QWidget {{ background: {C_ABYSS}; color: {C_SAND}; }}
|
||||
QWizard QPushButton {{ min-width: 100px; }}
|
||||
"""
|
||||
)
|
||||
|
||||
# Localización de botones (QWizard usa textos del sistema)
|
||||
self.setButtonText(QWizard.NextButton, "Siguiente >")
|
||||
self.setButtonText(QWizard.BackButton, "< Atrás")
|
||||
self.setButtonText(QWizard.FinishButton, "Crear proyecto")
|
||||
self.setButtonText(QWizard.CancelButton, "Cancelar")
|
||||
|
||||
# ----- Project construction -----------------------------------------
|
||||
|
||||
def build_project(self) -> Project:
|
||||
"""Construye un `Project` a partir de los datos capturados por el wizard."""
|
||||
project_name = self.field(F_PROJECT_NAME) or "Proyecto sin nombre"
|
||||
customer = self.field(F_CUSTOMER) or ""
|
||||
vessel_name = self.field(F_VESSEL_NAME) or project_name
|
||||
v_type = self.field(F_VESSEL_TYPE) or VesselType.YACHT_MOTOR.value
|
||||
v_sub = self.field(F_VESSEL_SUBTYPE) or VesselSubtype.PLANING.value
|
||||
loa = float(self.field(F_LOA) or 24.0)
|
||||
beam = float(self.field(F_BEAM) or 5.5)
|
||||
draft = float(self.field(F_DRAFT) or 1.8)
|
||||
bh_fwd = float(self.field(F_BULKHEAD_FWD) or (loa * 0.30))
|
||||
bh_aft = float(self.field(F_BULKHEAD_AFT) or (loa * 0.15))
|
||||
systems_raw = self.field(F_SYSTEMS) or [SystemId.MAIN_ENGINE.value]
|
||||
|
||||
# Build vessel
|
||||
decks = [
|
||||
Deck(id="lower", name="Cubierta inferior", z_bl_bottom=0.4, z_bl_top=draft + 1.0),
|
||||
Deck(
|
||||
id="main",
|
||||
name="Cubierta principal",
|
||||
z_bl_bottom=draft + 1.0,
|
||||
z_bl_top=draft + 3.0,
|
||||
),
|
||||
]
|
||||
bulkheads = [
|
||||
Bulkhead(id="er_aft", name="Mamparo popa SM", x_pp=bh_aft),
|
||||
Bulkhead(id="er_fwd", name="Mamparo proa SM", x_pp=bh_fwd),
|
||||
]
|
||||
try:
|
||||
vessel = Vessel(
|
||||
id=f"wizard_{int(loa * 10)}m",
|
||||
name=vessel_name,
|
||||
type=VesselType(v_type),
|
||||
subtype=VesselSubtype(v_sub),
|
||||
length_overall_m=loa,
|
||||
beam_max_m=beam,
|
||||
draft_m=draft,
|
||||
decks=decks,
|
||||
bulkheads=bulkheads,
|
||||
data_source="user_input",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Vessel build failed: %s", exc)
|
||||
raise
|
||||
|
||||
# Equipment + tags llegan en Sprint 2 (rule engine). Por ahora, vacío.
|
||||
try:
|
||||
systems_enabled = [SystemId(s) for s in systems_raw]
|
||||
except ValueError:
|
||||
systems_enabled = [SystemId.MAIN_ENGINE]
|
||||
|
||||
project_id = make_project_id(customer or "cliente", vessel.id)
|
||||
return Project(
|
||||
id=project_id,
|
||||
name=project_name,
|
||||
customer=customer,
|
||||
vessel=vessel,
|
||||
systems_enabled=systems_enabled,
|
||||
equipment=[],
|
||||
tags=[],
|
||||
notes="Creado desde wizard Sprint 1. Sprint 2 agregará equipos sugeridos.",
|
||||
)
|
||||
Reference in New Issue
Block a user