"""Definición física del buque: silueta, cubiertas, mamparos.""" from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field, field_validator from vmssailor.core.coords import ShipCoord from vmssailor.core.enums import VesselSubtype, VesselType class Deck(BaseModel): """Una cubierta del buque. Cada cubierta se describe por su polígono en planta (lista de puntos `(x_pp, y_cl)`) y la altura `z_bl_top` que define el techo de esa cubierta sobre la línea base. La altura del piso es `z_bl_bottom`. El polígono se almacena como pares `(x_pp, y_cl)` en metros. El render a pantalla aplica una transformada lineal en el cliente UI; en core jamás se hacen estas transformadas. """ model_config = ConfigDict(extra="forbid") id: str = Field(..., min_length=1, max_length=64) name: str = Field(..., min_length=1, max_length=128) z_bl_bottom: float = Field(..., ge=-5.0, le=40.0, description="Cota piso [m sobre BL].") z_bl_top: float = Field(..., ge=-5.0, le=45.0, description="Cota techo [m sobre BL].") polygon_xy: list[tuple[float, float]] = Field( default_factory=list, description="Polígono en planta como pares (x_pp, y_cl) en metros.", ) @field_validator("polygon_xy") @classmethod def _polygon_must_be_closed_or_empty( cls, v: list[tuple[float, float]] ) -> list[tuple[float, float]]: if not v: return v if len(v) < 3: raise ValueError("Polígono de cubierta requiere al menos 3 vértices.") return v def height(self) -> float: """Altura libre de la cubierta en metros.""" return self.z_bl_top - self.z_bl_bottom class Bulkhead(BaseModel): """Mamparo principal del buque. Identifica separaciones críticas (sala de máquinas, mamparo de colisión, etc.) que el integrador puede colocar en la silueta. """ model_config = ConfigDict(extra="forbid") id: str = Field(..., min_length=1, max_length=64) name: str = Field(..., min_length=1, max_length=128) x_pp: float = Field(..., ge=0.0, le=200.0, description="Posición longitudinal [m].") description: str = Field(default="", max_length=512) class Vessel(BaseModel): """Definición del buque (clase de buque o instancia específica). Puede provenir de la biblioteca curada (`vmssailor/library/vessels/`) o importarse para un proyecto específico. """ model_config = ConfigDict(extra="forbid") id: str = Field(..., min_length=1, max_length=128, description="ID estable, snake_case.") name: str = Field(..., min_length=1, max_length=128) type: VesselType subtype: VesselSubtype length_overall_m: float = Field(..., gt=0.0, le=200.0, description="Eslora total [m].") beam_max_m: float = Field(..., gt=0.0, le=30.0, description="Manga máxima [m].") draft_m: float = Field(..., gt=0.0, le=10.0, description="Calado [m].") displacement_kg: float | None = Field( default=None, gt=0.0, description="Desplazamiento [kg]. Opcional." ) decks: list[Deck] = Field(default_factory=list) bulkheads: list[Bulkhead] = Field(default_factory=list) silhouette_svg: str | None = Field( default=None, description="SVG inline opcional de la silueta. Sprint posterior." ) description: str = Field(default="", max_length=2048) data_source: str = Field( default="user_input", description=( "Origen de los datos: 'seed_estimate' | 'manufacturer_datasheet' | 'measured' | " "'user_input'. Marcar 'seed_estimate' para entradas de biblioteca semilla " "que requieren validación contra fuente oficial." ), ) @field_validator("decks") @classmethod def _decks_unique_ids(cls, v: list[Deck]) -> list[Deck]: ids = [d.id for d in v] if len(ids) != len(set(ids)): raise ValueError("Deck IDs deben ser únicos dentro de un Vessel.") return v @field_validator("bulkheads") @classmethod def _bulkheads_unique_ids(cls, v: list[Bulkhead]) -> list[Bulkhead]: ids = [b.id for b in v] if len(ids) != len(set(ids)): raise ValueError("Bulkhead IDs deben ser únicos dentro de un Vessel.") return v def position_at_origin(self) -> ShipCoord: """Coordenada del origen del marco del buque (Pp, CL, BL) = (0, 0, 0).""" return ShipCoord(x_pp=0.0, y_cl=0.0, z_bl=0.0) def position_at_bow(self) -> ShipCoord: """Coordenada aproximada en la proa (línea de crujía, línea base).""" return ShipCoord(x_pp=self.length_overall_m, y_cl=0.0, z_bl=0.0)