feat: Compass initial commit
@@ -0,0 +1,42 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
compass_error.log
|
||||||
|
compass_sim.log
|
||||||
|
|
||||||
|
# Debug outputs
|
||||||
|
out.txt
|
||||||
|
out2.txt
|
||||||
|
out3.txt
|
||||||
|
err.txt
|
||||||
|
err2.txt
|
||||||
|
err3.txt
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Database / local data
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# NextCloud sync metadata
|
||||||
|
.nextcloudsync.log
|
||||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="174.418mm"
|
||||||
|
height="66.771004mm"
|
||||||
|
viewBox="0 0 174.418 66.771004"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||||
|
sodipodi:docname="Perfil Carguero ocre.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="1.0167041"
|
||||||
|
inkscape:cx="397.85421"
|
||||||
|
inkscape:cy="92.947399"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1009"
|
||||||
|
inkscape:window-x="-8"
|
||||||
|
inkscape:window-y="-8"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Capa 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-55.567674,-48.336721)">
|
||||||
|
<path
|
||||||
|
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
|
||||||
|
d="m 61.446301,94.557256 c 0.0015,2.82065 -0.07796,2.720713 0.14574,7.102834 4.269258,0.30696 5.359228,0.53387 9.716856,0.54355 2.851676,0.006 5.313513,7.97248 5.03607,9.88966 l 134.235153,0.0674 c 3.59216,-0.069 16.88755,1.57273 12.82835,-5.64002 -0.83843,-1.4898 -3.22393,2.1554 -5.35978,1.85356 -1.65443,-4.99324 5.86656,-7.85234 7.66461,-14.105577 L 95.897085,94.614399 95.609107,74.980755 91.705563,69.645911 67.243347,69.385674 66.904958,94.613629 Z"
|
||||||
|
id="path1"
|
||||||
|
sodipodi:nodetypes="ccsccscccccccc" />
|
||||||
|
<path
|
||||||
|
style="fill:#806600;stroke:#000000;stroke-width:0.271241"
|
||||||
|
d="M 103.05694,94.585381 V 89.5246 h 32.1325 l 0.13009,5.060781 z"
|
||||||
|
id="path2" />
|
||||||
|
<path
|
||||||
|
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
|
||||||
|
d="m 142.16771,94.458708 -0.069,-5.175417 35.7909,-0.207018 0.023,5.313431 z"
|
||||||
|
id="path3" />
|
||||||
|
<path
|
||||||
|
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
|
||||||
|
d="m 186.35428,94.274695 0.046,-5.290428 23.18587,0.138012 0.092,5.198419 z"
|
||||||
|
id="path4" />
|
||||||
|
<path
|
||||||
|
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
|
||||||
|
d="m 220.37403,94.228689 0.0461,-16.613205 h 0.19518 l 0.24173,16.705214 z"
|
||||||
|
id="path5" />
|
||||||
|
<path
|
||||||
|
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
|
||||||
|
d="m 79.050616,69.501692 2.599211,0.046 -0.846449,-19.777498 h -0.390353 z"
|
||||||
|
id="path6" />
|
||||||
|
<path
|
||||||
|
style="fill:#806600;stroke:#000000;stroke-width:0.264583"
|
||||||
|
d="m 80.982773,53.101367 h 2.36919 l -0.046,0.184015 -2.300184,0.552045 z"
|
||||||
|
id="path7" />
|
||||||
|
<path
|
||||||
|
style="fill:#806600;stroke:#000000;stroke-width:0.266721"
|
||||||
|
d="m 61.480566,94.545248 -1.885345,-4.763729 2.925536,4.763729 z"
|
||||||
|
id="path8" />
|
||||||
|
<path
|
||||||
|
style="fill:#806600;stroke:#000000;stroke-width:0.25345"
|
||||||
|
d="m 70.185521,102.16932 -0.0052,2.03881 0.05484,2.84738 0.09994,4.85186 -7.300804,-0.0323 -0.09508,-9.56221 3.751845,0.21094 2.466239,0.26072 -0.03002,-0.54996 z"
|
||||||
|
id="path9"
|
||||||
|
sodipodi:nodetypes="cccccccccc" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,90 @@
|
|||||||
|
# Marine Compass Display — Estado del Proyecto
|
||||||
|
|
||||||
|
## Qué funciona
|
||||||
|
|
||||||
|
### Ventana principal (`ui/main_window.py`)
|
||||||
|
- Layout 60/40: rosa de los vientos (izq) + panel de info (der)
|
||||||
|
- Modo fullscreen con F para toggle, Escape para cerrar
|
||||||
|
- Modo kiosk (`--kiosk`): cursor oculto, screensaver desactivado (Linux/RPi)
|
||||||
|
- Smoothing de rumbo configurable en `config.py` (`HEADING_SMOOTHING`)
|
||||||
|
|
||||||
|
### Rosa de los vientos (`ui/compass_widget.py`)
|
||||||
|
- Tarjeta giratoria: el norte siempre apunta al norte real
|
||||||
|
- Línea lubber fija en rojo (arriba = rumbo del barco)
|
||||||
|
- Readout digital del rumbo **arriba** de la rosa, justo bajo la línea lubber
|
||||||
|
- Formato: `045.0°M` o `045.0°T` según modo seleccionado
|
||||||
|
- Estrella de 16 puntas: N=rojo, cardinales=blanco, intercardinales=oro
|
||||||
|
- Ring de ticks cada 5°, números cada 10°
|
||||||
|
- Modo noche / día
|
||||||
|
- Ball central muestra pitch+roll del buque
|
||||||
|
|
||||||
|
### Panel de información (`ui/info_panel.py`)
|
||||||
|
- **HDG grande**: muestra °M o °T según el botón HDG seleccionado ← bug corregido hoy
|
||||||
|
- Filas de datos: HDG(T), ROT, PITCH, ROLL, VAR, HEAVE, YAW RATE
|
||||||
|
- ROT Arc: semicírculo animado, azul=estribor, rojo=babor, escala ±60°/min
|
||||||
|
- Siluetas de barco animadas (motor-cruiser 40ft):
|
||||||
|
- **PITCH** (vista lateral): hull completo, cabina, flybridge, radar arch, línea de boot roja, línea de vida punteada, arco de proa
|
||||||
|
- **ROLL** (vista de proa): casco V, francobordo con curvatura, raíl de rociada, windshield en V con cristales azules separados, arco radar, luces de navegación rojo/verde
|
||||||
|
- **YAW** (vista de planta): casco cúbico bezier, cockpit con bancadas, cabina, flybridge, escotillas de cubierta, púlpito de proa, luces de nav
|
||||||
|
- Botones táctiles: HDG °M/°T, NIGHT, PORTS
|
||||||
|
- Marcos separados por gaps (para case con compartimentos físicos)
|
||||||
|
- Responsive: se adapta a cualquier tamaño de pantalla
|
||||||
|
|
||||||
|
### Simulador NMEA (`simulator/nmea_simulator.py`)
|
||||||
|
- Secuencia realista: INITIAL → TURN_STBD → COMP_STBD → STEADY_STBD → TURN_PORT → COMP_PORT → NORMAL
|
||||||
|
- Genera: HCHDG (rumbo), IIROT (ROT), IIXDR (pitch, roll, aceleración, giroscopio)
|
||||||
|
- Servidor TCP en 127.0.0.1:10110
|
||||||
|
- Variación magnética: -7.5° (Stuart, FL)
|
||||||
|
- SOG: 8.5 kn, ROT máx: 60°/min
|
||||||
|
|
||||||
|
### Lanzamiento (`launch.bat`)
|
||||||
|
- `launch.bat --sim` → usa `python` (con consola), se ven logs del simulador en tiempo real
|
||||||
|
- `launch.bat` → usa `pythonw` (sin consola), modo kiosk limpio para hardware
|
||||||
|
|
||||||
|
### Parser NMEA (`core/nmea_parser.py`)
|
||||||
|
- Parsea HCHDG, IIROT, IIXDR
|
||||||
|
- `NavData.hdg_true_calc` = hdg_mag + variation (calculo automático)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pendiente / Mejoras posibles
|
||||||
|
|
||||||
|
- [ ] Silueta ROLL (vista de proa): el usuario quiere más detalle y mejores proporciones
|
||||||
|
- [ ] Test en hardware real con puerto serial
|
||||||
|
- [ ] Modo RPi: probar con `--kiosk` en Raspberry Pi con pantalla táctil
|
||||||
|
- [ ] NMEA: agregar soporte para VHW, MWV, GGA si se necesita más datos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cómo probar
|
||||||
|
|
||||||
|
```
|
||||||
|
# Simulador (muestra logs en consola):
|
||||||
|
launch.bat --sim
|
||||||
|
|
||||||
|
# Con hardware real en COM3:
|
||||||
|
launch.bat --port COM3 --baud 4800
|
||||||
|
|
||||||
|
# Puerto configurado en config.py:
|
||||||
|
launch.bat
|
||||||
|
|
||||||
|
# Python directo (más control):
|
||||||
|
python main.py --sim
|
||||||
|
python main.py --port COM3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archivos clave
|
||||||
|
|
||||||
|
| Archivo | Función |
|
||||||
|
|---------|---------|
|
||||||
|
| `main.py` | Entry point, args, QApplication |
|
||||||
|
| `config.py` | Puertos serial, smoothing, FPS |
|
||||||
|
| `ui/main_window.py` | Ventana principal, layout, timer |
|
||||||
|
| `ui/compass_widget.py` | Rosa de los vientos (QPainter) |
|
||||||
|
| `ui/info_panel.py` | Panel derecho: datos, siluetas, botones |
|
||||||
|
| `ui/styles.py` | Colores y helpers de tema |
|
||||||
|
| `core/nmea_parser.py` | Parser NMEA 0183 |
|
||||||
|
| `core/serial_reader.py` | Lector serial (QThread) |
|
||||||
|
| `simulator/nmea_simulator.py` | Simulador de maniobras + TCP server |
|
||||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 93 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"_comment": "AR Electronics — Paleta oficial de marca. Usar en todas las apps.",
|
||||||
|
"_version": "1.0.0",
|
||||||
|
|
||||||
|
"background": {
|
||||||
|
"primary": "#0D1B2A",
|
||||||
|
"secondary": "#1A2744",
|
||||||
|
"card": "#162035",
|
||||||
|
"surface": "#1E2D47"
|
||||||
|
},
|
||||||
|
|
||||||
|
"accent": {
|
||||||
|
"blue_electric": "#2563EB",
|
||||||
|
"blue_neon": "#4A9FE8",
|
||||||
|
"blue_dark": "#1A47A8",
|
||||||
|
"blue_glow": "#60B8FF"
|
||||||
|
},
|
||||||
|
|
||||||
|
"text": {
|
||||||
|
"primary": "#E2E8F0",
|
||||||
|
"secondary": "#A8B5C4",
|
||||||
|
"muted": "#6B7A8D",
|
||||||
|
"on_accent": "#FFFFFF"
|
||||||
|
},
|
||||||
|
|
||||||
|
"status": {
|
||||||
|
"ok": "#22C55E",
|
||||||
|
"warning": "#F59E0B",
|
||||||
|
"alarm": "#EF4444",
|
||||||
|
"info": "#4A9FE8"
|
||||||
|
},
|
||||||
|
|
||||||
|
"metallic": {
|
||||||
|
"silver_light": "#C8D2DC",
|
||||||
|
"silver_mid": "#A8B5C4",
|
||||||
|
"silver_dark": "#6B7A8D"
|
||||||
|
},
|
||||||
|
|
||||||
|
"flutter": {
|
||||||
|
"_comment": "Valores listos para copiar en ThemeData de Flutter",
|
||||||
|
"primaryColor": "0xFF2563EB",
|
||||||
|
"scaffoldBackground": "0xFF0D1B2A",
|
||||||
|
"cardColor": "0xFF162035",
|
||||||
|
"accentColor": "0xFF4A9FE8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,15 @@
|
|||||||
|
SERIAL_PORTS = [
|
||||||
|
{'port': '/dev/ttyUSB0', 'baud': 4800, 'name': 'NMEA Primary'},
|
||||||
|
{'port': '/dev/ttyUSB1', 'baud': 38400, 'name': 'NMEA Fast'},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Windows example:
|
||||||
|
# {'port': 'COM3', 'baud': 4800, 'name': 'NMEA Primary'}
|
||||||
|
|
||||||
|
HEADING_SMOOTHING = 0.18 # 0=no smoothing, 1=instant snap
|
||||||
|
UI_REFRESH_MS = 80 # ~12 fps — smooth animation, low CPU
|
||||||
|
COMPASS_SPLIT = 0.60 # fraction of width for compass rose
|
||||||
|
|
||||||
|
# Vessel type — controls silhouettes in the attitude panel
|
||||||
|
# Options: 'motor_cruiser' | 'cargo' | 'offshore' | 'cruise' | 'superyacht'
|
||||||
|
VESSEL_TYPE = 'cargo'
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
NMEA 0183 parser — populates NavData from sentences.
|
||||||
|
Handles: HDG, HDT, HDM, RMC, VTG, ROT, XDR, MWV, GGA, ZDA
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _checksum_ok(sentence: str) -> bool:
|
||||||
|
if '*' not in sentence:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
data, cs = sentence.rsplit('*', 1)
|
||||||
|
val = 0
|
||||||
|
for c in data.lstrip('$!'):
|
||||||
|
val ^= ord(c)
|
||||||
|
return val == int(cs.strip()[:2], 16)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _f(s: str) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
return float(s) if s.strip() else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NavData:
|
||||||
|
# ── Heading ──────────────────────────────────────────────────────────────
|
||||||
|
hdg_mag: Optional[float] = None # Magnetic heading °
|
||||||
|
hdg_true: Optional[float] = None # True heading °
|
||||||
|
variation: Optional[float] = None # Mag variation (+ = East)
|
||||||
|
deviation: Optional[float] = None
|
||||||
|
|
||||||
|
# ── Motion ───────────────────────────────────────────────────────────────
|
||||||
|
rot: Optional[float] = None # Rate of turn °/min (+ = stbd)
|
||||||
|
|
||||||
|
# ── Attitude (BNO085) ─────────────────────────────────────────────────────
|
||||||
|
pitch: Optional[float] = None # ° bow-up positive
|
||||||
|
roll: Optional[float] = None # ° stbd positive
|
||||||
|
heel: Optional[float] = None # alias for roll on some talkers
|
||||||
|
|
||||||
|
# ── Acceleration (BNO085 via XDR) ────────────────────────────────────────
|
||||||
|
accel_x: Optional[float] = None # m/s² — surge (fore-aft)
|
||||||
|
accel_y: Optional[float] = None # m/s² — sway (port-stbd)
|
||||||
|
accel_z: Optional[float] = None # m/s² — heave (up-down)
|
||||||
|
|
||||||
|
# ── Angular velocity / gyro (BNO085 via XDR) ─────────────────────────────
|
||||||
|
gyro_x: Optional[float] = None # °/s — roll rate
|
||||||
|
gyro_y: Optional[float] = None # °/s — pitch rate
|
||||||
|
gyro_z: Optional[float] = None # °/s — yaw rate
|
||||||
|
|
||||||
|
# ── Quaternion (BNO085 via XDR or proprietary) ────────────────────────────
|
||||||
|
quat_w: Optional[float] = None
|
||||||
|
quat_x: Optional[float] = None
|
||||||
|
quat_y: Optional[float] = None
|
||||||
|
quat_z: Optional[float] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hdg_true_calc(self) -> Optional[float]:
|
||||||
|
if self.hdg_mag is not None and self.variation is not None:
|
||||||
|
return (self.hdg_mag + self.variation) % 360
|
||||||
|
return self.hdg_true
|
||||||
|
|
||||||
|
|
||||||
|
def parse(sentence: str, data: NavData) -> bool:
|
||||||
|
sentence = sentence.strip()
|
||||||
|
if not (sentence.startswith('$') or sentence.startswith('!')):
|
||||||
|
return False
|
||||||
|
if not _checksum_ok(sentence):
|
||||||
|
return False
|
||||||
|
|
||||||
|
body = sentence[1:sentence.rfind('*')] if '*' in sentence else sentence[1:]
|
||||||
|
p = body.split(',')
|
||||||
|
if not p:
|
||||||
|
return False
|
||||||
|
|
||||||
|
stype = p[0][-3:].upper()
|
||||||
|
try:
|
||||||
|
return _PARSERS.get(stype, lambda *_: False)(p, data)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Individual sentence parsers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _hdg(p, d):
|
||||||
|
# $HCHDG,x.x,x.x,a,x.x,a
|
||||||
|
d.hdg_mag = _f(p[1])
|
||||||
|
dev = _f(p[2])
|
||||||
|
if dev is not None:
|
||||||
|
d.deviation = dev if (len(p) > 3 and p[3].upper() != 'W') else -dev
|
||||||
|
var = _f(p[4]) if len(p) > 4 else None
|
||||||
|
if var is not None:
|
||||||
|
d.variation = var if (len(p) > 5 and p[5].upper() != 'W') else -var
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _hdt(p, d):
|
||||||
|
if len(p) > 2 and p[2].upper() == 'T':
|
||||||
|
d.hdg_true = _f(p[1])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _hdm(p, d):
|
||||||
|
if len(p) > 2 and p[2].upper() == 'M':
|
||||||
|
d.hdg_mag = _f(p[1])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _rmc(p, d):
|
||||||
|
# Only used to extract magnetic variation as fallback
|
||||||
|
if len(p) > 11 and p[10]:
|
||||||
|
var = _f(p[10])
|
||||||
|
if var is not None:
|
||||||
|
d.variation = var if p[11].upper() != 'W' else -var
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _rot(p, d):
|
||||||
|
if len(p) > 2 and p[2].upper() == 'A':
|
||||||
|
d.rot = _f(p[1])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _xdr(p, d):
|
||||||
|
"""
|
||||||
|
XDR carries all BNO085 outputs.
|
||||||
|
Expected names (configurable in ESP32 firmware):
|
||||||
|
PITCH, ROLL, HEEL
|
||||||
|
ACCELX, ACCELY, ACCELZ (or SURGE, SWAY, HEAVE)
|
||||||
|
GYROX, GYROY, GYROZ
|
||||||
|
QUATW, QUATX, QUATY, QUATZ
|
||||||
|
"""
|
||||||
|
i = 1
|
||||||
|
while i + 3 <= len(p) - 1:
|
||||||
|
name = p[i + 3].upper()
|
||||||
|
value = _f(p[i + 1])
|
||||||
|
if value is not None:
|
||||||
|
if 'PITCH' in name: d.pitch = value
|
||||||
|
elif 'ROLL' in name: d.roll = value
|
||||||
|
elif 'HEEL' in name: d.heel = value
|
||||||
|
elif name in ('ACCELX', 'SURGE'): d.accel_x = value
|
||||||
|
elif name in ('ACCELY', 'SWAY'): d.accel_y = value
|
||||||
|
elif name in ('ACCELZ', 'HEAVE'): d.accel_z = value
|
||||||
|
elif name in ('GYROX', 'ROLLR'): d.gyro_x = value
|
||||||
|
elif name in ('GYROY', 'PITCHR'): d.gyro_y = value
|
||||||
|
elif name in ('GYROZ', 'YAWR'): d.gyro_z = value
|
||||||
|
elif 'QUATW' in name: d.quat_w = value
|
||||||
|
elif 'QUATX' in name: d.quat_x = value
|
||||||
|
elif 'QUATY' in name: d.quat_y = value
|
||||||
|
elif 'QUATZ' in name: d.quat_z = value
|
||||||
|
i += 4
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_PARSERS = {
|
||||||
|
'HDG': _hdg, # Heading + variation
|
||||||
|
'HDT': _hdt, # True heading
|
||||||
|
'HDM': _hdm, # Magnetic heading
|
||||||
|
'ROT': _rot, # Rate of turn
|
||||||
|
'XDR': _xdr, # Pitch / Roll transducer
|
||||||
|
'RMC': _rmc, # Variation fallback (only var field used)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
Serial port reader — runs in a QThread, emits each NMEA sentence.
|
||||||
|
"""
|
||||||
|
import serial
|
||||||
|
import serial.tools.list_ports
|
||||||
|
from PyQt5.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
|
|
||||||
|
class SerialReader(QThread):
|
||||||
|
sentence = pyqtSignal(str)
|
||||||
|
connected = pyqtSignal(bool, str)
|
||||||
|
error = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, port: str, baud: int = 4800, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.port = port
|
||||||
|
self.baud = baud
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self._running = True
|
||||||
|
try:
|
||||||
|
ser = serial.Serial(self.port, self.baud, timeout=1.0)
|
||||||
|
self.connected.emit(True, self.port)
|
||||||
|
buf = ''
|
||||||
|
while self._running:
|
||||||
|
raw = ser.read(ser.in_waiting or 1)
|
||||||
|
buf += raw.decode('ascii', errors='ignore')
|
||||||
|
while '\n' in buf:
|
||||||
|
line, buf = buf.split('\n', 1)
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('$') or line.startswith('!'):
|
||||||
|
self.sentence.emit(line)
|
||||||
|
ser.close()
|
||||||
|
except serial.SerialException as e:
|
||||||
|
self.connected.emit(False, self.port)
|
||||||
|
self.error.emit(str(e))
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
self.wait(2000)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def available_ports():
|
||||||
|
return [p.device for p in serial.tools.list_ports.comports()]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
title Marine Compass
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo ================================================
|
||||||
|
echo MARINE COMPASS DISPLAY v1.0
|
||||||
|
echo ================================================
|
||||||
|
echo launch.bat -- hardware (config.py)
|
||||||
|
echo launch.bat --sim -- simulador maniobras
|
||||||
|
echo launch.bat --port COM3 --baud 4800
|
||||||
|
echo ================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: Check Python
|
||||||
|
python --version >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Python no encontrado. Instala desde python.org
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Dependencies
|
||||||
|
echo Verificando dependencias...
|
||||||
|
python -m pip install -r requirements.txt -q --no-warn-script-location
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Fallo al instalar dependencias.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo Dependencias OK.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: Sin argumentos -> simulador (doble-clic en Windows)
|
||||||
|
:: --sim -> simulador (consola activa, mismo efecto)
|
||||||
|
:: --port COM3 -> hardware en COM3 (modo kiosk, sin consola)
|
||||||
|
:: --port COM3 --baud 38400 -> hardware a 38400 bps
|
||||||
|
if "%1"=="" (
|
||||||
|
echo Modo: SIMULADOR -- consola activa, Ctrl+C para salir
|
||||||
|
echo.
|
||||||
|
python main.py --sim
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] El simulador termino con error. Revisa el log arriba.
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
) else if "%1"=="--sim" (
|
||||||
|
echo Modo: SIMULADOR -- consola activa, Ctrl+C para salir
|
||||||
|
echo.
|
||||||
|
python main.py --sim
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] El simulador termino con error. Revisa el log arriba.
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo Modo: HARDWARE %*
|
||||||
|
echo.
|
||||||
|
pythonw main.py %* > compass_error.log 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] Revisa compass_error.log
|
||||||
|
notepad compass_error.log
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ================================================================
|
||||||
|
# Marine Compass Display — Launcher para Linux / Raspberry Pi
|
||||||
|
# ================================================================
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# ./launch.sh → hardware (lee config.py)
|
||||||
|
# ./launch.sh --sim → simulador de maniobras
|
||||||
|
# ./launch.sh --kiosk → kiosk: fullscreen, sin cursor
|
||||||
|
# ./launch.sh --sim --kiosk
|
||||||
|
# ./launch.sh --port /dev/ttyUSB0 --baud 4800
|
||||||
|
#
|
||||||
|
# Auto-arranque en RPi — agregar en /etc/rc.local antes de 'exit 0':
|
||||||
|
# su -c "DISPLAY=:0 /home/pi/compass/launch.sh --kiosk &" pi
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "╔══════════════════════════════════════════════╗"
|
||||||
|
echo "║ MARINE COMPASS DISPLAY v1.0 ║"
|
||||||
|
echo "╠══════════════════════════════════════════════╣"
|
||||||
|
echo "║ Uso: ║"
|
||||||
|
echo "║ ./launch.sh → hardware ║"
|
||||||
|
echo "║ ./launch.sh --sim → simulador ║"
|
||||||
|
echo "║ ./launch.sh --kiosk → kiosk RPi ║"
|
||||||
|
echo "╚══════════════════════════════════════════════╝"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Verificar Python 3
|
||||||
|
if ! command -v python3 &>/dev/null; then
|
||||||
|
echo "[ERROR] python3 no encontrado."
|
||||||
|
echo "Instala: sudo apt install python3 python3-pip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dependencias
|
||||||
|
echo "Verificando dependencias..."
|
||||||
|
pip3 install -r requirements.txt -q
|
||||||
|
echo "Dependencias OK."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Display (necesario en rc.local / cron)
|
||||||
|
export DISPLAY="${DISPLAY:-:0}"
|
||||||
|
|
||||||
|
# Kiosk: deshabilitar screensaver
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$arg" = "--kiosk" ]; then
|
||||||
|
xset s off 2>/dev/null || true
|
||||||
|
xset -dpms 2>/dev/null || true
|
||||||
|
xset s noblank 2>/dev/null || true
|
||||||
|
command -v unclutter &>/dev/null && unclutter -idle 0 &
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Mostrar modo
|
||||||
|
if [[ " $* " == *" --sim "* ]] || [[ "$1" == "--sim" ]]; then
|
||||||
|
echo "Modo: SIMULADOR"
|
||||||
|
echo " INITIAL 60s → TURN STBD → 225° → TURN PORT → 045° → NORMAL"
|
||||||
|
else
|
||||||
|
echo "Modo: HARDWARE — leyendo puertos en config.py"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
python3 main.py "$@"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Marine Compass Display
|
||||||
|
----------------------
|
||||||
|
python main.py # normal run (reads config.py ports)
|
||||||
|
python main.py --sim # built-in NMEA simulator (no hardware needed)
|
||||||
|
python main.py --port COM3 # Windows override
|
||||||
|
python main.py --port /dev/ttyUSB0 --baud 38400
|
||||||
|
python main.py --kiosk # RPi fullscreen, cursor hidden, no screensaver
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Marine Compass Display')
|
||||||
|
parser.add_argument('--sim', action='store_true', help='Run with NMEA simulator')
|
||||||
|
parser.add_argument('--port', default=None, help='Serial port override')
|
||||||
|
parser.add_argument('--baud', type=int, default=4800)
|
||||||
|
parser.add_argument('--kiosk', action='store_true', help='Kiosk mode (RPi): hide cursor, disable screensaver')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.sim:
|
||||||
|
from simulator.nmea_simulator import start_simulator
|
||||||
|
start_simulator()
|
||||||
|
|
||||||
|
if args.port:
|
||||||
|
import config
|
||||||
|
config.SERIAL_PORTS = [{'port': args.port, 'baud': args.baud, 'name': 'CLI'}]
|
||||||
|
|
||||||
|
if args.kiosk:
|
||||||
|
_setup_kiosk()
|
||||||
|
|
||||||
|
# Must be set BEFORE QApplication is created
|
||||||
|
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||||
|
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
if args.kiosk:
|
||||||
|
app.setOverrideCursor(Qt.BlankCursor)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ui.main_window import MainWindow
|
||||||
|
win = MainWindow()
|
||||||
|
win.show()
|
||||||
|
win.activateWindow()
|
||||||
|
win.raise_()
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
from PyQt5.QtWidgets import QMessageBox
|
||||||
|
QMessageBox.critical(None, 'Error al iniciar', traceback.format_exc())
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_kiosk():
|
||||||
|
"""Disable screensaver and DPMS on Linux/RPi."""
|
||||||
|
import platform
|
||||||
|
if platform.system() != 'Linux':
|
||||||
|
return
|
||||||
|
import subprocess
|
||||||
|
for cmd in [
|
||||||
|
['xset', 's', 'off'],
|
||||||
|
['xset', '-dpms'],
|
||||||
|
['xset', 's', 'noblank'],
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
PyQt5>=5.15.0
|
||||||
|
pyserial>=3.5
|
||||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,375 @@
|
|||||||
|
"""
|
||||||
|
NMEA 0183 Simulator — Secuencia realista de maniobras de buque
|
||||||
|
==============================================================
|
||||||
|
|
||||||
|
Secuencia:
|
||||||
|
1. INITIAL — Rumbo 045°M estable, 60 segundos
|
||||||
|
2. TURN_STBD — Giro por estribor hacia 225° (180° del original)
|
||||||
|
3. COMP_STBD — Timonel compensa: ROT baja a 0, buque estabiliza en 225°
|
||||||
|
4. STEADY_STBD — Estable en 225°M, 60 segundos
|
||||||
|
5. TURN_PORT — Giro por babor de regreso a 045°
|
||||||
|
6. COMP_PORT — Timonel compensa: ROT sube desde negativo a 0
|
||||||
|
7. NORMAL — Navegación normal con movimiento suave de olas
|
||||||
|
|
||||||
|
Ejecutar standalone para ver output:
|
||||||
|
python -m simulator.nmea_simulator
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import threading
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from enum import Enum, auto
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Log to file — no console output when running under pythonw
|
||||||
|
_log_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'compass_sim.log')
|
||||||
|
logging.basicConfig(
|
||||||
|
filename=_log_path,
|
||||||
|
filemode='w',
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s %(message)s',
|
||||||
|
datefmt='%H:%M:%S',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _log(msg: str):
|
||||||
|
logging.info(msg)
|
||||||
|
try:
|
||||||
|
print(msg) # only visible when running from a terminal
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── NMEA helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _cs(s: str) -> str:
|
||||||
|
v = 0
|
||||||
|
for c in s:
|
||||||
|
v ^= ord(c)
|
||||||
|
return f'{v:02X}'
|
||||||
|
|
||||||
|
def _sentence(body: str) -> str:
|
||||||
|
return f'${body}*{_cs(body)}\r\n'
|
||||||
|
|
||||||
|
|
||||||
|
# ── Simulation phases ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Phase(Enum):
|
||||||
|
INITIAL = auto() # Estable en rumbo inicial
|
||||||
|
TURN_STBD = auto() # Girando por estribor
|
||||||
|
COMP_STBD = auto() # Timonel compensa al llegar a 225°
|
||||||
|
STEADY_STBD = auto() # Estable en 225°
|
||||||
|
TURN_PORT = auto() # Girando por babor
|
||||||
|
COMP_PORT = auto() # Timonel compensa al llegar a 045°
|
||||||
|
NORMAL = auto() # Navegación normal con olas
|
||||||
|
|
||||||
|
PHASE_NAMES = {
|
||||||
|
Phase.INITIAL: 'INITIAL — Estable en 045°M',
|
||||||
|
Phase.TURN_STBD: 'TURN_STBD — Girando ESTRIBOR → 225°',
|
||||||
|
Phase.COMP_STBD: 'COMP_STBD — Timonel compensa (ROT → 0)',
|
||||||
|
Phase.STEADY_STBD: 'STEADY_STBD — Estable en 225°M',
|
||||||
|
Phase.TURN_PORT: 'TURN_PORT — Girando BABOR → 045°',
|
||||||
|
Phase.COMP_PORT: 'COMP_PORT — Timonel compensa (ROT → 0)',
|
||||||
|
Phase.NORMAL: 'NORMAL — Navegación suave',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
INITIAL_HDG = 45.0 # Rumbo inicial °M
|
||||||
|
TURN_DEG = 25.0 # Grados de la maniobra (por estribor y luego por babor)
|
||||||
|
MAX_ROT = 30.0 # ROT máximo °/min (30 = 0.5°/s — maniobra moderada)
|
||||||
|
ROT_RAMP_SECS = 8.0 # Segundos para alcanzar MAX_ROT desde 0
|
||||||
|
COMP_THRESHOLD = 8.0 # Grados antes del rumbo objetivo donde el timonel compensa
|
||||||
|
STEADY_SECS = 15.0 # Tiempo estable en cada rumbo (segundos)
|
||||||
|
SOG = 8.5 # Velocidad (knots)
|
||||||
|
VARIATION = -7.5 # Variación magnética (W = negativo)
|
||||||
|
LAT = 27.1975 # Stuart, FL
|
||||||
|
LON = -80.1951
|
||||||
|
|
||||||
|
|
||||||
|
# ── Vessel state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Vessel:
|
||||||
|
heading: float = INITIAL_HDG
|
||||||
|
rot: float = 0.0 # °/min (+ = estribor, - = babor)
|
||||||
|
rot_target: float = 0.0 # ROT objetivo
|
||||||
|
pitch: float = 0.0
|
||||||
|
roll: float = 0.0
|
||||||
|
sog: float = SOG
|
||||||
|
phase: Phase = Phase.INITIAL
|
||||||
|
phase_t: float = 0.0 # tiempo en la fase actual (s)
|
||||||
|
norm_t: float = 0.0 # contador para simulación normal
|
||||||
|
sim_t: float = 0.0 # tiempo global continuo (s)
|
||||||
|
_target_hdg: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class VesselSim:
|
||||||
|
|
||||||
|
TICK = 0.25 # segundos por step de simulación
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.v = Vessel()
|
||||||
|
self.v._target_hdg = (INITIAL_HDG + TURN_DEG) % 360
|
||||||
|
self._phase_start_print()
|
||||||
|
|
||||||
|
# ── Main tick ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def step(self) -> None:
|
||||||
|
v = self.v
|
||||||
|
v.phase_t += self.TICK
|
||||||
|
v.sim_t += self.TICK
|
||||||
|
|
||||||
|
if v.phase == Phase.INITIAL: self._phase_initial()
|
||||||
|
elif v.phase == Phase.TURN_STBD: self._phase_turn(+1)
|
||||||
|
elif v.phase == Phase.COMP_STBD: self._phase_compensate(+1, Phase.STEADY_STBD)
|
||||||
|
elif v.phase == Phase.STEADY_STBD: self._phase_steady(Phase.TURN_PORT)
|
||||||
|
elif v.phase == Phase.TURN_PORT: self._phase_turn(-1)
|
||||||
|
elif v.phase == Phase.COMP_PORT: self._phase_compensate(-1, Phase.NORMAL)
|
||||||
|
elif v.phase == Phase.NORMAL: self._phase_normal()
|
||||||
|
|
||||||
|
t = v.sim_t
|
||||||
|
# Pitch: oleaje grueso — swell 8s (ω=0.785) + chop 3s (ω=2.09), máx ±5°
|
||||||
|
pitch_wave = (3.5 * math.sin(t * 0.785)
|
||||||
|
+ 1.1 * math.sin(t * 2.09 + 1.1)
|
||||||
|
+ 0.3 * math.sin(t * 3.14 + 0.5))
|
||||||
|
v.pitch = max(-5.0, min(5.0, pitch_wave + random.uniform(-0.10, 0.10)))
|
||||||
|
|
||||||
|
# Roll: período más corto que pitch (6s, ω=1.047), cabeceo cruzado, escora de maniobra
|
||||||
|
roll_wave = (3.8 * math.sin(t * 1.047 + 0.75)
|
||||||
|
+ 0.9 * math.sin(t * 2.51 + 2.3))
|
||||||
|
roll_turn = -(v.rot / MAX_ROT) * 2.5
|
||||||
|
v.roll = max(-5.0, min(5.0, roll_wave + roll_turn + random.uniform(-0.10, 0.10)))
|
||||||
|
|
||||||
|
# Update heading from ROT
|
||||||
|
v.heading = (v.heading + v.rot / 60.0 * self.TICK) % 360
|
||||||
|
|
||||||
|
# ── Phases ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _phase_initial(self):
|
||||||
|
v = self.v
|
||||||
|
v.rot = 0.0
|
||||||
|
if v.phase_t >= STEADY_SECS:
|
||||||
|
self._set_phase(Phase.TURN_STBD)
|
||||||
|
v._target_hdg = (INITIAL_HDG + TURN_DEG) % 360
|
||||||
|
|
||||||
|
def _phase_turn(self, direction: int):
|
||||||
|
"""direction: +1 = estribor, -1 = babor"""
|
||||||
|
v = self.v
|
||||||
|
target = v._target_hdg
|
||||||
|
|
||||||
|
# Angular distance remaining to target
|
||||||
|
if direction == +1:
|
||||||
|
remaining = (target - v.heading) % 360
|
||||||
|
else:
|
||||||
|
remaining = (v.heading - target) % 360
|
||||||
|
|
||||||
|
# Ramp ROT up over ROT_RAMP_SECS
|
||||||
|
ramp_progress = min(v.phase_t / ROT_RAMP_SECS, 1.0)
|
||||||
|
v.rot_target = direction * MAX_ROT * ramp_progress
|
||||||
|
|
||||||
|
# Within COMP_THRESHOLD: start reducing ROT (helmsman anticipates)
|
||||||
|
if remaining < COMP_THRESHOLD:
|
||||||
|
comp_factor = remaining / COMP_THRESHOLD
|
||||||
|
v.rot_target *= comp_factor
|
||||||
|
|
||||||
|
# Smooth ROT toward target
|
||||||
|
v.rot += (v.rot_target - v.rot) * 0.25
|
||||||
|
|
||||||
|
# Reached target heading?
|
||||||
|
if remaining < 2.0:
|
||||||
|
v.heading = target
|
||||||
|
if direction == +1:
|
||||||
|
self._set_phase(Phase.COMP_STBD)
|
||||||
|
else:
|
||||||
|
self._set_phase(Phase.COMP_PORT)
|
||||||
|
|
||||||
|
def _phase_compensate(self, prev_direction: int, next_phase: Phase):
|
||||||
|
"""Timonel centra el timón: ROT decae a 0."""
|
||||||
|
v = self.v
|
||||||
|
v.rot *= 0.80 # decaimiento exponencial suave
|
||||||
|
if abs(v.rot) < 0.4:
|
||||||
|
v.rot = 0.0
|
||||||
|
self._set_phase(next_phase)
|
||||||
|
if next_phase == Phase.TURN_PORT:
|
||||||
|
v._target_hdg = INITIAL_HDG # girar de vuelta a 045°
|
||||||
|
|
||||||
|
def _phase_steady(self, next_phase: Phase):
|
||||||
|
v = self.v
|
||||||
|
v.rot *= 0.90 # cualquier ROT residual se disipa
|
||||||
|
if v.phase_t >= STEADY_SECS:
|
||||||
|
self._set_phase(next_phase)
|
||||||
|
if next_phase == Phase.TURN_PORT:
|
||||||
|
v._target_hdg = INITIAL_HDG
|
||||||
|
|
||||||
|
def _phase_normal(self):
|
||||||
|
"""Navegación en mar grueso — giñadas amplias y correcciones rápidas."""
|
||||||
|
v = self.v
|
||||||
|
v.norm_t += self.TICK
|
||||||
|
t = v.norm_t
|
||||||
|
# Giñada compuesta: deriva principal 12° + componente irregular 5°
|
||||||
|
hdg_drift = 12.0 * math.sin(t * 0.09) + 5.0 * math.sin(t * 0.23 + 1.7)
|
||||||
|
target_hdg = (INITIAL_HDG + hdg_drift) % 360
|
||||||
|
err = ((target_hdg - v.heading) + 180) % 360 - 180
|
||||||
|
v.rot_target = max(-25.0, min(25.0, err * 2.5))
|
||||||
|
v.rot += (v.rot_target - v.rot) * 0.20
|
||||||
|
|
||||||
|
def _add_sea_noise(self):
|
||||||
|
pass # replaced by sinusoidal model in step()
|
||||||
|
|
||||||
|
# ── Derived data ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def accel(self):
|
||||||
|
"""Aceleración simulada del BNO085 (m/s²)."""
|
||||||
|
v = self.v
|
||||||
|
# Centrípeta en eje Y proporcional al ROT
|
||||||
|
centripetal_y = (v.rot / 60.0) * math.radians(v.sog * 0.5144) * 0.5
|
||||||
|
ax = math.sin(math.radians(v.pitch)) * 9.81 + random.uniform(-0.03, 0.03)
|
||||||
|
ay = math.sin(math.radians(v.roll)) * 9.81 + centripetal_y + random.uniform(-0.03, 0.03)
|
||||||
|
az = math.cos(math.radians(v.pitch)) * math.cos(math.radians(v.roll)) * 9.81
|
||||||
|
return ax, ay, az
|
||||||
|
|
||||||
|
def gyro(self):
|
||||||
|
"""Velocidad angular del giroscopio (°/s)."""
|
||||||
|
v = self.v
|
||||||
|
gx = v.roll * 0.02 + random.uniform(-0.02, 0.02)
|
||||||
|
gy = v.pitch * 0.02 + random.uniform(-0.02, 0.02)
|
||||||
|
gz = v.rot / 60.0 + random.uniform(-0.01, 0.01)
|
||||||
|
return gx, gy, gz
|
||||||
|
|
||||||
|
# ── NMEA sentences ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def sentences(self) -> list[str]:
|
||||||
|
self.step()
|
||||||
|
v = self.v
|
||||||
|
ax, ay, az = self.accel()
|
||||||
|
gx, gy, gz = self.gyro()
|
||||||
|
var_dir = 'W' if VARIATION < 0 else 'E'
|
||||||
|
|
||||||
|
return [
|
||||||
|
_sentence(f'HCHDG,{v.heading:.2f},,,{abs(VARIATION):.1f},{var_dir}'),
|
||||||
|
_sentence(f'IIROT,{v.rot:.2f},A'),
|
||||||
|
_sentence(
|
||||||
|
f'IIXDR,'
|
||||||
|
f'A,{v.pitch:.2f},D,PITCH,'
|
||||||
|
f'A,{v.roll:.2f},D,ROLL,'
|
||||||
|
f'A,{ax:.3f},M,ACCELX,'
|
||||||
|
f'A,{ay:.3f},M,ACCELY,'
|
||||||
|
f'A,{az:.3f},M,ACCELZ,'
|
||||||
|
f'A,{gx:.3f},D,GYROX,'
|
||||||
|
f'A,{gy:.3f},D,GYROY,'
|
||||||
|
f'A,{gz:.3f},D,GYROZ'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Phase helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _set_phase(self, phase: Phase):
|
||||||
|
self.v.phase = phase
|
||||||
|
self.v.phase_t = 0.0
|
||||||
|
self._phase_start_print()
|
||||||
|
|
||||||
|
def _phase_start_print(self):
|
||||||
|
v = self.v
|
||||||
|
name = PHASE_NAMES.get(v.phase, str(v.phase))
|
||||||
|
_log(f'[SIM] -- {name}')
|
||||||
|
_log(f'[SIM] HDG={v.heading:.1f} ROT={v.rot:.1f}/min '
|
||||||
|
f'PITCH={v.pitch:.1f} ROLL={v.roll:.1f}')
|
||||||
|
|
||||||
|
|
||||||
|
# ── TCP server ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _tcp_server(sim: VesselSim):
|
||||||
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
srv.bind(('127.0.0.1', 10110))
|
||||||
|
srv.listen(5)
|
||||||
|
_log('[SIM] NMEA TCP server en 127.0.0.1:10110 (1 Hz)')
|
||||||
|
_log('[SIM] Conecta con: python main.py --sim')
|
||||||
|
|
||||||
|
while True:
|
||||||
|
conn, addr = srv.accept()
|
||||||
|
_log(f'[SIM] Cliente conectado: {addr}')
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
t0 = time.time()
|
||||||
|
for s in sim.sentences():
|
||||||
|
conn.sendall(s.encode('ascii'))
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
time.sleep(max(0, 1.0 - elapsed))
|
||||||
|
except Exception:
|
||||||
|
_log(f'[SIM] Cliente desconectado: {addr}')
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Entry points ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_simulator():
|
||||||
|
"""Called by main.py --sim. Patches SerialReader and starts TCP server."""
|
||||||
|
sim = VesselSim()
|
||||||
|
t = threading.Thread(target=_tcp_server, args=(sim,), daemon=True)
|
||||||
|
t.start()
|
||||||
|
time.sleep(0.35)
|
||||||
|
config.SERIAL_PORTS = [{'port': 'tcp://127.0.0.1:10110', 'baud': 4800, 'name': 'Simulator'}]
|
||||||
|
_patch_reader()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_reader():
|
||||||
|
"""Monkey-patch SerialReader to handle tcp:// URI for the simulator."""
|
||||||
|
import socket as _socket
|
||||||
|
from core.serial_reader import SerialReader
|
||||||
|
|
||||||
|
original_run = SerialReader.run
|
||||||
|
|
||||||
|
def patched_run(self):
|
||||||
|
if not self.port.startswith('tcp://'):
|
||||||
|
return original_run(self)
|
||||||
|
self._running = True
|
||||||
|
host, port = self.port[6:].split(':')
|
||||||
|
try:
|
||||||
|
s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
||||||
|
s.connect((host, int(port)))
|
||||||
|
self.connected.emit(True, self.port)
|
||||||
|
buf = ''
|
||||||
|
while self._running:
|
||||||
|
data = s.recv(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
buf += data.decode('ascii', errors='ignore')
|
||||||
|
while '\n' in buf:
|
||||||
|
line, buf = buf.split('\n', 1)
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('$') or line.startswith('!'):
|
||||||
|
self.sentence.emit(line)
|
||||||
|
except Exception as e:
|
||||||
|
self.error.emit(str(e))
|
||||||
|
self.connected.emit(False, self.port)
|
||||||
|
|
||||||
|
SerialReader.run = patched_run
|
||||||
|
|
||||||
|
|
||||||
|
# ── Standalone ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
"""Run standalone to see console output without the UI."""
|
||||||
|
print('=' * 58)
|
||||||
|
print(' MARINE COMPASS SIMULATOR - Secuencia de Maniobras')
|
||||||
|
print('=' * 58)
|
||||||
|
print('')
|
||||||
|
sim = VesselSim()
|
||||||
|
try:
|
||||||
|
t = threading.Thread(target=_tcp_server, args=(sim,), daemon=True)
|
||||||
|
t.start()
|
||||||
|
# Keep alive — TCP server runs in daemon thread
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('\n[SIM] Detenido.')
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""
|
||||||
|
Compass rose widget.
|
||||||
|
The card rotates; the lubber line (red) is fixed at top.
|
||||||
|
Current heading always appears at the top of the rose.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from PyQt5.QtWidgets import QWidget
|
||||||
|
from PyQt5.QtCore import Qt, QPointF, QRectF
|
||||||
|
from PyQt5.QtGui import (QPainter, QPen, QBrush, QPainterPath,
|
||||||
|
QRadialGradient, QConicalGradient, QColor, QFont)
|
||||||
|
import ui.styles as S
|
||||||
|
|
||||||
|
|
||||||
|
class CompassWidget(QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setMinimumSize(300, 300)
|
||||||
|
self._hdg = 0.0
|
||||||
|
self._roll = 0.0
|
||||||
|
self._pitch = 0.0
|
||||||
|
self._night = False
|
||||||
|
self._hdg_mode = 'M' # 'M' = magnetic, 'T' = true
|
||||||
|
|
||||||
|
# ── Public setters ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_heading(self, h: float):
|
||||||
|
self._hdg = h % 360
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_attitude(self, pitch: float, roll: float):
|
||||||
|
self._pitch = pitch
|
||||||
|
self._roll = roll
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_hdg_mode(self, mode: str):
|
||||||
|
self._hdg_mode = mode
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_night(self, on: bool):
|
||||||
|
self._night = on
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── Paint ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
p.setRenderHint(QPainter.TextAntialiasing)
|
||||||
|
|
||||||
|
w, h = self.width(), self.height()
|
||||||
|
cx, cy = w / 2.0, h / 2.0
|
||||||
|
R = min(cx, cy) * 0.94 # outer bezel radius
|
||||||
|
|
||||||
|
C = S.is_night(self._night)
|
||||||
|
p.fillRect(self.rect(), C['bg'])
|
||||||
|
|
||||||
|
self._bezel(p, cx, cy, R, C)
|
||||||
|
|
||||||
|
# ── Rotating card ──────────────────────────────────────────────────
|
||||||
|
p.save()
|
||||||
|
p.translate(cx, cy)
|
||||||
|
p.rotate(-self._hdg) # card rotates opposite to heading
|
||||||
|
|
||||||
|
self._card_bg(p, R * 0.87, C)
|
||||||
|
self._tick_ring(p, R * 0.71, R * 0.87, C)
|
||||||
|
self._star(p, R * 0.50, C)
|
||||||
|
self._cardinal_labels(p, R * 0.62, C)
|
||||||
|
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
# ── Fixed elements ─────────────────────────────────────────────────
|
||||||
|
p.save()
|
||||||
|
p.translate(cx, cy)
|
||||||
|
self._lubber_line(p, R, C)
|
||||||
|
self._center_ball(p, R * 0.10, C)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
self._heading_readout(p, cx, cy, R, C)
|
||||||
|
|
||||||
|
# ── Drawing primitives ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _bezel(self, p, cx, cy, R, C):
|
||||||
|
p.save()
|
||||||
|
p.translate(cx, cy)
|
||||||
|
|
||||||
|
g = QRadialGradient(QPointF(0, -R * 0.3), R * 1.2)
|
||||||
|
g.setColorAt(0.0, S.BEZEL_MID)
|
||||||
|
g.setColorAt(0.82, S.BEZEL_DARK)
|
||||||
|
g.setColorAt(0.88, C['gold'])
|
||||||
|
g.setColorAt(0.93, C['bright'])
|
||||||
|
g.setColorAt(1.0, C['gold'].darker(160))
|
||||||
|
|
||||||
|
p.setBrush(QBrush(g))
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.drawEllipse(QPointF(0, 0), R * 1.01, R * 1.01)
|
||||||
|
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
def _card_bg(self, p, r, C):
|
||||||
|
g = QRadialGradient(QPointF(0, -r * 0.2), r)
|
||||||
|
g.setColorAt(0.0, QColor('#121A2E'))
|
||||||
|
g.setColorAt(0.7, QColor('#090D1A'))
|
||||||
|
g.setColorAt(1.0, QColor('#060810'))
|
||||||
|
p.setBrush(QBrush(g))
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.drawEllipse(QPointF(0, 0), r, r)
|
||||||
|
|
||||||
|
def _tick_ring(self, p, r_in, r_out, C):
|
||||||
|
span = r_out - r_in
|
||||||
|
|
||||||
|
for deg in range(0, 360, 5):
|
||||||
|
p.save()
|
||||||
|
p.rotate(deg)
|
||||||
|
|
||||||
|
if deg % 90 == 0:
|
||||||
|
length = span * 0.88
|
||||||
|
pen = QPen(C['bright'], max(1.8, r_out * 0.006))
|
||||||
|
elif deg % 45 == 0:
|
||||||
|
length = span * 0.72
|
||||||
|
pen = QPen(C['gold'], max(1.4, r_out * 0.005))
|
||||||
|
elif deg % 10 == 0:
|
||||||
|
length = span * 0.55
|
||||||
|
pen = QPen(C['gold'].darker(130), max(1.1, r_out * 0.004))
|
||||||
|
else:
|
||||||
|
length = span * 0.32
|
||||||
|
pen = QPen(S.GOLD_DIM, max(0.8, r_out * 0.003))
|
||||||
|
|
||||||
|
p.setPen(pen)
|
||||||
|
p.drawLine(QPointF(0, -r_out), QPointF(0, -r_out + length))
|
||||||
|
|
||||||
|
# Degree numbers every 10°, skip cardinal positions
|
||||||
|
if deg % 10 == 0 and deg % 45 != 0:
|
||||||
|
fs = max(7, int(r_out * 0.052))
|
||||||
|
f = QFont('Arial', fs)
|
||||||
|
p.setFont(f)
|
||||||
|
p.setPen(QPen(C['dim']))
|
||||||
|
text_y = -(r_out - span * 0.98) - fs * 1.6
|
||||||
|
rect = QRectF(-20, text_y - 12, 40, 24)
|
||||||
|
p.drawText(rect, Qt.AlignCenter, str(deg // 10))
|
||||||
|
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
def _star(self, p, r, C):
|
||||||
|
"""16-point compass star. N=red, cardinals=white, intercardinals=gold."""
|
||||||
|
for i in range(16):
|
||||||
|
p.save()
|
||||||
|
p.rotate(i * 22.5)
|
||||||
|
|
||||||
|
cardinal = (i % 4 == 0)
|
||||||
|
intercardinal = (i % 2 == 0 and not cardinal)
|
||||||
|
is_north = (i == 0)
|
||||||
|
|
||||||
|
if cardinal:
|
||||||
|
tip = -r
|
||||||
|
shld_y = -r * 0.28
|
||||||
|
width = r * 0.17
|
||||||
|
color = C['red'] if is_north else C['white']
|
||||||
|
elif intercardinal:
|
||||||
|
tip = -r * 0.68
|
||||||
|
shld_y = -r * 0.20
|
||||||
|
width = r * 0.11
|
||||||
|
color = C['gold']
|
||||||
|
else:
|
||||||
|
tip = -r * 0.42
|
||||||
|
shld_y = -r * 0.12
|
||||||
|
width = r * 0.07
|
||||||
|
color = S.GOLD_DIM
|
||||||
|
|
||||||
|
# Main point (upper half — bright)
|
||||||
|
path = QPainterPath()
|
||||||
|
path.moveTo(0, tip)
|
||||||
|
path.lineTo(-width / 2, shld_y)
|
||||||
|
path.lineTo(0, 0)
|
||||||
|
path.lineTo( width / 2, shld_y)
|
||||||
|
path.closeSubpath()
|
||||||
|
p.setBrush(QBrush(color))
|
||||||
|
p.setPen(QPen(color.darker(200), 0.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
# Lower shadow half
|
||||||
|
shadow = QPainterPath()
|
||||||
|
shadow.moveTo(0, 0)
|
||||||
|
shadow.lineTo(-width / 2, shld_y)
|
||||||
|
shadow.lineTo(0, abs(tip) * 0.22)
|
||||||
|
shadow.lineTo( width / 2, shld_y)
|
||||||
|
shadow.closeSubpath()
|
||||||
|
p.setBrush(QBrush(color.darker(220)))
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.drawPath(shadow)
|
||||||
|
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
# Center ring
|
||||||
|
cr = r * 0.09
|
||||||
|
p.setPen(QPen(C['gold'], max(1.5, r * 0.025)))
|
||||||
|
p.setBrush(QBrush(S.BEZEL_DARK))
|
||||||
|
p.drawEllipse(QPointF(0, 0), cr, cr)
|
||||||
|
|
||||||
|
def _cardinal_labels(self, p, r, C):
|
||||||
|
entries = [
|
||||||
|
( 0, 'N', C['bright'], True, True),
|
||||||
|
( 90, 'E', C['white'], True, False),
|
||||||
|
(180, 'S', C['white'], True, False),
|
||||||
|
(270, 'W', C['white'], True, False),
|
||||||
|
( 45, 'NE', C['gold'], False, False),
|
||||||
|
(135, 'SE', C['gold'], False, False),
|
||||||
|
(225, 'SW', C['gold'], False, False),
|
||||||
|
(315, 'NW', C['gold'], False, False),
|
||||||
|
]
|
||||||
|
for angle, label, color, big, bold in entries:
|
||||||
|
p.save()
|
||||||
|
p.rotate(angle)
|
||||||
|
fs = max(11, int(r * 0.17)) if big else max(7, int(r * 0.10))
|
||||||
|
f = QFont('Arial', fs)
|
||||||
|
f.setBold(bold)
|
||||||
|
p.setFont(f)
|
||||||
|
p.setPen(QPen(color))
|
||||||
|
rect = QRectF(-r * 0.20, -r * 1.12, r * 0.40, r * 0.24)
|
||||||
|
p.drawText(rect, Qt.AlignCenter, label)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
def _lubber_line(self, p, R, C):
|
||||||
|
"""Fixed red lubber line — always at top = ship's heading."""
|
||||||
|
pen = QPen(C['red'], max(2.5, R * 0.013))
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
p.setPen(pen)
|
||||||
|
p.drawLine(QPointF(0, -R * 0.97), QPointF(0, -R * 0.70))
|
||||||
|
|
||||||
|
# Arrow tip
|
||||||
|
path = QPainterPath()
|
||||||
|
path.moveTo(0, -R * 0.70)
|
||||||
|
path.lineTo(-R * 0.028, -R * 0.62)
|
||||||
|
path.lineTo( R * 0.028, -R * 0.62)
|
||||||
|
path.closeSubpath()
|
||||||
|
p.setBrush(QBrush(C['red']))
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
def _center_ball(self, p, r, C):
|
||||||
|
"""Ball in center that shows roll/pitch deviation."""
|
||||||
|
p.setPen(QPen(C['gold'], max(1.5, r * 0.10)))
|
||||||
|
p.setBrush(QBrush(QColor('#080C18')))
|
||||||
|
p.drawEllipse(QPointF(0, 0), r, r)
|
||||||
|
|
||||||
|
max_d = r * 0.58
|
||||||
|
ball_x = math.sin(math.radians(self._roll)) * max_d
|
||||||
|
ball_y = -math.sin(math.radians(self._pitch)) * max_d
|
||||||
|
dist = math.hypot(ball_x, ball_y)
|
||||||
|
if dist > max_d:
|
||||||
|
ball_x = ball_x / dist * max_d
|
||||||
|
ball_y = ball_y / dist * max_d
|
||||||
|
|
||||||
|
br = r * 0.38
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QBrush(C['gold']))
|
||||||
|
p.drawEllipse(QPointF(ball_x, ball_y), br, br)
|
||||||
|
|
||||||
|
def _heading_readout(self, p, cx, cy, R, C):
|
||||||
|
"""Digital heading displayed at the top of the compass, just below the lubber arrow."""
|
||||||
|
fs = max(13, int(R * 0.115))
|
||||||
|
p.setFont(QFont('Courier New', fs, QFont.Bold))
|
||||||
|
|
||||||
|
# Semi-transparent backing so text reads cleanly over the rotating card
|
||||||
|
box_w = R * 0.68
|
||||||
|
box_h = R * 0.21
|
||||||
|
box_x = cx - box_w / 2
|
||||||
|
box_y = cy - R * 0.59
|
||||||
|
p.setBrush(QBrush(QColor(6, 9, 20, 200)))
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.drawRoundedRect(QRectF(box_x, box_y, box_w, box_h),
|
||||||
|
max(3, box_h * 0.20), max(3, box_h * 0.20))
|
||||||
|
|
||||||
|
p.setPen(QPen(C['bright']))
|
||||||
|
p.drawText(QRectF(box_x, box_y, box_w, box_h),
|
||||||
|
Qt.AlignCenter, f"{self._hdg:05.1f}°{self._hdg_mode}")
|
||||||
@@ -0,0 +1,949 @@
|
|||||||
|
"""
|
||||||
|
Right-side information panel — fully responsive.
|
||||||
|
• Large heading display
|
||||||
|
• Data fields (HDG T, ROT, PITCH, ROLL, VAR, HEAVE, YAW RATE)
|
||||||
|
• ROT arc indicator
|
||||||
|
• Boat attitude silhouettes (pitch / roll / yaw)
|
||||||
|
• Touch buttons
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
|
QLabel, QPushButton, QFrame, QSizePolicy)
|
||||||
|
from PyQt5.QtCore import Qt, pyqtSignal, QRectF, QPointF
|
||||||
|
from PyQt5.QtGui import (QFont, QPainter, QPen, QBrush,
|
||||||
|
QColor, QPainterPath)
|
||||||
|
import ui.styles as S
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Info Panel
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class InfoPanel(QWidget):
|
||||||
|
|
||||||
|
night_toggled = pyqtSignal(bool)
|
||||||
|
port_requested = pyqtSignal()
|
||||||
|
hdg_mode_changed = pyqtSignal(str) # 'M' or 'T'
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._night = False
|
||||||
|
self._hdg_mode = 'M'
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
# ── Layout ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(4, 4, 4, 4)
|
||||||
|
root.setSpacing(4)
|
||||||
|
self._root_layout = root
|
||||||
|
|
||||||
|
# Large magnetic heading
|
||||||
|
self.hdg_val = QLabel('---.-°M')
|
||||||
|
self.hdg_val.setAlignment(Qt.AlignCenter)
|
||||||
|
self.hdg_val.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored)
|
||||||
|
root.addWidget(self.hdg_val, stretch=4)
|
||||||
|
|
||||||
|
# Data rows
|
||||||
|
self._rows = {}
|
||||||
|
fields = [
|
||||||
|
('HDG (T)', 'hdg_t', '---.-°T'),
|
||||||
|
('ROT', 'rot', '--- °/min'),
|
||||||
|
('PITCH', 'pitch', '+--.-°'),
|
||||||
|
('ROLL', 'roll', '+--.-°'),
|
||||||
|
('VAR', 'var', '--.-°-'),
|
||||||
|
('HEAVE', 'accel_z', '--.- m/s²'),
|
||||||
|
('YAW RATE', 'gyro_z', '--.- °/s'),
|
||||||
|
]
|
||||||
|
rows_wrap = QWidget()
|
||||||
|
rows_wrap.setObjectName('section_box')
|
||||||
|
rows_vl = QVBoxLayout(rows_wrap)
|
||||||
|
rows_vl.setContentsMargins(0, 0, 0, 0)
|
||||||
|
rows_vl.setSpacing(0)
|
||||||
|
for label, key, default in fields:
|
||||||
|
row, val = self._field_row(label, default)
|
||||||
|
self._rows[key] = val
|
||||||
|
rows_vl.addWidget(row)
|
||||||
|
root.addWidget(rows_wrap, stretch=3 * 7)
|
||||||
|
|
||||||
|
# ROT arc
|
||||||
|
self.rot_arc = RotArc()
|
||||||
|
self.rot_arc.setObjectName('section_box')
|
||||||
|
root.addWidget(self.rot_arc, stretch=5)
|
||||||
|
|
||||||
|
# Boat attitude silhouettes
|
||||||
|
import config
|
||||||
|
self.boat_att = BoatAttitudeWidget(vessel_type=config.VESSEL_TYPE)
|
||||||
|
self.boat_att.setObjectName('section_box')
|
||||||
|
root.addWidget(self.boat_att, stretch=7)
|
||||||
|
|
||||||
|
# Touch buttons
|
||||||
|
self._btn_container = QWidget()
|
||||||
|
self._btn_container.setObjectName('section_box')
|
||||||
|
self._btn_layout = QHBoxLayout(self._btn_container)
|
||||||
|
self._btn_hdg = self._btn('HDG °M', self._toggle_hdg_mode)
|
||||||
|
self._btn_night = self._btn('NIGHT', self._toggle_night)
|
||||||
|
self._btn_port = self._btn('PORTS', self.port_requested.emit)
|
||||||
|
self._btn_layout.addWidget(self._btn_hdg)
|
||||||
|
self._btn_layout.addWidget(self._btn_night)
|
||||||
|
self._btn_layout.addWidget(self._btn_port)
|
||||||
|
root.addWidget(self._btn_container, stretch=2)
|
||||||
|
|
||||||
|
self._apply_theme()
|
||||||
|
|
||||||
|
def _field_row(self, label_text, default):
|
||||||
|
row = QWidget()
|
||||||
|
row.setObjectName('field_row')
|
||||||
|
hl = QHBoxLayout(row)
|
||||||
|
hl.setContentsMargins(0, 0, 0, 0)
|
||||||
|
lbl = QLabel(label_text)
|
||||||
|
lbl.setObjectName('field_lbl')
|
||||||
|
lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||||
|
val = QLabel(default)
|
||||||
|
val.setObjectName('field_val')
|
||||||
|
val.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
hl.addWidget(lbl, stretch=4)
|
||||||
|
hl.addWidget(val, stretch=6)
|
||||||
|
return row, val
|
||||||
|
|
||||||
|
def _divider(self):
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFixedHeight(1)
|
||||||
|
line.setStyleSheet(f'background: {S.DIVIDER.name()};')
|
||||||
|
return line
|
||||||
|
|
||||||
|
def _btn(self, text, callback):
|
||||||
|
b = QPushButton(text)
|
||||||
|
b.setObjectName('nav_btn')
|
||||||
|
b.clicked.connect(callback)
|
||||||
|
return b
|
||||||
|
|
||||||
|
# ── Theme ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _apply_theme(self):
|
||||||
|
n = self._night
|
||||||
|
bg = S.N_BG.name() if n else S.PANEL_BG.name()
|
||||||
|
gold = S.N_GOLD.name() if n else S.GOLD.name()
|
||||||
|
bright = S.N_GOLD.name() if n else S.GOLD_BRIGHT.name()
|
||||||
|
white = S.N_WHITE.name() if n else S.WHITE.name()
|
||||||
|
dim = S.N_WHITE.name() if n else S.WHITE_DIM.name()
|
||||||
|
box_bg = S.BEZEL_DARK.name()
|
||||||
|
|
||||||
|
h = max(self.height(), 480)
|
||||||
|
w = max(self.width(), 200)
|
||||||
|
|
||||||
|
hdg_fs = max(14, h // 12)
|
||||||
|
data_fs = max(10, h // 38)
|
||||||
|
lbl_fs = max(9, h // 48)
|
||||||
|
btn_fs = max(9, h // 50)
|
||||||
|
btn_h = max(36, h // 14)
|
||||||
|
pad_h = max(2, h // 120)
|
||||||
|
pad_w = max(6, w // 25)
|
||||||
|
btn_sp = max(3, w // 60)
|
||||||
|
btn_rad = max(4, h // 100)
|
||||||
|
gap = max(4, h // 120) # spacing between frames
|
||||||
|
brad = max(4, h // 140) # border radius for section boxes
|
||||||
|
|
||||||
|
self._root_layout.setContentsMargins(gap, gap, gap, gap)
|
||||||
|
self._root_layout.setSpacing(gap)
|
||||||
|
self._btn_layout.setContentsMargins(btn_sp, btn_sp, btn_sp, btn_sp)
|
||||||
|
self._btn_layout.setSpacing(btn_sp)
|
||||||
|
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QWidget {{
|
||||||
|
background: {bg};
|
||||||
|
color: {white};
|
||||||
|
font-family: 'Courier New';
|
||||||
|
}}
|
||||||
|
QWidget#section_box {{
|
||||||
|
background: {box_bg};
|
||||||
|
border: 1px solid {S.DIVIDER.name()};
|
||||||
|
border-radius: {brad}px;
|
||||||
|
}}
|
||||||
|
QWidget#field_row {{
|
||||||
|
border-bottom: 1px solid {S.DIVIDER.name()};
|
||||||
|
}}
|
||||||
|
QLabel#field_lbl {{
|
||||||
|
color: {dim};
|
||||||
|
font-size: {lbl_fs}px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding-left: {pad_w}px;
|
||||||
|
padding-top: {pad_h}px;
|
||||||
|
padding-bottom: {pad_h}px;
|
||||||
|
}}
|
||||||
|
QLabel#field_val {{
|
||||||
|
color: {white};
|
||||||
|
font-size: {data_fs}px;
|
||||||
|
padding-right: {pad_w}px;
|
||||||
|
padding-top: {pad_h}px;
|
||||||
|
padding-bottom: {pad_h}px;
|
||||||
|
}}
|
||||||
|
QPushButton#nav_btn {{
|
||||||
|
background: {S.BEZEL_MID.name()};
|
||||||
|
color: {gold};
|
||||||
|
border: 1px solid {gold};
|
||||||
|
border-radius: {btn_rad}px;
|
||||||
|
font-size: {btn_fs}px;
|
||||||
|
font-weight: bold;
|
||||||
|
min-height: {btn_h}px;
|
||||||
|
}}
|
||||||
|
QPushButton#nav_btn:pressed {{
|
||||||
|
background: {gold};
|
||||||
|
color: {bg};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
f = QFont('Courier New', hdg_fs, QFont.Bold)
|
||||||
|
self.hdg_val.setFont(f)
|
||||||
|
self.hdg_val.setStyleSheet(f'color: {bright}; background: transparent;')
|
||||||
|
|
||||||
|
self.rot_arc.set_night(n)
|
||||||
|
self.boat_att.set_night(n)
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self._apply_theme()
|
||||||
|
|
||||||
|
# ── Data update ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def update_data(self, nav):
|
||||||
|
if self._hdg_mode == 'T':
|
||||||
|
hdg = nav.hdg_true_calc
|
||||||
|
suffix = '°T'
|
||||||
|
else:
|
||||||
|
hdg = nav.hdg_mag
|
||||||
|
suffix = '°M'
|
||||||
|
self.hdg_val.setText(f'{hdg:05.1f}{suffix}' if hdg is not None else f'---.--{suffix}')
|
||||||
|
self._set('hdg_t', self._fmt_hdg(nav.hdg_true_calc, '°T'))
|
||||||
|
self._set('rot', self._fmt_rot(nav.rot))
|
||||||
|
self._set('pitch', self._fmt_signed(nav.pitch, '°'))
|
||||||
|
self._set('roll', self._fmt_signed(nav.roll, '°'))
|
||||||
|
self._set('var', self._fmt_var(nav.variation))
|
||||||
|
self._set('accel_z', self._fmt_accel(nav.accel_z))
|
||||||
|
self._set('gyro_z', self._fmt_gyro(nav.gyro_z))
|
||||||
|
|
||||||
|
rot = nav.rot or 0.0
|
||||||
|
pitch = nav.pitch or 0.0
|
||||||
|
roll = nav.roll or 0.0
|
||||||
|
|
||||||
|
self.rot_arc.set_rot(rot)
|
||||||
|
self.boat_att.set_attitude(pitch, roll, rot)
|
||||||
|
|
||||||
|
def _set(self, key, text):
|
||||||
|
if key in self._rows:
|
||||||
|
self._rows[key].setText(text)
|
||||||
|
|
||||||
|
# ── Formatting ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt_hdg(v, suffix):
|
||||||
|
return f'{v:05.1f}{suffix}' if v is not None else f'---.--{suffix}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt_rot(v):
|
||||||
|
return f'{v:+.1f} °/min' if v is not None else '--- °/min'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt_signed(v, unit):
|
||||||
|
return f'{v:+.1f}{unit}' if v is not None else f'+--.-{unit}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt_var(v):
|
||||||
|
return f'{abs(v):.1f}°{"E" if v >= 0 else "W"}' if v is not None else '--.-°-'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt_accel(v):
|
||||||
|
return f'{v:+.2f} m/s²' if v is not None else '--.- m/s²'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt_gyro(v):
|
||||||
|
return f'{v:+.2f} °/s' if v is not None else '--.- °/s'
|
||||||
|
|
||||||
|
# ── Toggles ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _toggle_hdg_mode(self):
|
||||||
|
self._hdg_mode = 'T' if self._hdg_mode == 'M' else 'M'
|
||||||
|
self._btn_hdg.setText(f'HDG °{self._hdg_mode}')
|
||||||
|
self.hdg_mode_changed.emit(self._hdg_mode)
|
||||||
|
|
||||||
|
def _toggle_night(self):
|
||||||
|
self._night = not self._night
|
||||||
|
self._apply_theme()
|
||||||
|
self.night_toggled.emit(self._night)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# ROT Arc
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class RotArc(QWidget):
|
||||||
|
"""Semicircular rate-of-turn indicator. Fully proportional."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._rot = 0.0
|
||||||
|
self._night = False
|
||||||
|
|
||||||
|
def set_rot(self, rot):
|
||||||
|
self._rot = rot or 0.0
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_night(self, on):
|
||||||
|
self._night = on
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
w, h = self.width(), self.height()
|
||||||
|
if w < 10 or h < 10:
|
||||||
|
return
|
||||||
|
|
||||||
|
gold = S.N_GOLD if self._night else S.GOLD
|
||||||
|
white = S.N_WHITE if self._night else S.WHITE_DIM
|
||||||
|
red = S.N_RED if self._night else S.RED
|
||||||
|
blue = S.N_BLUE if self._night else S.BLUE
|
||||||
|
|
||||||
|
# Reserve top & bottom padding so text doesn't touch dividers
|
||||||
|
pad = max(6, h * 0.10)
|
||||||
|
cx = w / 2.0
|
||||||
|
cy = h * 0.60
|
||||||
|
r = min(w * 0.40, (h - pad * 2) * 0.68)
|
||||||
|
lw = max(2.0, r * 0.07)
|
||||||
|
|
||||||
|
# Grey track arc
|
||||||
|
p.setPen(QPen(S.BEZEL_MID, lw, Qt.SolidLine, Qt.RoundCap))
|
||||||
|
p.drawArc(int(cx - r), int(cy - r), int(r * 2), int(r * 2),
|
||||||
|
0, 180 * 16)
|
||||||
|
|
||||||
|
# Colored ROT arc (scale: ±60°/min = full 90° sweep)
|
||||||
|
rot_c = max(-60.0, min(60.0, self._rot))
|
||||||
|
if abs(rot_c) > 0.3:
|
||||||
|
span = -(rot_c / 60.0) * 90.0
|
||||||
|
color = blue if rot_c > 0 else red
|
||||||
|
p.setPen(QPen(color, lw * 1.4, Qt.SolidLine, Qt.RoundCap))
|
||||||
|
p.drawArc(int(cx - r), int(cy - r), int(r * 2), int(r * 2),
|
||||||
|
int(90 * 16), int(-span * 16))
|
||||||
|
|
||||||
|
# Center tick
|
||||||
|
p.setPen(QPen(gold, max(1.5, r * 0.04)))
|
||||||
|
p.drawLine(int(cx), int(cy - r * 1.10), int(cx), int(cy - r * 0.88))
|
||||||
|
|
||||||
|
# PORT / STBD labels — above the arc ends
|
||||||
|
side_fs = max(7, int(r * 0.17))
|
||||||
|
p.setFont(QFont('Arial', side_fs))
|
||||||
|
p.setPen(QPen(white))
|
||||||
|
side_h = max(14, int(r * 0.28))
|
||||||
|
p.drawText(int(cx - r - r * 0.1), int(cy - r * 0.18),
|
||||||
|
int(r * 0.9), side_h, Qt.AlignCenter, 'PORT')
|
||||||
|
p.drawText(int(cx + r * 0.2), int(cy - r * 0.18),
|
||||||
|
int(r * 0.9), side_h, Qt.AlignCenter, 'STBD')
|
||||||
|
|
||||||
|
# ROT value — with gap below arc
|
||||||
|
txt_fs = max(8, int(r * 0.22))
|
||||||
|
txt_h = max(16, int(r * 0.35))
|
||||||
|
p.setFont(QFont('Courier New', txt_fs, QFont.Bold))
|
||||||
|
p.setPen(QPen(white))
|
||||||
|
p.drawText(0, int(cy + r * 0.18), w, txt_h,
|
||||||
|
Qt.AlignCenter, f'{self._rot:+.1f} °/min')
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Boat Attitude Widget
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class BoatAttitudeWidget(QWidget):
|
||||||
|
"""
|
||||||
|
Three animated boat silhouettes:
|
||||||
|
PITCH — side view, bow right, rotates fore/aft
|
||||||
|
ROLL — front view, tilts port/stbd
|
||||||
|
YAW — top view, rotates with ROT
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, vessel_type: str = 'motor_cruiser', parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._pitch = 0.0
|
||||||
|
self._roll = 0.0
|
||||||
|
self._rot = 0.0
|
||||||
|
self._night = False
|
||||||
|
self._vessel_type = vessel_type
|
||||||
|
|
||||||
|
def set_attitude(self, pitch: float, roll: float, rot: float = 0.0):
|
||||||
|
self._pitch = pitch
|
||||||
|
self._roll = roll
|
||||||
|
self._rot = rot
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_night(self, on: bool):
|
||||||
|
self._night = on
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing)
|
||||||
|
p.setRenderHint(QPainter.TextAntialiasing)
|
||||||
|
|
||||||
|
w, h = self.width(), self.height()
|
||||||
|
if w < 30 or h < 20:
|
||||||
|
return
|
||||||
|
|
||||||
|
pw = w / 3.0 # panel width per silhouette
|
||||||
|
|
||||||
|
self._draw_panel(p, 0, pw, h, self._pitch,
|
||||||
|
'PITCH', 'side', f'{self._pitch:+.1f}°')
|
||||||
|
self._draw_panel(p, pw, pw, h, self._roll,
|
||||||
|
'ROLL', 'front', f'{self._roll:+.1f}°')
|
||||||
|
self._draw_panel(p, pw * 2, pw, h, self._rot,
|
||||||
|
'YAW', 'top', f'{self._rot:+.1f}°/m')
|
||||||
|
|
||||||
|
def _draw_panel(self, p, x, pw, h, value, label, view, val_str):
|
||||||
|
gold = S.N_GOLD if self._night else S.GOLD
|
||||||
|
white = S.N_WHITE if self._night else S.WHITE
|
||||||
|
dim = S.N_WHITE if self._night else S.WHITE_DIM
|
||||||
|
|
||||||
|
mg = max(3, int(pw * 0.06))
|
||||||
|
lh = max(14, int(h * 0.15)) # label height
|
||||||
|
vh = max(14, int(h * 0.15)) # value height
|
||||||
|
boat_h = h - lh - vh
|
||||||
|
|
||||||
|
cx = x + pw / 2.0
|
||||||
|
cy = lh + boat_h / 2.0
|
||||||
|
r = min(pw * 0.42, boat_h * 0.44)
|
||||||
|
|
||||||
|
# Panel bg
|
||||||
|
p.setBrush(QBrush(S.BEZEL_DARK))
|
||||||
|
p.setPen(QPen(S.DIVIDER, 1))
|
||||||
|
p.drawRoundedRect(QRectF(x + mg, mg, pw - mg * 2, h - mg * 2),
|
||||||
|
max(3, mg * 0.6), max(3, mg * 0.6))
|
||||||
|
|
||||||
|
# Label
|
||||||
|
lfs = max(7, int(h * 0.085))
|
||||||
|
p.setFont(QFont('Arial', lfs, QFont.Bold))
|
||||||
|
p.setPen(QPen(gold))
|
||||||
|
p.drawText(QRectF(x, mg, pw, lh), Qt.AlignCenter, label)
|
||||||
|
|
||||||
|
# Waterline (fixed)
|
||||||
|
p.setPen(QPen(dim.darker(180) if not self._night else dim.darker(120),
|
||||||
|
max(1, int(r * 0.03)), Qt.DashLine))
|
||||||
|
p.drawLine(QPointF(x + mg * 2, cy), QPointF(x + pw - mg * 2, cy))
|
||||||
|
|
||||||
|
# Rotated boat silhouette
|
||||||
|
p.save()
|
||||||
|
p.translate(cx, cy)
|
||||||
|
|
||||||
|
vt = self._vessel_type
|
||||||
|
if view == 'side':
|
||||||
|
p.rotate(-value)
|
||||||
|
if vt == 'cargo': self._boat_side_cargo(p, r, gold)
|
||||||
|
else: self._boat_side(p, r, gold)
|
||||||
|
elif view == 'front':
|
||||||
|
p.rotate(value)
|
||||||
|
if vt == 'cargo': self._boat_front_cargo(p, r, gold)
|
||||||
|
else: self._boat_front(p, r, gold)
|
||||||
|
elif view == 'top':
|
||||||
|
vis = max(-45.0, min(45.0, value * 0.75))
|
||||||
|
p.rotate(vis)
|
||||||
|
if vt == 'cargo': self._boat_top_cargo(p, r, gold)
|
||||||
|
else: self._boat_top(p, r, gold)
|
||||||
|
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
# Value
|
||||||
|
vfs = max(7, int(h * 0.10))
|
||||||
|
p.setFont(QFont('Courier New', vfs, QFont.Bold))
|
||||||
|
p.setPen(QPen(white))
|
||||||
|
p.drawText(QRectF(x, h - vh - mg, pw, vh), Qt.AlignCenter, val_str)
|
||||||
|
|
||||||
|
# ── Boat shapes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _boat_side(self, p, r, color):
|
||||||
|
"""Modern superyacht starboard profile — bow right, sleek low lines."""
|
||||||
|
lw = max(1.0, r * 0.025)
|
||||||
|
h_fill = color.darker(148)
|
||||||
|
h_pen = color.darker(185)
|
||||||
|
c_fill = color.darker(162)
|
||||||
|
win_col = QColor(28, 48, 95, 220)
|
||||||
|
|
||||||
|
# ── 1. HULL — sleek, low freeboard ────────────────────────
|
||||||
|
hull = QPainterPath()
|
||||||
|
hull.moveTo(-r*0.88, -r*0.10) # transom top
|
||||||
|
hull.lineTo(-r*0.92, r*0.14) # transom bottom
|
||||||
|
hull.quadTo(-r*0.20, r*0.30, r*0.28, r*0.22)
|
||||||
|
hull.quadTo( r*0.68, r*0.13, r*0.94, -r*0.03)
|
||||||
|
hull.lineTo( r*0.80, -r*0.28) # bow deck
|
||||||
|
hull.cubicTo(r*0.52, -r*0.22, r*0.04, -r*0.16, -r*0.30, -r*0.16)
|
||||||
|
hull.lineTo(-r*0.88, -r*0.10)
|
||||||
|
hull.closeSubpath()
|
||||||
|
p.setPen(QPen(h_pen, lw))
|
||||||
|
p.setBrush(QBrush(h_fill))
|
||||||
|
p.drawPath(hull)
|
||||||
|
|
||||||
|
# ── 2. RED BOOT STRIPE ─────────────────────────────────────
|
||||||
|
boot = QPainterPath()
|
||||||
|
boot.moveTo(-r*0.92, r*0.14)
|
||||||
|
boot.quadTo(-r*0.20, r*0.20, r*0.28, r*0.14)
|
||||||
|
boot.quadTo( r*0.68, r*0.06, r*0.94, -r*0.03)
|
||||||
|
boot.lineTo( r*0.90, r*0.02)
|
||||||
|
boot.quadTo( r*0.66, r*0.06, r*0.24, r*0.10)
|
||||||
|
boot.quadTo(-r*0.20, r*0.14, -r*0.92, r*0.08)
|
||||||
|
boot.closeSubpath()
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QBrush(QColor(172, 22, 22, 210)))
|
||||||
|
p.drawPath(boot)
|
||||||
|
|
||||||
|
# ── 3. MAIN SUPERSTRUCTURE — long, low, angular ───────────
|
||||||
|
sup = QPainterPath()
|
||||||
|
sup.moveTo(-r*0.76, -r*0.10)
|
||||||
|
sup.lineTo(-r*0.76, -r*0.42)
|
||||||
|
sup.lineTo(-r*0.70, -r*0.46)
|
||||||
|
sup.lineTo( r*0.36, -r*0.46)
|
||||||
|
sup.lineTo( r*0.44, -r*0.40)
|
||||||
|
sup.lineTo( r*0.44, -r*0.10)
|
||||||
|
sup.closeSubpath()
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.85))
|
||||||
|
p.setBrush(QBrush(c_fill))
|
||||||
|
p.drawPath(sup)
|
||||||
|
|
||||||
|
# ── 4. LONG WINDOW STRIP ───────────────────────────────────
|
||||||
|
p.setPen(QPen(color.darker(122), max(1, r*0.018)))
|
||||||
|
p.setBrush(QBrush(win_col.lighter(140)))
|
||||||
|
p.drawRoundedRect(QRectF(-r*0.72, -r*0.43, r*1.06, r*0.22),
|
||||||
|
r*0.016, r*0.016)
|
||||||
|
|
||||||
|
# ── 5. BRIDGE DECK ─────────────────────────────────────────
|
||||||
|
br = QPainterPath()
|
||||||
|
br.moveTo(-r*0.38, -r*0.46)
|
||||||
|
br.lineTo(-r*0.38, -r*0.68)
|
||||||
|
br.lineTo(-r*0.32, -r*0.72)
|
||||||
|
br.lineTo( r*0.26, -r*0.72)
|
||||||
|
br.lineTo( r*0.32, -r*0.66)
|
||||||
|
br.lineTo( r*0.32, -r*0.46)
|
||||||
|
br.closeSubpath()
|
||||||
|
p.setBrush(QBrush(c_fill.darker(112)))
|
||||||
|
p.drawPath(br)
|
||||||
|
p.setBrush(QBrush(win_col.lighter(160)))
|
||||||
|
p.drawRoundedRect(QRectF(-r*0.32, -r*0.69, r*0.54, r*0.16),
|
||||||
|
r*0.012, r*0.012)
|
||||||
|
|
||||||
|
# ── 6. MAST & RADAR ────────────────────────────────────────
|
||||||
|
p.setPen(QPen(color.darker(128), max(1.5, r*0.028)))
|
||||||
|
p.drawLine(QPointF(r*0.00, -r*0.72), QPointF(r*0.00, -r*0.95))
|
||||||
|
p.setPen(QPen(h_pen, max(1, r*0.015)))
|
||||||
|
p.setBrush(QBrush(color.darker(160)))
|
||||||
|
p.drawEllipse(QPointF(r*0.00, -r*0.93), r*0.048, r*0.034)
|
||||||
|
|
||||||
|
# ── 7. SWIM PLATFORM ───────────────────────────────────────
|
||||||
|
plat = QPainterPath()
|
||||||
|
plat.moveTo(-r*0.88, r*0.14)
|
||||||
|
plat.lineTo(-r*0.98, r*0.14)
|
||||||
|
plat.lineTo(-r*0.98, r*0.20)
|
||||||
|
plat.lineTo(-r*0.88, r*0.20)
|
||||||
|
plat.closeSubpath()
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.8))
|
||||||
|
p.setBrush(QBrush(h_fill.darker(110)))
|
||||||
|
p.drawPath(plat)
|
||||||
|
|
||||||
|
def _boat_front(self, p, r, color):
|
||||||
|
"""Modern superyacht bow-on — deep-V hull, wide flare, low superstructure."""
|
||||||
|
lw = max(1.0, r * 0.025)
|
||||||
|
h_fill = color.darker(148)
|
||||||
|
h_pen = color.darker(185)
|
||||||
|
c_fill = color.darker(162)
|
||||||
|
win_col = QColor(28, 48, 95, 220)
|
||||||
|
|
||||||
|
# ── HULL below waterline — sharp V ─────────────────────────
|
||||||
|
hull_v = QPainterPath()
|
||||||
|
hull_v.moveTo( 0, r*0.50)
|
||||||
|
hull_v.lineTo(-r*0.52, r*0.06)
|
||||||
|
hull_v.lineTo( r*0.52, r*0.06)
|
||||||
|
hull_v.closeSubpath()
|
||||||
|
p.setPen(QPen(h_pen, lw))
|
||||||
|
p.setBrush(QBrush(h_fill.darker(118)))
|
||||||
|
p.drawPath(hull_v)
|
||||||
|
|
||||||
|
# ── HULL TOPSIDES — wide flare ─────────────────────────────
|
||||||
|
hull_t = QPainterPath()
|
||||||
|
hull_t.moveTo(-r*0.52, r*0.06)
|
||||||
|
hull_t.quadTo(-r*0.84, -r*0.02, -r*0.88, -r*0.16)
|
||||||
|
hull_t.lineTo(-r*0.62, -r*0.24)
|
||||||
|
hull_t.lineTo( 0, -r*0.32)
|
||||||
|
hull_t.lineTo( r*0.62, -r*0.24)
|
||||||
|
hull_t.lineTo( r*0.88, -r*0.16)
|
||||||
|
hull_t.quadTo( r*0.84, -r*0.02, r*0.52, r*0.06)
|
||||||
|
hull_t.closeSubpath()
|
||||||
|
p.setPen(QPen(h_pen, lw))
|
||||||
|
p.setBrush(QBrush(h_fill))
|
||||||
|
p.drawPath(hull_t)
|
||||||
|
|
||||||
|
# Boot stripe
|
||||||
|
p.setPen(QPen(QColor(172, 22, 22, 210), max(2.0, r*0.034)))
|
||||||
|
p.drawLine(QPointF(-r*0.52, r*0.06), QPointF(r*0.52, r*0.06))
|
||||||
|
|
||||||
|
# ── WINDSHIELD — wide, angled ──────────────────────────────
|
||||||
|
ws = QPainterPath()
|
||||||
|
ws.moveTo(-r*0.26, -r*0.32)
|
||||||
|
ws.lineTo(-r*0.20, -r*0.58)
|
||||||
|
ws.lineTo( 0, -r*0.64)
|
||||||
|
ws.lineTo( r*0.20, -r*0.58)
|
||||||
|
ws.lineTo( r*0.26, -r*0.32)
|
||||||
|
ws.closeSubpath()
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.85))
|
||||||
|
p.setBrush(QBrush(c_fill))
|
||||||
|
p.drawPath(ws)
|
||||||
|
|
||||||
|
# Glass panels (port + stbd)
|
||||||
|
for sign in (-1, 1):
|
||||||
|
pg = QPainterPath()
|
||||||
|
pg.moveTo(sign * r*0.22, -r*0.34)
|
||||||
|
pg.lineTo(sign * r*0.16, -r*0.54)
|
||||||
|
pg.lineTo(sign * r*0.02, -r*0.60)
|
||||||
|
pg.lineTo(sign * r*0.02, -r*0.34)
|
||||||
|
pg.closeSubpath()
|
||||||
|
p.setPen(QPen(color.darker(122), max(1, r*0.016)))
|
||||||
|
p.setBrush(QBrush(win_col.lighter(145)))
|
||||||
|
p.drawPath(pg)
|
||||||
|
|
||||||
|
# ── MAST ───────────────────────────────────────────────────
|
||||||
|
p.setPen(QPen(color.darker(128), max(1.5, r*0.030)))
|
||||||
|
p.drawLine(QPointF(0, -r*0.64), QPointF(0, -r*0.92))
|
||||||
|
# Radar dome
|
||||||
|
p.setPen(QPen(h_pen, max(1, r*0.015)))
|
||||||
|
p.setBrush(QBrush(color.darker(160)))
|
||||||
|
p.drawEllipse(QPointF(0, -r*0.90), r*0.054, r*0.038)
|
||||||
|
|
||||||
|
# ── NAV LIGHTS ─────────────────────────────────────────────
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QBrush(QColor(220, 40, 40, 240)))
|
||||||
|
p.drawEllipse(QPointF(-r*0.90, -r*0.14), r*0.036, r*0.036)
|
||||||
|
p.setBrush(QBrush(QColor(40, 205, 80, 240)))
|
||||||
|
p.drawEllipse(QPointF( r*0.90, -r*0.14), r*0.036, r*0.036)
|
||||||
|
|
||||||
|
# Keel stem
|
||||||
|
p.setPen(QPen(color.darker(120), max(1.0, r*0.030)))
|
||||||
|
p.drawLine(QPointF(0, r*0.50), QPointF(0, r*0.12))
|
||||||
|
|
||||||
|
# ── CARGO SHIP ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _boat_side_cargo(self, p, r, color):
|
||||||
|
"""Bulk carrier starboard profile — bow right, bridge + funnel at stern (left).
|
||||||
|
High freeboard, 4 prominent goalpost cranes, bulbous bow."""
|
||||||
|
lw = max(1.0, r * 0.022)
|
||||||
|
h_fill = color.darker(152)
|
||||||
|
h_pen = color.darker(190)
|
||||||
|
s_fill = color.darker(160)
|
||||||
|
crane_c = color.darker(122)
|
||||||
|
|
||||||
|
DK = -r * 0.14 # deck rail (above center = negative y)
|
||||||
|
WL = r * 0.24 # waterline
|
||||||
|
KL = r * 0.44 # keel
|
||||||
|
DT = DK - r*0.10 # deck top surface
|
||||||
|
|
||||||
|
# ── HULL — very elongated, high freeboard ──────────────────
|
||||||
|
hull = QPainterPath()
|
||||||
|
hull.moveTo(-r*0.93, DK)
|
||||||
|
hull.lineTo(-r*0.93, KL) # stern vertical
|
||||||
|
hull.lineTo( r*0.74, KL) # flat keel
|
||||||
|
hull.quadTo( r*0.94, KL, r*0.94, WL) # bow keel curve
|
||||||
|
hull.lineTo( r*0.94, DT - r*0.06) # bow stem
|
||||||
|
hull.quadTo( r*0.86, DT - r*0.10, r*0.64, DT - r*0.10)
|
||||||
|
hull.lineTo(-r*0.86, DT - r*0.10) # flat main deck
|
||||||
|
hull.lineTo(-r*0.93, DK)
|
||||||
|
hull.closeSubpath()
|
||||||
|
p.setPen(QPen(h_pen, lw))
|
||||||
|
p.setBrush(QBrush(h_fill))
|
||||||
|
p.drawPath(hull)
|
||||||
|
|
||||||
|
# Bulbous bow
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.70))
|
||||||
|
p.setBrush(QBrush(h_fill.darker(114)))
|
||||||
|
p.drawEllipse(QPointF(r*0.96, WL + r*0.04), r*0.036, r*0.066)
|
||||||
|
|
||||||
|
# Boot stripe — bold red antifouling
|
||||||
|
p.setPen(QPen(QColor(148, 12, 12, 245), max(2.5, r * 0.042)))
|
||||||
|
p.drawLine(QPointF(-r*0.93, WL), QPointF(r*0.93, WL))
|
||||||
|
|
||||||
|
# ── BRIDGE CASTLE at STERN — tall, prominent ───────────────
|
||||||
|
# Level 1 — accommodation block (widest)
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.84))
|
||||||
|
p.setBrush(QBrush(s_fill))
|
||||||
|
p.drawRect(QRectF(-r*0.93, DT - r*0.52, r*0.44, r*0.42))
|
||||||
|
# Level 2 — bridge deck
|
||||||
|
p.setBrush(QBrush(s_fill.darker(110)))
|
||||||
|
p.drawRect(QRectF(-r*0.91, DT - r*0.68, r*0.36, r*0.16))
|
||||||
|
# Level 3 — wheelhouse
|
||||||
|
p.setBrush(QBrush(s_fill.darker(122)))
|
||||||
|
p.drawRect(QRectF(-r*0.88, DT - r*0.82, r*0.26, r*0.14))
|
||||||
|
# Bridge windows row
|
||||||
|
p.setPen(QPen(color.darker(122), max(1, r*0.015)))
|
||||||
|
p.setBrush(QBrush(QColor(28, 48, 95, 230)))
|
||||||
|
for wx in (-r*0.84, -r*0.74, -r*0.64):
|
||||||
|
p.drawRect(QRectF(wx, DT - r*0.79, r*0.08, r*0.10))
|
||||||
|
|
||||||
|
# Funnel — tall, slim, forward of bridge
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.78))
|
||||||
|
p.setBrush(QBrush(h_fill.darker(124)))
|
||||||
|
p.drawRect(QRectF(-r*0.60, DT - r*0.80, r*0.12, r*0.70))
|
||||||
|
p.setBrush(QBrush(h_fill.darker(132)))
|
||||||
|
p.drawRect(QRectF(-r*0.62, DT - r*0.88, r*0.16, r*0.10)) # cap
|
||||||
|
|
||||||
|
# ── GOALPOST CRANES — 4 prominent H-frames ─────────────────
|
||||||
|
ct = DT - r * 0.54 # crossbeam height
|
||||||
|
hw = r * 0.058
|
||||||
|
|
||||||
|
for gx in (-r*0.15, r*0.18, r*0.50, r*0.80):
|
||||||
|
lp, rp = gx - hw, gx + hw
|
||||||
|
# Vertical legs
|
||||||
|
p.setPen(QPen(crane_c, max(2.0, r * 0.034)))
|
||||||
|
p.drawLine(QPointF(lp, DT), QPointF(lp, ct))
|
||||||
|
p.drawLine(QPointF(rp, DT), QPointF(rp, ct))
|
||||||
|
# Crossbeam
|
||||||
|
p.setPen(QPen(crane_c, max(1.5, r * 0.025)))
|
||||||
|
p.drawLine(QPointF(lp - r*0.04, ct), QPointF(rp + r*0.04, ct))
|
||||||
|
# Derrick booms angled outward
|
||||||
|
p.setPen(QPen(crane_c, max(1.0, r * 0.018)))
|
||||||
|
p.drawLine(QPointF(lp, ct + r*0.10),
|
||||||
|
QPointF(lp - r*0.20, ct - r*0.18))
|
||||||
|
p.drawLine(QPointF(rp, ct + r*0.10),
|
||||||
|
QPointF(rp + r*0.20, ct - r*0.18))
|
||||||
|
|
||||||
|
# ── CARGO HATCH COAMINGS — 4 hatches ──────────────────────
|
||||||
|
p.setPen(QPen(h_pen, max(0.8, r * 0.015)))
|
||||||
|
p.setBrush(QBrush(h_fill.lighter(118)))
|
||||||
|
for hx in (-r*0.12, r*0.20, r*0.52, r*0.80):
|
||||||
|
p.drawRoundedRect(QRectF(hx - r*0.12, DT + r*0.01, r*0.22, r*0.10),
|
||||||
|
r*0.012, r*0.012)
|
||||||
|
|
||||||
|
# Nav lights
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QBrush(QColor(220, 40, 40, 232)))
|
||||||
|
p.drawEllipse(QPointF(-r*0.91, DT - r*0.08), r*0.028, r*0.028)
|
||||||
|
p.setBrush(QBrush(QColor(40, 205, 80, 232)))
|
||||||
|
p.drawEllipse(QPointF( r*0.90, DT - r*0.08), r*0.028, r*0.028)
|
||||||
|
|
||||||
|
def _boat_front_cargo(self, p, r, color):
|
||||||
|
"""Bulk carrier bow-on — wide boxy hull, full flare, tall stacked bridge."""
|
||||||
|
lw = max(1.0, r * 0.022)
|
||||||
|
h_fill = color.darker(152)
|
||||||
|
h_pen = color.darker(190)
|
||||||
|
s_fill = color.darker(160)
|
||||||
|
DK = -r * 0.12 # deck
|
||||||
|
WL = r * 0.10 # waterline
|
||||||
|
KL = r * 0.56 # keel
|
||||||
|
HW = r * 0.90 # hull half-width — bulk carriers are WIDE
|
||||||
|
|
||||||
|
# ── HULL below waterline — less V, more full-form ──────────
|
||||||
|
hull_v = QPainterPath()
|
||||||
|
hull_v.moveTo( 0, KL)
|
||||||
|
hull_v.lineTo(-r*0.36, WL + r*0.10)
|
||||||
|
hull_v.lineTo( r*0.36, WL + r*0.10)
|
||||||
|
hull_v.closeSubpath()
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QBrush(h_fill.darker(120)))
|
||||||
|
p.drawPath(hull_v)
|
||||||
|
|
||||||
|
# ── HULL TOPSIDES — wide, strong flare ─────────────────────
|
||||||
|
hull = QPainterPath()
|
||||||
|
hull.moveTo(-r*0.36, WL + r*0.10)
|
||||||
|
hull.quadTo(-r*0.70, WL + r*0.02, -HW, DK + r*0.08)
|
||||||
|
hull.lineTo(-HW, DK)
|
||||||
|
hull.lineTo( HW, DK)
|
||||||
|
hull.lineTo( HW, DK + r*0.08)
|
||||||
|
hull.quadTo( r*0.70, WL + r*0.02, r*0.36, WL + r*0.10)
|
||||||
|
hull.closeSubpath()
|
||||||
|
p.setPen(QPen(h_pen, lw))
|
||||||
|
p.setBrush(QBrush(h_fill))
|
||||||
|
p.drawPath(hull)
|
||||||
|
|
||||||
|
# Boot stripe — bold red
|
||||||
|
p.setPen(QPen(QColor(148, 12, 12, 245), max(2.5, r * 0.042)))
|
||||||
|
p.drawLine(QPointF(-HW + r*0.06, WL), QPointF(HW - r*0.06, WL))
|
||||||
|
|
||||||
|
# ── STACKED SUPERSTRUCTURE — 3 levels ──────────────────────
|
||||||
|
for bw, bt, bh in [
|
||||||
|
(r*0.56, DK - r*0.30, r*0.30), # accommodation (widest)
|
||||||
|
(r*0.42, DK - r*0.52, r*0.22), # bridge deck
|
||||||
|
(r*0.30, DK - r*0.70, r*0.18), # wheelhouse
|
||||||
|
]:
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.82))
|
||||||
|
p.setBrush(QBrush(s_fill))
|
||||||
|
p.drawRect(QRectF(-bw, bt, bw * 2, bh))
|
||||||
|
|
||||||
|
# Bridge windows — prominent row
|
||||||
|
p.setPen(QPen(color.darker(122), max(1, r * 0.016)))
|
||||||
|
p.setBrush(QBrush(QColor(28, 48, 95, 232)))
|
||||||
|
win_y = DK - r*0.67
|
||||||
|
for wx in (-r*0.24, -r*0.12, 0, r*0.12, r*0.24):
|
||||||
|
p.drawRect(QRectF(wx - r*0.045, win_y, r*0.088, r*0.12))
|
||||||
|
|
||||||
|
# Funnel top (visible above wheelhouse)
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.7))
|
||||||
|
p.setBrush(QBrush(h_fill.darker(124)))
|
||||||
|
p.drawRect(QRectF(-r*0.10, DK - r*0.88, r*0.20, r*0.18))
|
||||||
|
|
||||||
|
# ── MAST + YARDARM + RADAR ─────────────────────────────────
|
||||||
|
p.setPen(QPen(color.darker(128), max(1.5, r * 0.026)))
|
||||||
|
p.drawLine(QPointF(0, DK - r*0.70), QPointF(0, DK - r*0.96))
|
||||||
|
p.setPen(QPen(color.darker(132), max(1.0, r * 0.018)))
|
||||||
|
p.drawLine(QPointF(-r*0.22, DK - r*0.88), QPointF(r*0.22, DK - r*0.88))
|
||||||
|
p.setPen(QPen(h_pen, max(1, r * 0.014)))
|
||||||
|
p.setBrush(QBrush(color.darker(164)))
|
||||||
|
p.drawEllipse(QPointF(0, DK - r*0.94), r*0.046, r*0.032)
|
||||||
|
|
||||||
|
# ── NAV LIGHTS ─────────────────────────────────────────────
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QBrush(QColor(220, 40, 40, 240)))
|
||||||
|
p.drawEllipse(QPointF(-HW + r*0.02, DK + r*0.02), r*0.036, r*0.036)
|
||||||
|
p.setBrush(QBrush(QColor(40, 205, 80, 240)))
|
||||||
|
p.drawEllipse(QPointF( HW - r*0.02, DK + r*0.02), r*0.036, r*0.036)
|
||||||
|
|
||||||
|
# Keel stem
|
||||||
|
p.setPen(QPen(color.darker(120), max(1.0, r * 0.026)))
|
||||||
|
p.drawLine(QPointF(0, KL), QPointF(0, WL + r*0.08))
|
||||||
|
|
||||||
|
def _boat_top_cargo(self, p, r, color):
|
||||||
|
"""Bulk carrier plan view — bow at top, WIDE oval (bulk carriers are wide!),
|
||||||
|
bridge castle at stern, 4 cargo holds, 2 goalpost crane pairs."""
|
||||||
|
lw = max(1.0, r * 0.022)
|
||||||
|
h_fill = color.darker(152)
|
||||||
|
h_pen = color.darker(190)
|
||||||
|
s_fill = color.darker(160)
|
||||||
|
HW = r * 0.46 # hull half-width — bulk carriers have ~3:1 L/B ratio
|
||||||
|
|
||||||
|
# ── HULL — wide oval with raked bow ────────────────────────
|
||||||
|
hull = QPainterPath()
|
||||||
|
hull.moveTo( 0, -r*0.94) # bow tip
|
||||||
|
hull.cubicTo(-r*0.20, -r*0.88, -HW, -r*0.70, -HW, -r*0.30)
|
||||||
|
hull.lineTo(-HW, r*0.50) # port flat side
|
||||||
|
hull.quadTo(-HW, r*0.76, 0, r*0.86) # stern port
|
||||||
|
hull.quadTo( HW, r*0.76, HW, r*0.50) # stern stbd
|
||||||
|
hull.lineTo( HW, -r*0.30) # stbd flat side
|
||||||
|
hull.cubicTo( HW, -r*0.70, r*0.20, -r*0.88, 0, -r*0.94)
|
||||||
|
hull.closeSubpath()
|
||||||
|
p.setPen(QPen(h_pen, lw))
|
||||||
|
p.setBrush(QBrush(h_fill))
|
||||||
|
p.drawPath(hull)
|
||||||
|
|
||||||
|
# ── BRIDGE CASTLE at STERN (bottom) ────────────────────────
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.82))
|
||||||
|
p.setBrush(QBrush(s_fill))
|
||||||
|
p.drawRoundedRect(QRectF(-r*0.32, r*0.52, r*0.64, r*0.26),
|
||||||
|
r*0.05, r*0.05)
|
||||||
|
# Funnel — circle on superstructure
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.70))
|
||||||
|
p.setBrush(QBrush(h_fill.darker(122)))
|
||||||
|
p.drawEllipse(QPointF(0, r*0.70), r*0.086, r*0.072)
|
||||||
|
|
||||||
|
# ── CARGO HOLDS — 4 wide hatches ───────────────────────────
|
||||||
|
p.setPen(QPen(h_pen, max(0.9, r * 0.016)))
|
||||||
|
p.setBrush(QBrush(h_fill.lighter(116)))
|
||||||
|
for hy in (-r*0.72, -r*0.36, r*0.00, r*0.36):
|
||||||
|
p.drawRoundedRect(QRectF(-r*0.38, hy, r*0.76, r*0.28),
|
||||||
|
r*0.04, r*0.04)
|
||||||
|
|
||||||
|
# ── GOALPOST CRANE PAIRS — 2 sets of double H-frames ───────
|
||||||
|
crane_c = color.darker(126)
|
||||||
|
for cy_c in (-r*0.56, r*0.14):
|
||||||
|
# Two athwartship beams (top and bottom chord of goalpost)
|
||||||
|
p.setPen(QPen(crane_c, max(2.5, r * 0.044)))
|
||||||
|
p.drawLine(QPointF(-r*0.38, cy_c - r*0.07),
|
||||||
|
QPointF( r*0.38, cy_c - r*0.07))
|
||||||
|
p.drawLine(QPointF(-r*0.38, cy_c + r*0.07),
|
||||||
|
QPointF( r*0.38, cy_c + r*0.07))
|
||||||
|
# Centerline spine
|
||||||
|
p.setPen(QPen(crane_c, max(1.2, r * 0.018)))
|
||||||
|
p.drawLine(QPointF(0, cy_c - r*0.10), QPointF(0, cy_c + r*0.10))
|
||||||
|
|
||||||
|
# ── CENTERLINE dashed ───────────────────────────────────────
|
||||||
|
p.setPen(QPen(color.darker(148), max(0.8, r * 0.016), Qt.DashLine))
|
||||||
|
p.drawLine(QPointF(0, -r*0.88), QPointF(0, r*0.82))
|
||||||
|
|
||||||
|
# ── BOW DIRECTION ARROW ─────────────────────────────────────
|
||||||
|
arrow = QPainterPath()
|
||||||
|
arrow.moveTo( 0, -r*0.94)
|
||||||
|
arrow.lineTo(-r*0.09, -r*0.78)
|
||||||
|
arrow.lineTo( r*0.09, -r*0.78)
|
||||||
|
arrow.closeSubpath()
|
||||||
|
p.setBrush(QBrush(color))
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.drawPath(arrow)
|
||||||
|
|
||||||
|
# ── NAV LIGHTS ─────────────────────────────────────────────
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QBrush(QColor(220, 40, 40, 240)))
|
||||||
|
p.drawEllipse(QPointF(-HW + r*0.04, -r*0.48), r*0.040, r*0.040)
|
||||||
|
p.setBrush(QBrush(QColor(40, 205, 80, 240)))
|
||||||
|
p.drawEllipse(QPointF( HW - r*0.04, -r*0.48), r*0.040, r*0.040)
|
||||||
|
|
||||||
|
def _boat_top(self, p, r, color):
|
||||||
|
"""Modern superyacht plan view — bow at top, sleek teardrop hull."""
|
||||||
|
lw = max(1.0, r * 0.025)
|
||||||
|
h_fill = color.darker(148)
|
||||||
|
h_pen = color.darker(185)
|
||||||
|
c_fill = color.darker(162)
|
||||||
|
dk_fill = color.darker(155)
|
||||||
|
|
||||||
|
# ── HULL — sharp bow, wide transom ─────────────────────────
|
||||||
|
hull = QPainterPath()
|
||||||
|
hull.moveTo( 0, -r*0.94)
|
||||||
|
hull.cubicTo(-r*0.20, -r*0.86, -r*0.38, -r*0.50, -r*0.38, -r*0.08)
|
||||||
|
hull.cubicTo(-r*0.38, r*0.22, -r*0.32, r*0.50, -r*0.22, r*0.64)
|
||||||
|
hull.lineTo(-r*0.22, r*0.76)
|
||||||
|
hull.lineTo( r*0.22, r*0.76)
|
||||||
|
hull.lineTo( r*0.22, r*0.64)
|
||||||
|
hull.cubicTo( r*0.32, r*0.50, r*0.38, r*0.22, r*0.38, -r*0.08)
|
||||||
|
hull.cubicTo( r*0.38, -r*0.50, r*0.20, -r*0.86, 0, -r*0.94)
|
||||||
|
hull.closeSubpath()
|
||||||
|
p.setPen(QPen(h_pen, lw))
|
||||||
|
p.setBrush(QBrush(h_fill))
|
||||||
|
p.drawPath(hull)
|
||||||
|
|
||||||
|
# ── LONG SUPERSTRUCTURE ────────────────────────────────────
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.85))
|
||||||
|
p.setBrush(QBrush(c_fill))
|
||||||
|
p.drawRoundedRect(QRectF(-r*0.22, -r*0.34, r*0.44, r*0.64),
|
||||||
|
r*0.06, r*0.06)
|
||||||
|
|
||||||
|
# ── BRIDGE (forward section of superstructure) ─────────────
|
||||||
|
p.setBrush(QBrush(c_fill.darker(112)))
|
||||||
|
p.drawRoundedRect(QRectF(-r*0.15, -r*0.30, r*0.30, r*0.22),
|
||||||
|
r*0.05, r*0.05)
|
||||||
|
|
||||||
|
# ── AFT DECK (open) ────────────────────────────────────────
|
||||||
|
p.setBrush(QBrush(dk_fill))
|
||||||
|
p.drawRoundedRect(QRectF(-r*0.18, r*0.36, r*0.36, r*0.26),
|
||||||
|
r*0.04, r*0.04)
|
||||||
|
|
||||||
|
# ── SWIM PLATFORM ──────────────────────────────────────────
|
||||||
|
p.setBrush(QBrush(h_fill.darker(110)))
|
||||||
|
p.drawRoundedRect(QRectF(-r*0.16, r*0.64, r*0.32, r*0.12),
|
||||||
|
r*0.03, r*0.03)
|
||||||
|
|
||||||
|
# ── FOREDECK HATCH ─────────────────────────────────────────
|
||||||
|
p.setPen(QPen(h_pen, lw * 0.80))
|
||||||
|
p.setBrush(QBrush(h_fill.darker(108)))
|
||||||
|
p.drawRoundedRect(QRectF(-r*0.06, -r*0.60, r*0.12, r*0.10),
|
||||||
|
r*0.02, r*0.02)
|
||||||
|
p.drawRoundedRect(QRectF(-r*0.06, -r*0.46, r*0.12, r*0.10),
|
||||||
|
r*0.02, r*0.02)
|
||||||
|
|
||||||
|
# ── CENTERLINE ─────────────────────────────────────────────
|
||||||
|
p.setPen(QPen(color.darker(148), max(0.8, r*0.016), Qt.DashLine))
|
||||||
|
p.drawLine(QPointF(0, -r*0.88), QPointF(0, r*0.70))
|
||||||
|
|
||||||
|
# ── BOW DIRECTION ARROW ─────────────────────────────────────
|
||||||
|
arrow = QPainterPath()
|
||||||
|
arrow.moveTo( 0, -r*0.94)
|
||||||
|
arrow.lineTo(-r*0.08, -r*0.78)
|
||||||
|
arrow.lineTo( r*0.08, -r*0.78)
|
||||||
|
arrow.closeSubpath()
|
||||||
|
p.setBrush(QBrush(color))
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.drawPath(arrow)
|
||||||
|
|
||||||
|
# ── NAV LIGHTS ─────────────────────────────────────────────
|
||||||
|
p.setBrush(QBrush(QColor(220, 40, 40, 240)))
|
||||||
|
p.drawEllipse(QPointF(-r*0.36, -r*0.22), r*0.038, r*0.038)
|
||||||
|
p.setBrush(QBrush(QColor(40, 205, 80, 240)))
|
||||||
|
p.drawEllipse(QPointF( r*0.36, -r*0.22), r*0.038, r*0.038)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Main window — responsive full-screen layout.
|
||||||
|
Left: compass rose | Right: info panel
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from PyQt5.QtWidgets import (QMainWindow, QWidget, QHBoxLayout,
|
||||||
|
QDialog, QVBoxLayout, QListWidget,
|
||||||
|
QPushButton, QLabel, QApplication)
|
||||||
|
from PyQt5.QtCore import Qt, QTimer
|
||||||
|
from PyQt5.QtGui import QColor, QIcon
|
||||||
|
|
||||||
|
import config
|
||||||
|
from core.nmea_parser import NavData, parse
|
||||||
|
from core.serial_reader import SerialReader
|
||||||
|
from ui.compass_widget import CompassWidget
|
||||||
|
from ui.info_panel import InfoPanel
|
||||||
|
import ui.styles as S
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle('AR Compass — Marine')
|
||||||
|
_logo = Path(__file__).parent.parent / 'assets' / 'images' / 'ar_logo_full.png'
|
||||||
|
if _logo.exists():
|
||||||
|
self.setWindowIcon(QIcon(str(_logo)))
|
||||||
|
self.showFullScreen()
|
||||||
|
|
||||||
|
self._nav = NavData()
|
||||||
|
self._smooth = 0.0
|
||||||
|
self._readers: list[SerialReader] = []
|
||||||
|
self._night = False
|
||||||
|
self._hdg_mode = 'M' # 'M' magnetic, 'T' true
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._start_serial()
|
||||||
|
self._start_timer()
|
||||||
|
|
||||||
|
# ── UI ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
central = QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
central.setStyleSheet(f'background: {S.BG.name()};')
|
||||||
|
|
||||||
|
hl = QHBoxLayout(central)
|
||||||
|
hl.setContentsMargins(0, 0, 0, 0)
|
||||||
|
hl.setSpacing(0)
|
||||||
|
|
||||||
|
self.compass = CompassWidget()
|
||||||
|
self.panel = InfoPanel()
|
||||||
|
|
||||||
|
# Divider
|
||||||
|
div = QWidget()
|
||||||
|
div.setFixedWidth(2)
|
||||||
|
div.setStyleSheet(f'background: {S.DIVIDER.name()};')
|
||||||
|
|
||||||
|
# 60 / 40 split
|
||||||
|
hl.addWidget(self.compass, stretch=60)
|
||||||
|
hl.addWidget(div)
|
||||||
|
hl.addWidget(self.panel, stretch=40)
|
||||||
|
|
||||||
|
self.panel.night_toggled.connect(self._on_night)
|
||||||
|
self.panel.port_requested.connect(self._show_port_dialog)
|
||||||
|
self.panel.hdg_mode_changed.connect(self._on_hdg_mode)
|
||||||
|
|
||||||
|
# ── Timer — animation & UI refresh ──────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_timer(self):
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.timeout.connect(self._tick)
|
||||||
|
self._timer.start(config.UI_REFRESH_MS)
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
# Select heading source based on mode
|
||||||
|
if self._hdg_mode == 'T':
|
||||||
|
hdg = self._nav.hdg_true_calc
|
||||||
|
else:
|
||||||
|
hdg = self._nav.hdg_mag
|
||||||
|
|
||||||
|
if hdg is not None:
|
||||||
|
diff = ((hdg - self._smooth + 180) % 360) - 180
|
||||||
|
self._smooth = (self._smooth + diff * config.HEADING_SMOOTHING) % 360
|
||||||
|
self.compass.set_heading(self._smooth)
|
||||||
|
|
||||||
|
self.compass.set_attitude(
|
||||||
|
self._nav.pitch or 0.0,
|
||||||
|
self._nav.roll or 0.0,
|
||||||
|
)
|
||||||
|
self.panel.update_data(self._nav)
|
||||||
|
|
||||||
|
# ── Serial ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_serial(self):
|
||||||
|
for cfg in config.SERIAL_PORTS:
|
||||||
|
r = SerialReader(cfg['port'], cfg['baud'], self)
|
||||||
|
r.sentence.connect(self._on_sentence)
|
||||||
|
r.start()
|
||||||
|
self._readers.append(r)
|
||||||
|
|
||||||
|
def _on_sentence(self, line: str):
|
||||||
|
parse(line, self._nav)
|
||||||
|
|
||||||
|
# ── Night mode ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_hdg_mode(self, mode: str):
|
||||||
|
self._hdg_mode = mode
|
||||||
|
self.compass.set_hdg_mode(mode)
|
||||||
|
|
||||||
|
def _on_night(self, on: bool):
|
||||||
|
self._night = on
|
||||||
|
self.compass.set_night(on)
|
||||||
|
bg = S.N_BG.name() if on else S.BG.name()
|
||||||
|
self.centralWidget().setStyleSheet(f'background: {bg};')
|
||||||
|
|
||||||
|
# ── Port dialog ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _show_port_dialog(self):
|
||||||
|
dlg = PortDialog(self)
|
||||||
|
if dlg.exec_() == QDialog.Accepted:
|
||||||
|
port, baud = dlg.selected()
|
||||||
|
if port:
|
||||||
|
self._stop_serial()
|
||||||
|
config.SERIAL_PORTS = [{'port': port, 'baud': baud, 'name': 'Selected'}]
|
||||||
|
self._start_serial()
|
||||||
|
|
||||||
|
def _stop_serial(self):
|
||||||
|
for r in self._readers:
|
||||||
|
r.stop()
|
||||||
|
self._readers.clear()
|
||||||
|
|
||||||
|
# ── Keyboard shortcuts ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def keyPressEvent(self, event):
|
||||||
|
if event.key() == Qt.Key_Escape:
|
||||||
|
self.close()
|
||||||
|
elif event.key() == Qt.Key_F:
|
||||||
|
if self.isFullScreen():
|
||||||
|
self.showNormal()
|
||||||
|
else:
|
||||||
|
self.showFullScreen()
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
self._stop_serial()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
class PortDialog(QDialog):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle('Select Serial Port')
|
||||||
|
self.setMinimumSize(400, 300)
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QDialog {{ background: {S.PANEL_BG.name()}; color: {S.WHITE.name()}; }}
|
||||||
|
QListWidget {{ background: {S.BEZEL_DARK.name()}; color: {S.WHITE.name()};
|
||||||
|
border: 1px solid {S.GOLD_DIM.name()}; font-size: 14px; }}
|
||||||
|
QListWidget::item:selected {{ background: {S.GOLD_DIM.name()}; }}
|
||||||
|
QPushButton {{ background: {S.BEZEL_MID.name()}; color: {S.GOLD.name()};
|
||||||
|
border: 1px solid {S.GOLD.name()}; padding: 8px; border-radius: 4px; }}
|
||||||
|
""")
|
||||||
|
|
||||||
|
vl = QVBoxLayout(self)
|
||||||
|
vl.addWidget(QLabel('Available serial ports:'))
|
||||||
|
|
||||||
|
self._list = QListWidget()
|
||||||
|
for p in SerialReader.available_ports():
|
||||||
|
self._list.addItem(p)
|
||||||
|
vl.addWidget(self._list)
|
||||||
|
|
||||||
|
hl = QHBoxLayout()
|
||||||
|
ok = QPushButton('Connect 4800 baud')
|
||||||
|
ok38 = QPushButton('Connect 38400 baud')
|
||||||
|
cancel = QPushButton('Cancel')
|
||||||
|
ok.clicked.connect(lambda: self._accept(4800))
|
||||||
|
ok38.clicked.connect(lambda: self._accept(38400))
|
||||||
|
cancel.clicked.connect(self.reject)
|
||||||
|
hl.addWidget(ok)
|
||||||
|
hl.addWidget(ok38)
|
||||||
|
hl.addWidget(cancel)
|
||||||
|
vl.addLayout(hl)
|
||||||
|
|
||||||
|
self._port = None
|
||||||
|
self._baud = 4800
|
||||||
|
|
||||||
|
def _accept(self, baud):
|
||||||
|
items = self._list.selectedItems()
|
||||||
|
if items:
|
||||||
|
self._port = items[0].text()
|
||||||
|
self._baud = baud
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def selected(self):
|
||||||
|
return self._port, self._baud
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Nautical color theme — day and night modes.
|
||||||
|
"""
|
||||||
|
from PyQt5.QtGui import QColor, QFont
|
||||||
|
|
||||||
|
# ── Day palette ────────────────────────────────────────────────────────────
|
||||||
|
BG = QColor('#07090F')
|
||||||
|
PANEL_BG = QColor('#0C1020')
|
||||||
|
BEZEL_DARK = QColor('#10141E')
|
||||||
|
BEZEL_MID = QColor('#1A2030')
|
||||||
|
CARD_BG = QColor('#0A0D18')
|
||||||
|
|
||||||
|
GOLD = QColor('#C9A84C')
|
||||||
|
GOLD_BRIGHT = QColor('#F0C040')
|
||||||
|
GOLD_DIM = QColor('#6A5820')
|
||||||
|
|
||||||
|
WHITE = QColor('#E8E8E0')
|
||||||
|
WHITE_DIM = QColor('#7A8A9A')
|
||||||
|
CREAM = QColor('#F5F0E0')
|
||||||
|
|
||||||
|
RED = QColor('#CC2222') # north marker + lubber line
|
||||||
|
BLUE = QColor('#4A9EFF') # COG marker
|
||||||
|
GREEN = QColor('#44CC88') # GPS valid
|
||||||
|
ORANGE = QColor('#FF8844') # warning
|
||||||
|
|
||||||
|
DIVIDER = QColor('#1A2030')
|
||||||
|
|
||||||
|
# ── Night palette ──────────────────────────────────────────────────────────
|
||||||
|
N_BG = QColor('#060708')
|
||||||
|
N_GOLD = QColor('#6A4A10')
|
||||||
|
N_WHITE = QColor('#5A6A55')
|
||||||
|
N_RED = QColor('#661111')
|
||||||
|
N_BLUE = QColor('#1A3A66')
|
||||||
|
|
||||||
|
|
||||||
|
def is_night(night: bool):
|
||||||
|
return {
|
||||||
|
'bg': N_BG if night else BG,
|
||||||
|
'gold': N_GOLD if night else GOLD,
|
||||||
|
'bright': N_GOLD if night else GOLD_BRIGHT,
|
||||||
|
'white': N_WHITE if night else WHITE,
|
||||||
|
'dim': N_WHITE if night else WHITE_DIM,
|
||||||
|
'red': N_RED if night else RED,
|
||||||
|
'blue': N_BLUE if night else BLUE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── AR Electronics brand palette ────────────────────────────────────────────
|
||||||
|
# Additivos — no reemplazan la paleta operacional náutica.
|
||||||
|
BRAND_NAVY = QColor('#0D1B2A')
|
||||||
|
BRAND_BLUE_ELECTRIC = QColor('#2563EB')
|
||||||
|
BRAND_BLUE_NEON = QColor('#4A9FE8')
|
||||||
|
BRAND_TEXT = QColor('#E2E8F0')
|
||||||
|
BRAND_SILVER = QColor('#C8D2DC')
|
||||||
|
|
||||||
|
|
||||||
|
def mono(size: int, bold: bool = False) -> QFont:
|
||||||
|
f = QFont('Courier New', size)
|
||||||
|
f.setBold(bold)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def sans(size: int, bold: bool = False) -> QFont:
|
||||||
|
f = QFont('Arial', size)
|
||||||
|
f.setBold(bold)
|
||||||
|
return f
|
||||||