""" 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": {}, }