0dbc2a4518
- 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)
274 lines
9.1 KiB
Python
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": {},
|
|
}
|