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
+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