Backend: - Add /api/v1/ue/* endpoints (compute-points, cables, landing-points, satellites, status) returning flat JSON optimised for UE5 C++ parsing UE5 client (ue_client/): - PlanetDataManager: HTTP fetch + local mock JSON loader, spawns ComputePointActors - ComputePointActor / InteractiveObjectBase: hover/select state, material switching - GlobeInteractionComponent: drag-to-rotate via CesiumGeoreference origin shift, inertia, zoom - StereoRenderingManager: runtime SbS/TbB stereo toggle, IPD control (format TBD) - MotionCaptureInterface: protocol-agnostic gesture/rotate/zoom delegate interface (impl TBD) - PlanetPlayerController: unified mouse + motion-capture input routing - PlanetGameMode, Build.cs, Config, mock data Docs: - ue5_mvp_fused_plan.md updated to v3.0 for LED display context - ue_client_setup_guide.md: step-by-step editor setup guide - ue_todo.md: pending items blocked on vendor answers (stereo format + mocap protocol) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
352 lines
11 KiB
Python
352 lines
11 KiB
Python
"""UE Client Data API
|
|
|
|
Flat JSON endpoints designed for easy parsing in Unreal Engine C++/Blueprint.
|
|
Avoids GeoJSON nesting — every field is at the top level of each item.
|
|
"""
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func
|
|
|
|
from app.core.collected_data_fields import get_record_field
|
|
from app.core.satellite_tle import build_tle_lines_from_elements
|
|
from app.core.time import to_iso8601_utc
|
|
from app.db.session import get_db
|
|
from app.models.collected_data import CollectedData
|
|
|
|
router = APIRouter()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _current_stmt(source: str, limit: Optional[int] = None):
|
|
stmt = (
|
|
select(CollectedData)
|
|
.where(CollectedData.source == source)
|
|
.where(CollectedData.is_current.is_(True))
|
|
.order_by(CollectedData.id.desc())
|
|
)
|
|
if limit:
|
|
stmt = stmt.limit(limit)
|
|
return stmt
|
|
|
|
|
|
async def _fetch(db: AsyncSession, source: str, limit: Optional[int] = None) -> List[CollectedData]:
|
|
result = await db.execute(_current_stmt(source, limit))
|
|
return list(result.scalars().all())
|
|
|
|
|
|
def _safe_float(value: Any) -> Optional[float]:
|
|
try:
|
|
v = float(value)
|
|
return v if v == v else None # reject NaN
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/status")
|
|
async def ue_status(db: AsyncSession = Depends(get_db)):
|
|
"""Quick health-check + data counts for the UE client."""
|
|
from datetime import UTC, datetime
|
|
|
|
async def count_source(source: str) -> int:
|
|
result = await db.execute(
|
|
select(func.count())
|
|
.select_from(CollectedData)
|
|
.where(CollectedData.source == source)
|
|
.where(CollectedData.is_current.is_(True))
|
|
)
|
|
return result.scalar() or 0
|
|
|
|
return {
|
|
"ok": True,
|
|
"server_time": to_iso8601_utc(datetime.now(UTC)),
|
|
"compute_points_count": await count_source("top500"),
|
|
"cables_count": await count_source("telegeography_cables"),
|
|
"landing_points_count": await count_source("arcgis_landing"),
|
|
"satellites_count": await count_source("celestrak"),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Compute points (TOP500 supercomputers)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/compute-points")
|
|
async def ue_compute_points(
|
|
limit: int = Query(default=500, ge=1, le=2000),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Returns TOP500 supercomputer data as a flat JSON array.
|
|
|
|
Response shape:
|
|
{
|
|
"count": 500,
|
|
"items": [
|
|
{
|
|
"id": "top500_1",
|
|
"name": "Frontier",
|
|
"latitude": 36.01,
|
|
"longitude": -84.26,
|
|
"country": "United States",
|
|
"city": "Oak Ridge",
|
|
"rank": 1,
|
|
"rmax_tflops": 1194000.0,
|
|
"rpeak_tflops": 1679616.0,
|
|
"cores": 8730112,
|
|
"power_kw": 22703.0
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
records = await _fetch(db, "top500", limit)
|
|
items = []
|
|
for record in records:
|
|
meta = record.extra_data or {}
|
|
lat = _safe_float(get_record_field(record, "latitude"))
|
|
lon = _safe_float(get_record_field(record, "longitude"))
|
|
if lat is None or lon is None:
|
|
continue
|
|
items.append({
|
|
"id": f"top500_{record.id}",
|
|
"name": record.name or "Unknown",
|
|
"latitude": lat,
|
|
"longitude": lon,
|
|
"country": get_record_field(record, "country") or "",
|
|
"city": get_record_field(record, "city") or "",
|
|
"rank": meta.get("rank"),
|
|
"rmax_tflops": _safe_float(get_record_field(record, "rmax")),
|
|
"rpeak_tflops": _safe_float(get_record_field(record, "rpeak")),
|
|
"cores": meta.get("cores"),
|
|
"power_kw": _safe_float(get_record_field(record, "power")),
|
|
})
|
|
return {"count": len(items), "items": items}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cable landing points
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/landing-points")
|
|
async def ue_landing_points(db: AsyncSession = Depends(get_db)):
|
|
"""
|
|
Returns cable landing points as a flat JSON array.
|
|
|
|
Response shape:
|
|
{
|
|
"count": 1200,
|
|
"items": [
|
|
{
|
|
"id": "lp_42",
|
|
"name": "Shoreham",
|
|
"latitude": 50.83,
|
|
"longitude": -0.28,
|
|
"country": "United Kingdom",
|
|
"city": "Shoreham-by-Sea",
|
|
"cable_names": ["FLAG", "TAT-14"]
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
# Load landing points
|
|
lp_records = await _fetch(db, "arcgis_landing")
|
|
|
|
# Load relation + cable data for cable_names mapping
|
|
rel_result = await db.execute(
|
|
select(CollectedData)
|
|
.where(CollectedData.source == "arcgis_relation")
|
|
.where(CollectedData.is_current.is_(True))
|
|
)
|
|
rel_records = list(rel_result.scalars().all())
|
|
|
|
cable_result = await db.execute(
|
|
select(CollectedData)
|
|
.where(CollectedData.source == "telegeography_cables")
|
|
.where(CollectedData.is_current.is_(True))
|
|
)
|
|
cable_records = list(cable_result.scalars().all())
|
|
|
|
# Build mapping: city_id → list of cable names
|
|
city_to_cable_ids: Dict[int, List[int]] = {}
|
|
for r in rel_records:
|
|
meta = r.extra_data or {}
|
|
city_id = meta.get("city_id")
|
|
cable_id = meta.get("cable_id")
|
|
if city_id is not None and cable_id is not None:
|
|
city_to_cable_ids.setdefault(city_id, [])
|
|
if cable_id not in city_to_cable_ids[city_id]:
|
|
city_to_cable_ids[city_id].append(cable_id)
|
|
|
|
cable_id_to_name: Dict[int, str] = {}
|
|
for r in cable_records:
|
|
meta = r.extra_data or {}
|
|
cable_id = meta.get("cable_id")
|
|
if cable_id and r.name:
|
|
cable_id_to_name[cable_id] = r.name
|
|
|
|
items = []
|
|
for record in lp_records:
|
|
lat = _safe_float(get_record_field(record, "latitude"))
|
|
lon = _safe_float(get_record_field(record, "longitude"))
|
|
if lat is None or lon is None:
|
|
continue
|
|
meta = record.extra_data or {}
|
|
city_id = meta.get("city_id")
|
|
cable_names = []
|
|
if city_id in city_to_cable_ids:
|
|
cable_names = [
|
|
cable_id_to_name[cid]
|
|
for cid in city_to_cable_ids[city_id]
|
|
if cid in cable_id_to_name
|
|
]
|
|
items.append({
|
|
"id": f"lp_{record.id}",
|
|
"name": record.name or "Unknown",
|
|
"latitude": lat,
|
|
"longitude": lon,
|
|
"country": get_record_field(record, "country") or "",
|
|
"city": get_record_field(record, "city") or "",
|
|
"cable_names": cable_names,
|
|
})
|
|
return {"count": len(items), "items": items}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cables (route geometry)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/cables")
|
|
async def ue_cables(db: AsyncSession = Depends(get_db)):
|
|
"""
|
|
Returns cable route geometry.
|
|
|
|
Each segment is a flat array of [lon, lat] pairs.
|
|
|
|
Response shape:
|
|
{
|
|
"count": 100,
|
|
"items": [
|
|
{
|
|
"id": "cable_42",
|
|
"cable_id": "flag",
|
|
"name": "FLAG",
|
|
"status": "active",
|
|
"length_km": 28000,
|
|
"segments": [
|
|
[[lon, lat], [lon, lat], ...]
|
|
]
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
records = await _fetch(db, "telegeography_cables")
|
|
items = []
|
|
for record in records:
|
|
meta = record.extra_data or {}
|
|
route_coords = meta.get("route_coordinates", [])
|
|
segments: List[List[List[float]]] = []
|
|
|
|
if route_coords:
|
|
# Support both flat [lon,lat] array and array-of-arrays
|
|
if route_coords and isinstance(route_coords[0][0], list):
|
|
raw_lines = route_coords
|
|
else:
|
|
raw_lines = [route_coords]
|
|
|
|
for raw_line in raw_lines:
|
|
line = []
|
|
for pt in raw_line:
|
|
try:
|
|
line.append([float(pt[0]), float(pt[1])])
|
|
except (TypeError, ValueError, IndexError):
|
|
continue
|
|
if len(line) >= 2:
|
|
segments.append(line)
|
|
|
|
if not segments:
|
|
continue
|
|
|
|
items.append({
|
|
"id": f"cable_{record.id}",
|
|
"cable_id": record.source_id or record.name or "",
|
|
"name": record.name or "Unknown",
|
|
"status": meta.get("status", "active"),
|
|
"length_km": _safe_float(get_record_field(record, "value")),
|
|
"owners": meta.get("owners") or [],
|
|
"rfs": meta.get("rfs"),
|
|
"color": meta.get("color"),
|
|
"segments": segments,
|
|
})
|
|
return {"count": len(items), "items": items}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Satellites (TLE data)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/satellites")
|
|
async def ue_satellites(
|
|
limit: int = Query(default=200, ge=1, le=5000),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Returns satellite TLE data for orbit propagation in UE.
|
|
|
|
Response shape:
|
|
{
|
|
"count": 200,
|
|
"items": [
|
|
{
|
|
"id": "sat_42",
|
|
"norad_id": "25544",
|
|
"name": "ISS (ZARYA)",
|
|
"tle_line1": "1 25544U ...",
|
|
"tle_line2": "2 25544 ...",
|
|
"epoch": "2026-04-14T00:00:00Z",
|
|
"inclination": 51.6,
|
|
"mean_motion": 15.5
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
records = await _fetch(db, "celestrak", limit)
|
|
items = []
|
|
for record in records:
|
|
meta = record.extra_data or {}
|
|
norad_id = meta.get("norad_cat_id")
|
|
if not norad_id:
|
|
continue
|
|
tle1 = meta.get("tle_line1")
|
|
tle2 = meta.get("tle_line2")
|
|
if not tle1 or not tle2:
|
|
tle1, tle2 = build_tle_lines_from_elements(
|
|
norad_cat_id=norad_id,
|
|
epoch=meta.get("epoch"),
|
|
inclination=meta.get("inclination"),
|
|
raan=meta.get("raan"),
|
|
eccentricity=meta.get("eccentricity"),
|
|
arg_of_perigee=meta.get("arg_of_perigee"),
|
|
mean_anomaly=meta.get("mean_anomaly"),
|
|
mean_motion=meta.get("mean_motion"),
|
|
)
|
|
items.append({
|
|
"id": f"sat_{record.id}",
|
|
"norad_id": str(norad_id),
|
|
"name": record.name or "Unknown",
|
|
"tle_line1": tle1 or "",
|
|
"tle_line2": tle2 or "",
|
|
"epoch": meta.get("epoch") or "",
|
|
"inclination": _safe_float(meta.get("inclination")),
|
|
"raan": _safe_float(meta.get("raan")),
|
|
"eccentricity": _safe_float(meta.get("eccentricity")),
|
|
"mean_motion": _safe_float(meta.get("mean_motion")),
|
|
})
|
|
return {"count": len(items), "items": items}
|