feat: add UE5 LED display client — backend API, C++ source, stereo framework

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>
This commit is contained in:
linkong
2026-04-14 18:39:05 +08:00
parent d9adaf4134
commit 1e6f4b338b
28 changed files with 2781 additions and 949 deletions

View File

@@ -15,6 +15,7 @@ from app.api.v1 import (
bgp,
system_control,
tv,
ue_data,
)
api_router = APIRouter()
@@ -35,3 +36,4 @@ api_router.include_router(system_control.router, prefix="/system", tags=["system
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
api_router.include_router(ue_data.router, prefix="/ue", tags=["ue-client"])

View File

@@ -0,0 +1,351 @@
"""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}