Files
AR-Shipdesign/arshipdesign/core/project.py
T
alro65 3b0d5e9e50 Modulo 1: serializacion Hull / Project en formato .arsd (Task 11)
- hull.py: Hull.to_dict() serializa a dict JSON con formato hull_v1
  (arrays numpy -> listas Python); Hull.from_dict() deserializa con
  validacion de claves y forma de array.

- project.py: Project.hull (property lazy) deserializa el Hull desde
  ship_data; Project.set_hull() persiste el Hull y marca is_modified.

- main_window.py: _on_new_project guarda el Hull en el proyecto;
  _on_project_loaded restaura el Hull en todos los visores al abrir
  un archivo .arsd; _on_hull_changed_from_editor mantiene el proyecto
  sincronizado con ediciones en el editor de offsets.

- test_serialization.py: 26 tests (round-trip dict, round-trip ZIP,
  5 familias parametricas, escritura atomica, proyecto sin Hull).

Suite total: 112 tests -- 112 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:33:34 -04:00

313 lines
11 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
# ──────────────────────────────────────────────
# HULL
# ──────────────────────────────────────────────
@property
def hull(self):
"""Hull activo del proyecto, o None si no hay geometría guardada.
El Hull se deserializa bajo demanda desde ship_data["hull"].
La primera llamada realiza la conversión; las siguientes también
(el objeto no se cachea para mantener la coherencia con ediciones
posteriores de ship_data).
Returns
-------
Hull | None
"""
hull_data = self.ship_data.get("hull")
if not hull_data or hull_data.get("format") not in ("hull_v1",):
return None
try:
from arshipdesign.core.hull import Hull
return Hull.from_dict(hull_data)
except Exception as exc:
logger.warning("No se pudo deserializar el Hull: %s", exc)
return None
def set_hull(self, hull) -> None:
"""Serializa el Hull en ship_data y marca el proyecto como modificado.
Parameters
----------
hull : Hull
El casco a guardar. Se serializa llamando a ``hull.to_dict()``.
"""
self.ship_data["hull"] = hull.to_dict()
self._is_modified = True
logger.debug("Hull '%s' guardado en proyecto '%s'", hull.name, self.name)
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": {},
}