Files
AR-Shipdesign/arshipdesign/core/project.py
T
alro65 0dbc2a4518 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)
2026-05-26 22:10:18 -04:00

274 lines
9.1 KiB
Python

"""
Clase Project — raíz del modelo de datos de AR-ShipDesign.
Gestiona la serialización/deserialización del formato .arsd
(ZIP con JSONs + meshes binarios).
El archivo .arsd es un ZIP que contiene:
manifest.json → metadatos del proyecto
ship.json → datos del buque
geometry/ → meshes binarios (.npz)
loadcases/ → condiciones de carga
"""
from __future__ import annotations
import json
import shutil
import zipfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
from arshipdesign.utils.logger import get_logger
logger = get_logger("core.project")
ARSD_EXTENSION = ".arsd"
MANIFEST_FILE = "manifest.json"
SHIP_FILE = "ship.json"
FORMAT_VERSION = "1.0"
@dataclass
class ProjectMetadata:
"""Metadatos del proyecto."""
name: str = "Proyecto sin título"
description: str = ""
author: str = ""
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
modified_at: str = field(default_factory=lambda: datetime.now().isoformat())
format_version: str = FORMAT_VERSION
arshipdesign_version: str = "0.1.0"
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"author": self.author,
"created_at": self.created_at,
"modified_at": self.modified_at,
"format_version": self.format_version,
"arshipdesign_version": self.arshipdesign_version,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ProjectMetadata":
return cls(
name=data.get("name", "Sin título"),
description=data.get("description", ""),
author=data.get("author", ""),
created_at=data.get("created_at", datetime.now().isoformat()),
modified_at=data.get("modified_at", datetime.now().isoformat()),
format_version=data.get("format_version", FORMAT_VERSION),
arshipdesign_version=data.get("arshipdesign_version", "0.1.0"),
)
class Project:
"""
Raíz del modelo de datos de AR-ShipDesign.
Gestiona el ciclo de vida del proyecto: nuevo, abrir, guardar, guardar como.
El formato .arsd es un archivo ZIP con estructura interna definida.
Parameters
----------
path : Path | None
Ruta al archivo .arsd. None para proyecto nuevo sin guardar.
Examples
--------
>>> project = Project.new("Mi velero")
>>> project.save(Path("D:/proyectos/mi_velero.arsd"))
>>> project2 = Project.load(Path("D:/proyectos/mi_velero.arsd"))
"""
def __init__(self, path: Path | None = None) -> None:
self.path: Path | None = path
self.metadata: ProjectMetadata = ProjectMetadata()
self.ship_data: dict[str, Any] = {}
self._is_modified: bool = False
# ──────────────────────────────────────────────
# CONSTRUCTORES
# ──────────────────────────────────────────────
@classmethod
def new(cls, name: str = "Proyecto sin título", author: str = "") -> "Project":
"""Crea un proyecto nuevo vacío."""
project = cls(path=None)
project.metadata = ProjectMetadata(name=name, author=author)
project.ship_data = _default_ship_data()
project._is_modified = True
logger.info("Nuevo proyecto creado: %s", name)
return project
@classmethod
def load(cls, path: Path) -> "Project":
"""
Carga un proyecto desde un archivo .arsd.
Parameters
----------
path : Path
Ruta al archivo .arsd.
Raises
------
FileNotFoundError
Si el archivo no existe.
ValueError
Si el archivo no es un .arsd válido.
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Archivo no encontrado: {path}")
if path.suffix.lower() != ARSD_EXTENSION:
raise ValueError(f"El archivo no tiene extensión {ARSD_EXTENSION}: {path}")
logger.info("Cargando proyecto: %s", path)
try:
with zipfile.ZipFile(path, "r") as zf:
names = zf.namelist()
if MANIFEST_FILE not in names:
raise ValueError("Archivo .arsd corrupto: falta manifest.json")
manifest_raw = zf.read(MANIFEST_FILE).decode("utf-8")
manifest = json.loads(manifest_raw)
metadata = ProjectMetadata.from_dict(manifest)
ship_data: dict[str, Any] = {}
if SHIP_FILE in names:
ship_raw = zf.read(SHIP_FILE).decode("utf-8")
ship_data = json.loads(ship_raw)
except zipfile.BadZipFile as e:
raise ValueError(f"Archivo .arsd inválido o corrupto: {e}") from e
project = cls(path=path)
project.metadata = metadata
project.ship_data = ship_data
project._is_modified = False
logger.info("Proyecto cargado: %s (v%s)", metadata.name, metadata.format_version)
return project
# ──────────────────────────────────────────────
# GUARDAR
# ──────────────────────────────────────────────
def save(self, path: Path | None = None) -> None:
"""
Guarda el proyecto en un archivo .arsd.
Parameters
----------
path : Path | None
Si se especifica, guarda en esa ruta (guardar como).
Si es None, guarda en self.path.
Raises
------
ValueError
Si no hay ruta definida.
"""
save_path = path or self.path
if save_path is None:
raise ValueError("No hay ruta definida. Use save(path) para guardar.")
save_path = Path(save_path)
if save_path.suffix.lower() != ARSD_EXTENSION:
save_path = save_path.with_suffix(ARSD_EXTENSION)
# Actualizar fecha de modificación
self.metadata.modified_at = datetime.now().isoformat()
# Guardar en un temporal primero (escritura atómica)
tmp_path = save_path.with_suffix(".arsd.tmp")
try:
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# manifest.json
zf.writestr(
MANIFEST_FILE,
json.dumps(self.metadata.to_dict(), indent=2, ensure_ascii=False),
)
# ship.json
zf.writestr(
SHIP_FILE,
json.dumps(self.ship_data, indent=2, ensure_ascii=False),
)
# Reemplazar el archivo final
shutil.move(str(tmp_path), str(save_path))
except Exception:
if tmp_path.exists():
tmp_path.unlink()
raise
self.path = save_path
self._is_modified = False
logger.info("Proyecto guardado: %s", save_path)
def save_as(self, path: Path) -> None:
"""Guarda el proyecto en una nueva ruta."""
self.save(path)
# ──────────────────────────────────────────────
# PROPIEDADES
# ──────────────────────────────────────────────
@property
def name(self) -> str:
return self.metadata.name
@name.setter
def name(self, value: str) -> None:
self.metadata.name = value
self._is_modified = True
@property
def is_modified(self) -> bool:
return self._is_modified
@property
def display_name(self) -> str:
"""Nombre para mostrar en la barra de título."""
base = self.metadata.name
if self.path:
base = self.path.stem
return f"{base}{'*' if self._is_modified else ''}"
def mark_modified(self) -> None:
"""Marca el proyecto como modificado."""
self._is_modified = True
def __repr__(self) -> str:
return f"Project(name={self.name!r}, path={self.path})"
# ──────────────────────────────────────────────
# HELPERS PRIVADOS
# ──────────────────────────────────────────────
def _default_ship_data() -> dict[str, Any]:
"""Estructura de datos inicial de un buque vacío."""
return {
"type": "motor", # motor | sailing_mono | sailing_cat | planing
"hull": {},
"appendages": [],
"superstructure": {},
"tanks": [],
"compartments": [],
"loadcases": [],
"rig": None,
"propulsion": {},
"systems": {},
"scantling": {},
}