v0.1-sprint0: Esqueleto completo AR-ShipDesign
- Estructura completa de carpetas (236 módulos stub + implementados) - pyproject.toml, requirements, .gitignore, LICENSE (propietario) - core/project.py: serialización .arsd (ZIP con JSON) - core/units.py: conversiones SI <-> imperial completas - ui/main_window.py: layout DELFTship-style con todos los paneles - Árbol de proyecto (dock izquierda) - Tabs de módulos (centro) - Panel de propiedades (dock derecha) - Panel hidrostáticos en vivo (inferior, fijo) - ui/i18n: español e inglés - ui/themes: tema claro y oscuro - utils/logger.py, settings.py, validation.py - data/liquids.json: 15 líquidos navales - data/stability_criteria.json: IMO IS Code 2008, A.749(18), USCG - tests/test_startup.py: 12 tests, todos PASSED - Módulo scantling/ ISO 12215 (stubs Sprint 2.5) - Módulo fabrication/molds/ para moldes FRP (stubs Sprint 13B) - Módulo fabrication/ para CNC plasma/router/laser (stubs Sprint 13)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# arshipdesign/utils
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Runner asíncrono para cálculos pesados usando QThreadPool.
|
||||
|
||||
Evita congelar la UI durante cálculos largos (>200ms).
|
||||
Stub — Implementación en Sprint 2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Callable, Any
|
||||
|
||||
|
||||
class AsyncRunner:
|
||||
"""
|
||||
Stub. Implementación en Sprint 2.
|
||||
|
||||
Ejecutará tareas en QThreadPool y emitirá señales Qt con el resultado.
|
||||
"""
|
||||
|
||||
def run(self, func: Callable, *args: Any, callback: Callable | None = None) -> None:
|
||||
raise NotImplementedError("AsyncRunner — Implementación en Sprint 2")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Configuración de logging para AR-ShipDesign.
|
||||
|
||||
Logs rotativos en %APPDATA%/ARShipDesign/logs/
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_log_dir() -> Path:
|
||||
"""Retorna el directorio de logs en %APPDATA%."""
|
||||
appdata = os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")
|
||||
log_dir = Path(appdata) / "ARShipDesign" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
return log_dir
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO") -> logging.Logger:
|
||||
"""
|
||||
Configura el sistema de logging de la aplicación.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
level : str
|
||||
Nivel de logging: DEBUG, INFO, WARNING, ERROR
|
||||
|
||||
Returns
|
||||
-------
|
||||
logging.Logger
|
||||
Logger raíz de la aplicación.
|
||||
"""
|
||||
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
||||
|
||||
log_file = get_log_dir() / "arshipdesign.log"
|
||||
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s [%(levelname)-8s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# Handler de archivo rotativo (5 MB × 3 archivos)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.setLevel(numeric_level)
|
||||
|
||||
# Handler de consola
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(numeric_level)
|
||||
|
||||
root_logger = logging.getLogger("arshipdesign")
|
||||
root_logger.setLevel(numeric_level)
|
||||
root_logger.addHandler(file_handler)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
root_logger.info("AR-ShipDesign logging iniciado — nivel: %s", level)
|
||||
return root_logger
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Retorna un logger hijo del logger raíz de la app."""
|
||||
return logging.getLogger(f"arshipdesign.{name}")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Gestión de configuración de usuario con QSettings.
|
||||
|
||||
Persiste preferencias: idioma, tema, unidades, archivos recientes, etc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSettings
|
||||
|
||||
APP_NAME = "ARShipDesign"
|
||||
ORG_NAME = "AlvaroRodriguez"
|
||||
|
||||
# Claves de configuración
|
||||
KEY_LANGUAGE = "ui/language"
|
||||
KEY_THEME = "ui/theme"
|
||||
KEY_UNITS = "ui/units"
|
||||
KEY_RECENT_FILES = "project/recentFiles"
|
||||
KEY_MAX_RECENT = "project/maxRecentFiles"
|
||||
KEY_WINDOW_GEOMETRY = "ui/windowGeometry"
|
||||
KEY_WINDOW_STATE = "ui/windowState"
|
||||
KEY_HYDRO_DENSITY = "calc/waterDensity"
|
||||
KEY_LOG_LEVEL = "system/logLevel"
|
||||
|
||||
|
||||
def get_settings() -> QSettings:
|
||||
"""Retorna la instancia de QSettings de la aplicación."""
|
||||
return QSettings(ORG_NAME, APP_NAME)
|
||||
|
||||
|
||||
def get_language() -> str:
|
||||
s = get_settings()
|
||||
return s.value(KEY_LANGUAGE, "es")
|
||||
|
||||
|
||||
def set_language(lang: str) -> None:
|
||||
s = get_settings()
|
||||
s.setValue(KEY_LANGUAGE, lang)
|
||||
|
||||
|
||||
def get_theme() -> str:
|
||||
s = get_settings()
|
||||
return s.value(KEY_THEME, "dark")
|
||||
|
||||
|
||||
def set_theme(theme: str) -> None:
|
||||
s = get_settings()
|
||||
s.setValue(KEY_THEME, theme)
|
||||
|
||||
|
||||
def get_units() -> str:
|
||||
"""'si' o 'imperial'"""
|
||||
s = get_settings()
|
||||
return s.value(KEY_UNITS, "si")
|
||||
|
||||
|
||||
def set_units(units: str) -> None:
|
||||
s = get_settings()
|
||||
s.setValue(KEY_UNITS, units)
|
||||
|
||||
|
||||
def get_recent_files() -> list[str]:
|
||||
s = get_settings()
|
||||
val = s.value(KEY_RECENT_FILES, [])
|
||||
if isinstance(val, str):
|
||||
return [val]
|
||||
return list(val) if val else []
|
||||
|
||||
|
||||
def add_recent_file(path: str) -> None:
|
||||
s = get_settings()
|
||||
recent = get_recent_files()
|
||||
if path in recent:
|
||||
recent.remove(path)
|
||||
recent.insert(0, path)
|
||||
max_recent = int(s.value(KEY_MAX_RECENT, 10))
|
||||
recent = recent[:max_recent]
|
||||
s.setValue(KEY_RECENT_FILES, recent)
|
||||
|
||||
|
||||
def get_water_density() -> float:
|
||||
"""Densidad del agua de mar en kg/m³ (por defecto 1025)."""
|
||||
s = get_settings()
|
||||
return float(s.value(KEY_HYDRO_DENSITY, 1025.0))
|
||||
|
||||
|
||||
def get_log_level() -> str:
|
||||
s = get_settings()
|
||||
return s.value(KEY_LOG_LEVEL, "INFO")
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Utilidades de validación de datos de entrada.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def is_positive(value: float, name: str = "valor") -> float:
|
||||
"""Valida que un valor sea positivo. Lanza ValueError si no."""
|
||||
if value <= 0:
|
||||
raise ValueError(f"{name} debe ser positivo, se recibió {value}")
|
||||
return value
|
||||
|
||||
|
||||
def is_in_range(value: float, low: float, high: float, name: str = "valor") -> float:
|
||||
"""Valida que un valor esté en el rango [low, high]."""
|
||||
if not (low <= value <= high):
|
||||
raise ValueError(f"{name} debe estar entre {low} y {high}, se recibió {value}")
|
||||
return value
|
||||
Reference in New Issue
Block a user