- Coaster editor: name input in top bar, saved/loaded with coaster data - CoasterListPanel: show coaster name prominently alongside creator username - ChallengesListPanel: drill-in detail view with center map, plan coaster, and accept challenge buttons; coaster count shown in list and detail - AllCoastersPanel: coaster count visible in challenge entries - Backend: add coaster_count to ChallengeMapSerializer and ChallengeDetailSerializer - Fix: ChallengeLayer and ChallengesListPanel were reading f.properties.id (always undefined) instead of f.id — GeoFeatureModelSerializer puts the pk at the GeoJSON Feature level, not in properties - Types: remove id from ChallengeMapProperties to reflect actual data shape Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
351 lines
12 KiB
Python
351 lines
12 KiB
Python
"""
|
||
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, 0.6),
|
||
"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": "<presigned URL or null>",
|
||
"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')
|
||
.prefetch_related('ratings')
|
||
.order_by('-updated_at')
|
||
)
|
||
return Response(CoasterSerializer(coasters, many=True, context={'request': request}).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, context={'request': request}).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)
|
||
|
||
|
||
class CoasterGlobalListView(APIView):
|
||
"""GET /api/v1/coaster/coasters/ — list all coasters across all challenges."""
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get(self, request):
|
||
from .models import Coaster
|
||
from .serializers import CoasterSerializer
|
||
coasters = (
|
||
Coaster.objects
|
||
.select_related('creator')
|
||
.prefetch_related('ratings')
|
||
.order_by('-updated_at')
|
||
)
|
||
return Response(
|
||
CoasterSerializer(coasters, many=True, context={'request': request}).data
|
||
)
|
||
|
||
|
||
class CoasterRateView(APIView):
|
||
"""POST /api/v1/coaster/{pk}/rate/ — submit or update a 1–5 star rating."""
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def post(self, request, pk):
|
||
from .models import Coaster, CoasterRating
|
||
from .serializers import CoasterSerializer
|
||
|
||
try:
|
||
coaster = Coaster.objects.prefetch_related('ratings').get(id=pk)
|
||
except Coaster.DoesNotExist:
|
||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||
|
||
try:
|
||
rating_value = int(request.data.get('rating', 0))
|
||
except (TypeError, ValueError):
|
||
return Response(
|
||
{'error': 'rating must be an integer 1–5'},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
|
||
if not (1 <= rating_value <= 5):
|
||
return Response(
|
||
{'error': 'rating must be between 1 and 5'},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
|
||
CoasterRating.objects.update_or_create(
|
||
coaster=coaster,
|
||
user=request.user,
|
||
defaults={'rating': rating_value},
|
||
)
|
||
|
||
# Re-fetch to get updated rating aggregates
|
||
coaster.refresh_from_db()
|
||
coaster_fresh = Coaster.objects.prefetch_related('ratings').get(id=pk)
|
||
return Response(
|
||
CoasterSerializer(coaster_fresh, context={'request': request}).data
|
||
)
|