"""Utility models and parsing helpers for CPACS content.
This module offers lightweight stand-ins for TiXI/TiGL objects. The goal is to
provide predictable behavior in test environments while preserving the shape of
the real APIs. Geometry calculations are intentionally simplified; the focus is
on producing deterministic, well-structured JSON for the MCP tools.
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from xml.etree import ElementTree as ET
[docs]
@dataclass
class BoundingBox:
"""Axis-aligned bounding box."""
xmin: float
xmax: float
ymin: float
ymax: float
zmin: float
zmax: float
[docs]
@classmethod
def from_index(cls, index: int) -> BoundingBox:
"""Create a simple bounding box derived from an index."""
base = float(index)
return cls(
xmin=base,
xmax=base + 1.0,
ymin=-base,
ymax=base + 0.5,
zmin=-0.25 * (base + 1.0),
zmax=0.25 * (base + 1.0),
)
[docs]
@classmethod
def combine(cls, boxes: Iterable[BoundingBox]) -> BoundingBox:
"""Combine multiple bounding boxes into a single envelope."""
boxes = list(boxes)
if not boxes:
return cls(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
return cls(
xmin=min(box.xmin for box in boxes),
xmax=max(box.xmax for box in boxes),
ymin=min(box.ymin for box in boxes),
ymax=max(box.ymax for box in boxes),
zmin=min(box.zmin for box in boxes),
zmax=max(box.zmax for box in boxes),
)
[docs]
@dataclass
class ComponentDefinition:
"""Description of a CPACS component."""
uid: str
name: str
index: int
type_name: str
symmetry: str | None
parameters: dict[str, float]
bounding_box: BoundingBox
[docs]
@dataclass
class CPACSConfiguration:
"""Parsed CPACS configuration used by the MCP tools."""
wings: list[ComponentDefinition]
fuselages: list[ComponentDefinition]
rotors: list[ComponentDefinition]
engines: list[ComponentDefinition]
[docs]
def all_components(self) -> list[ComponentDefinition]:
"""Return all components in a single list."""
return [*self.wings, *self.fuselages, *self.rotors, *self.engines]
[docs]
def bounding_box(self) -> BoundingBox:
"""Envelope covering all components."""
return BoundingBox.combine(
component.bounding_box for component in self.all_components()
)
[docs]
def find_component(self, uid: str) -> ComponentDefinition | None:
"""Locate a component by UID (exact match first, then case-insensitive)."""
for component in self.all_components():
if component.uid == uid:
return component
uid_lower = uid.lower()
for component in self.all_components():
if component.uid.lower() == uid_lower:
return component
return None
[docs]
@dataclass
class TixiDocument:
"""Lightweight TiXI document stub."""
xml_content: str
file_name: str | None = None
closed: bool = False
[docs]
def close(self) -> None:
"""Mark the document as closed."""
self.closed = True
[docs]
@dataclass
class TiglConfiguration:
"""Lightweight TiGL configuration stub."""
cpacs_configuration: CPACSConfiguration
closed: bool = False
[docs]
def close(self) -> None:
"""Mark the configuration as closed."""
self.closed = True
[docs]
def getWingCount(self) -> int: # noqa: N802 - mimic TiGL naming
"""Return the number of wings in the configuration."""
return len(self.cpacs_configuration.wings)
[docs]
def getFuselageCount(self) -> int: # noqa: N802 - mimic TiGL naming
"""Return the number of fuselages in the configuration."""
return len(self.cpacs_configuration.fuselages)
[docs]
def getRotorCount(self) -> int: # noqa: N802 - mimic TiGL naming
"""Return the number of rotors in the configuration."""
return len(self.cpacs_configuration.rotors)
[docs]
def getEngineCount(self) -> int: # noqa: N802 - mimic TiGL naming
"""Return the number of engines in the configuration."""
return len(self.cpacs_configuration.engines)
def _parse_components(root: ET.Element, tag: str) -> list[ComponentDefinition]:
"""Parse CPACS components of a given tag."""
components: list[ComponentDefinition] = []
for index, element in enumerate(root.findall(f".//{tag}"), start=1):
# CPACS commonly uses "uID" while fixtures may use lowercase "uid".
uid = element.get("uID") or element.get("uid") or f"{tag}_{index}"
name = element.get("name") or uid
symmetry = element.get("symmetry")
parameters: dict[str, float] = {}
for attr, raw in element.attrib.items():
if attr in {"uid", "uID", "name", "symmetry"}:
continue
try:
parameters[attr] = float(raw)
except ValueError:
continue
components.append(
ComponentDefinition(
uid=uid,
name=name,
index=index,
type_name=tag.capitalize(),
symmetry=symmetry,
parameters=parameters,
bounding_box=BoundingBox.from_index(index),
)
)
return components
[docs]
def parse_cpacs(xml_content: str) -> CPACSConfiguration:
"""Parse CPACS XML content into a configuration representation."""
root = ET.fromstring(xml_content)
wings = _parse_components(root, "wing")
fuselages = _parse_components(root, "fuselage")
rotors = _parse_components(root, "rotor")
engines = _parse_components(root, "engine")
return CPACSConfiguration(
wings=wings, fuselages=fuselages, rotors=rotors, engines=engines
)
[docs]
def build_handles(
xml_content: str, file_name: str | None
) -> tuple[TixiDocument, TiglConfiguration, CPACSConfiguration, dict[str, str | None]]:
"""Create TiXI/TiGL stand-ins from XML content."""
tixi_document = TixiDocument(xml_content=xml_content, file_name=file_name)
configuration = parse_cpacs(xml_content)
tigl_configuration = TiglConfiguration(cpacs_configuration=configuration)
metadata = extract_metadata(xml_content, file_name)
return tixi_document, tigl_configuration, configuration, metadata