""" POST /api/v1/coaster/simulate/ Accepts a list of geographic coordinates, runs the physics-based rail generator, optionally builds a GLB model, and returns the rail point arrays plus a presigned S3 URL for the model. """ import logging import math import uuid from rest_framework.response import Response from rest_framework.views import APIView from rest_framework import status # numpy and physics deps are imported lazily inside the view so the server # can start (and serve all other endpoints) even before the container has been # rebuilt with the new requirements. logger = logging.getLogger(__name__) # ── Coordinate helpers ───────────────────────────────────────────────────────── _R_EARTH = 6_371_000.0 # metres def _to_enu(coords: list) -> tuple: """ Convert [[lon, lat, alt], …] to a local ENU numpy array (metres). Uses a flat-Earth approximation; accurate to < 0.01 % for areas < 10 km. Returns (points_m, origin) where origin = (lon0, lat0, alt0). """ lon0, lat0, alt0 = coords[0] cos_lat = math.cos(math.radians(lat0)) pts = [] for lon, lat, alt in coords: x = (lon - lon0) * math.radians(1) * cos_lat * _R_EARTH # East y = (lat - lat0) * math.radians(1) * _R_EARTH # North z = alt - alt0 # Up pts.append([x, y, z]) import numpy as np # lazy return np.array(pts), (lon0, lat0, alt0) def _from_enu(pts_m, origin: tuple) -> list: """Convert local ENU numpy array back to [[lon, lat, alt], …].""" lon0, lat0, alt0 = origin cos_lat = math.cos(math.radians(lat0)) result = [] for x, y, z in pts_m: lon = lon0 + x / (cos_lat * _R_EARTH) * math.degrees(1) lat = lat0 + y / _R_EARTH * math.degrees(1) alt = alt0 + z result.append([lon, lat, alt]) return result # ── S3 upload ────────────────────────────────────────────────────────────────── def _upload_glb(glb_bytes: bytes) -> str | None: """ Upload GLB bytes to S3 and return a presigned GET URL (1 h). Returns None in development (no S3 configured). """ try: from apps.utils.storage import _is_s3_storage, _get_client from django.conf import settings if not _is_s3_storage(): logger.info("S3 not configured — skipping GLB upload") return None key = f"coaster/models/{uuid.uuid4()}.glb" client = _get_client() client.put_object( Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key, Body=glb_bytes, ContentType="model/gltf-binary", ) return client.generate_presigned_url( "get_object", Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": key}, ExpiresIn=3600, ) except Exception: logger.exception("GLB upload failed") return None # ── Allowed simulation params ────────────────────────────────────────────────── _PARAM_SCHEMA = { "rail_spacing": (float, 0.5, 10.0, 1.5), "mass": (float, 1.0, 50000, 1000.0), "initial_velocity": (float, 0.0, 100.0, 1.0), "friction_coeff": (float, 0.0, 1.0, 0.02), "junction_window_length": (float, 0.5, 50.0, 5.0), "junction_threshold": (float, 50.0, 99.0, 88.0), "binormal_smooth_iterations": (int, 0, 50, 5), "downsample_factor": (int, 1, 100, 10), "internal_steps": (int, 500, 20000, 5000), } def _parse_strips(raw) -> list: """ Validate and sanitize acceleration strip list from request body. Each strip: { start_frac: float, end_frac: float, accel_ms2: float } """ if not isinstance(raw, list): return [] result = [] for item in raw: if not isinstance(item, dict): continue try: sf = float(item['start_frac']) ef = float(item['end_frac']) ac = float(item['accel_ms2']) except (KeyError, TypeError, ValueError): continue sf = max(0.0, min(1.0, sf)) ef = max(0.0, min(1.0, ef)) ac = max(-50.0, min(50.0, ac)) if sf >= ef: continue result.append({'start_frac': sf, 'end_frac': ef, 'accel_ms2': ac}) return result def _parse_params(raw: dict) -> dict: out = {} for name, (typ, lo, hi, default) in _PARAM_SCHEMA.items(): val = raw.get(name, default) try: val = typ(val) except (TypeError, ValueError): val = default out[name] = max(lo, min(hi, val)) return out # ── View ─────────────────────────────────────────────────────────────────────── class CoasterSimulateView(APIView): """ POST /api/v1/coaster/simulate/ Request body: { "path": [[lon, lat, alt_m], …], // ≥ 2 points "params": { … } // optional overrides } Response 200: { "rail_1": [[lon, lat, alt], …], "rail_2": [[lon, lat, alt], …], "origin": [lon0, lat0, alt0], "model_url": "", "diagnostics": { … } } """ def post(self, request): path = request.data.get("path") if not isinstance(path, list) or len(path) < 2: return Response( {"error": "invalid_path", "detail": "path must be a list of ≥ 2 [lon, lat, alt] coordinates."}, status=status.HTTP_400_BAD_REQUEST, ) # Validate each coordinate try: path = [[float(v) for v in pt] for pt in path] for pt in path: if len(pt) != 3: raise ValueError except (TypeError, ValueError): return Response( {"error": "invalid_path", "detail": "Each point must be [lon, lat, alt_m] (three numbers)."}, status=status.HTTP_400_BAD_REQUEST, ) params = _parse_params(request.data.get("params") or {}) strips = _parse_strips(request.data.get("acceleration_strips") or []) # Convert to local ENU pts_enu, origin = _to_enu(path) # Run physics try: from .physics import generate_rails rail_1, rail_2, diag, profile = generate_rails( pts_enu, **params, acceleration_strips=strips ) except Exception as exc: logger.exception("Physics simulation failed") return Response( {"error": "simulation_failed", "detail": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) # Build GLB (build123d + trimesh) — gracefully skip on failure model_url = None try: from .mesh import build_glb glb_bytes = build_glb(rail_1, rail_2) model_url = _upload_glb(glb_bytes) except Exception: logger.exception("GLB generation failed — returning coordinates only") return Response({ "rail_1": _from_enu(rail_1, origin), "rail_2": _from_enu(rail_2, origin), "origin": list(origin), "model_url": model_url, "diagnostics": diag, "profile": profile, }) # ── Coaster persistence ──────────────────────────────────────────────────────── from rest_framework.permissions import IsAuthenticated # noqa: E402 class CoasterListCreateView(APIView): """ GET /api/v1/coaster/challenges/{challenge_id}/coasters/ — list all coasters POST /api/v1/coaster/challenges/{challenge_id}/coasters/ — save (upsert) """ permission_classes = [IsAuthenticated] def get(self, request, challenge_id): from .models import Coaster from .serializers import CoasterSerializer coasters = ( Coaster.objects .filter(challenge_id=challenge_id) .select_related('creator') .order_by('-updated_at') ) return Response(CoasterSerializer(coasters, many=True).data) def post(self, request, challenge_id): from .models import Coaster from .serializers import CoasterSerializer from apps.challenges.models import Challenge try: challenge = Challenge.objects.get(id=challenge_id) except Challenge.DoesNotExist: return Response( {"error": "challenge_not_found"}, status=status.HTTP_404_NOT_FOUND, ) coaster, created = Coaster.objects.update_or_create( creator=request.user, challenge=challenge, defaults={ "name": request.data.get("name", ""), "anchors": request.data.get("anchors", []), "acceleration_strips": request.data.get("acceleration_strips", []), }, ) return Response( CoasterSerializer(coaster).data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, ) class CoasterDeleteView(APIView): """DELETE /api/v1/coaster/{pk}/""" permission_classes = [IsAuthenticated] def delete(self, request, pk): from .models import Coaster try: coaster = Coaster.objects.get(id=pk) except Coaster.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) if coaster.creator != request.user: return Response(status=status.HTTP_403_FORBIDDEN) coaster.delete() return Response(status=status.HTTP_204_NO_CONTENT)