commit d93412cd0d053ce43bcfbac47a6062d0aaebc6ba Author: Marius Unsel Date: Tue Apr 7 01:12:40 2026 +0200 Initial commit Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b16b35c --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# Django +SECRET_KEY=change-me-to-a-long-random-string +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Database (PostGIS) +POSTGRES_DB=splatmap +POSTGRES_USER=splatmap +POSTGRES_PASSWORD=splatmap +DATABASE_URL=postgis://splatmap:splatmap@db:5432/splatmap + +# Redis / Celery +REDIS_URL=redis://redis:6379/0 +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=django-db + +# Authentik OIDC +OIDC_RP_CLIENT_ID=splatmap-backend +OIDC_RP_CLIENT_SECRET= +OIDC_OP_BASE_URL=https://auth.yourdomain.com/application/o/splatmap + +# Wasabi S3 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_STORAGE_BUCKET_NAME=splatmap +AWS_S3_ENDPOINT_URL=https://s3.wasabisys.com +AWS_S3_REGION_NAME=us-east-1 + +# Firebase (FCM) +FIREBASE_CREDENTIALS_FILE=/app/secrets/firebase-credentials.json + +# RunPod +RUNPOD_API_KEY= +RUNPOD_ENDPOINT_ID= + +# Webhook secret — must match what is configured in RunPod endpoint settings +WEBHOOK_SECRET=change-me-to-a-random-secret + +# Public URL of this API (used in RunPod callback payload) +API_BASE_URL=http://localhost:8000 + +# Cloudflare CDN prefix in front of Wasabi bucket (leave empty in dev) +CDN_BASE_URL= + +# Sentry (production only) +SENTRY_DSN= + +# Frontend (Vite) — VITE_ prefix exposes these to the browser bundle +VITE_OIDC_AUTHORITY=http://localhost:9000/application/o/splatmap +VITE_OIDC_CLIENT_ID=splatmap-web +VITE_API_BASE_URL=/api/v1 +VITE_CESIUM_ION_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8d1a16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Environment +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.egg-info/ +*.egg +.eggs/ +dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage +coverage.xml + +# Django +staticfiles/ +media/ +backend/secrets/ + +# Node / Vite +node_modules/ +web/dist/ +web/.vite/ +*.tsbuildinfo + +# Docker +*.override.yml + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp +*.swo diff --git a/README.md b/README.md new file mode 100644 index 0000000..4082918 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# SplatMap + +A mobile + web app where users record videos of real-world locations, which are processed into 3D Gaussian Splats and surfaced on a map. At street-level zoom the map transitions into a live 3D splat rendering of that location. Users can create public challenges to send others to specific regions for recording. + +## Stack + +- **Mobile** — React Native + Vision Camera + Mapbox +- **Web map** — Cesium.js + gaussian-splats-3d +- **Backend** — Django + GeoDjango + PostGIS +- **Queue** — Celery + Redis +- **Splatting pipeline** — COLMAP + gsplat on RunPod +- **Storage** — Wasabi (S3-compatible) +- **Auth** — Authentik (OIDC) +- **Notifications** — Firebase Cloud Messaging + +## Prerequisites + +- Docker + Docker Compose +- Node.js 20+ (for the web frontend) +- A `.env` file (see below) + +## Setup + +### 1. Environment + +```bash +cp .env.example .env +``` + +Open `.env` and set at minimum: + +``` +SECRET_KEY= +POSTGRES_PASSWORD= +VITE_CESIUM_ION_TOKEN= +``` + +All other values can stay as defaults for local development. + +> **Cesium Ion token** — register a free account at https://cesium.com/ion/ and create a token +> with default asset access. Required even in development for imagery and terrain. + +### 2. Start the backend + +```bash +docker compose up --build +docker compose exec web python manage.py migrate +``` + +### 3. Start the web frontend + +The frontend is not containerised — run it locally alongside Docker. + +```bash +cd web +npm install +npm run dev +``` + +Frontend: http://localhost:5173 (proxies `/api` → Django at :8000) + +### 4. Create a superuser + +```bash +docker compose exec web python manage.py createsuperuser +``` + +Admin panel: http://localhost:8000/admin + +## Common commands + +```bash +# Start all services +docker compose up + +# Start in background +docker compose up -d + +# Rebuild after dependency changes +docker compose up --build + +# Run migrations +docker compose exec web python manage.py migrate + +# Make new migrations after model changes +docker compose exec web python manage.py makemigrations + +# Open a Django shell +docker compose exec web python manage.py shell + +# Open a psql shell +docker compose exec db psql -U splatmap splatmap + +# View logs for a specific service +docker compose logs -f web +docker compose logs -f celery + +# Stop all services +docker compose down + +# Stop and remove volumes (wipes the database) +docker compose down -v +``` + +## Project structure + +``` +rcnn/ +├── docker-compose.yml +├── .env.example +├── web/ ← Vite + React frontend +│ ├── package.json +│ ├── vite.config.ts +│ └── src/ +│ ├── cesium/ ← Cesium viewer + camera hooks +│ ├── splat/ ← Gaussian splat layer + renderer +│ ├── challenges/ ← Challenge layer + panel + creator +│ ├── api/ ← Typed API wrappers +│ ├── store/ ← Zustand state slices +│ ├── auth/ ← Authentik OIDC +│ └── ui/ ← Shared UI components +└── backend/ + ├── Dockerfile + ├── manage.py + ├── requirements/ + │ ├── base.txt + │ ├── development.txt + │ └── production.txt + ├── config/ + │ ├── settings/ + │ │ ├── base.py + │ │ ├── development.py + │ │ └── production.py + │ ├── urls.py + │ ├── api_urls.py + │ └── celery.py + └── apps/ + ├── users/ + ├── splats/ + ├── challenges/ + └── jobs/ +``` + +## API + +Base URL: `http://localhost:8000/api/v1/` + +All endpoints require a Bearer token from Authentik. In development you can test unauthenticated endpoints directly, or pass a token via: + +``` +Authorization: Bearer +``` + +## Running tests + +```bash +docker compose exec web pytest +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d881cf1 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim + +# GDAL and geo dependencies for GeoDjango +RUN apt-get update && apt-get install -y --no-install-recommends \ + gdal-bin \ + libgdal-dev \ + libgeos-dev \ + libproj-dev \ + binutils \ + && rm -rf /var/lib/apt/lists/* + +ENV GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so +ENV GEOS_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgeos_c.so + +WORKDIR /app + +ARG REQUIREMENTS=base +COPY requirements/ requirements/ +RUN pip install --no-cache-dir -r requirements/${REQUIREMENTS}.txt + +COPY . . + +RUN python manage.py collectstatic --noinput 2>/dev/null || true + +EXPOSE 8000 + +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"] diff --git a/backend/apps/__init__.py b/backend/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/challenges/__init__.py b/backend/apps/challenges/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/challenges/apps.py b/backend/apps/challenges/apps.py new file mode 100644 index 0000000..fd9beb4 --- /dev/null +++ b/backend/apps/challenges/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChallengesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.challenges" diff --git a/backend/apps/challenges/migrations/0001_initial.py b/backend/apps/challenges/migrations/0001_initial.py new file mode 100644 index 0000000..abf1ae6 --- /dev/null +++ b/backend/apps/challenges/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1.4 on 2026-04-06 03:08 + +import django.contrib.gis.db.models.fields +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Challenge', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default='')), + ('status', models.CharField(choices=[('active', 'Active'), ('closed', 'Closed')], db_index=True, default='active', max_length=20)), + ('region', django.contrib.gis.db.models.fields.PolygonField(geography=True, srid=4326)), + ('region_centroid', django.contrib.gis.db.models.fields.PointField(geography=True, srid=4326)), + ('max_submissions', models.PositiveIntegerField(blank=True, null=True)), + ('submission_count', models.PositiveIntegerField(default=0)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'challenges', + }, + ), + migrations.CreateModel( + name='ChallengeParticipant', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('joined_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'challenge_participants', + }, + ), + ] diff --git a/backend/apps/challenges/migrations/0002_initial.py b/backend/apps/challenges/migrations/0002_initial.py new file mode 100644 index 0000000..ed0c4f8 --- /dev/null +++ b/backend/apps/challenges/migrations/0002_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 5.1.4 on 2026-04-06 03:08 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('challenges', '0001_initial'), + ('splats', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='challenge', + name='creator', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_challenges', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='challengeparticipant', + name='challenge', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='challenges.challenge'), + ), + migrations.AddField( + model_name='challengeparticipant', + name='submitted_splat', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='challenge_participation', to='splats.splat'), + ), + migrations.AddField( + model_name='challengeparticipant', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='challenge_participations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddIndex( + model_name='challenge', + index=models.Index(fields=['status', 'created_at'], name='challenges_status_7602d2_idx'), + ), + migrations.AddIndex( + model_name='challengeparticipant', + index=models.Index(fields=['user', 'joined_at'], name='challenge_p_user_id_c70b4e_idx'), + ), + migrations.AlterUniqueTogether( + name='challengeparticipant', + unique_together={('challenge', 'user')}, + ), + ] diff --git a/backend/apps/challenges/migrations/__init__.py b/backend/apps/challenges/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/challenges/models.py b/backend/apps/challenges/models.py new file mode 100644 index 0000000..93e5f24 --- /dev/null +++ b/backend/apps/challenges/models.py @@ -0,0 +1,85 @@ +import uuid +from django.contrib.gis.db import models +from django.conf import settings + + +class Challenge(models.Model): + class Status(models.TextChoices): + ACTIVE = "active", "Active" + CLOSED = "closed", "Closed" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + creator = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="created_challenges" + ) + + title = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE, db_index=True) + + # The polygon users must be physically inside to submit a recording. + # Drawn by the creator on the Cesium map. + region = models.PolygonField(geography=True) + # Derived centroid — stored separately for cheap proximity queries and map pin placement. + # Set automatically from `region` on save; not user-supplied. + region_centroid = models.PointField(geography=True) + + # Optional cap on accepted submissions (null = unlimited) + max_submissions = models.PositiveIntegerField(null=True, blank=True) + # Denormalised count of published splats linked to this challenge. + # Incremented by a Celery task when a splat transitions to is_published=True. + # Avoids a COUNT(*) on every map tile request. + submission_count = models.PositiveIntegerField(default=0) + + expires_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + # Keep centroid in sync with region automatically + if self.region: + self.region_centroid = self.region.centroid + super().save(*args, **kwargs) + + def __str__(self): + return self.title + + class Meta: + db_table = "challenges" + indexes = [ + models.Index(fields=["status", "created_at"]), + ] + + +class ChallengeParticipant(models.Model): + """ + A user who has bookmarked / accepted a challenge. + Used to target FCM notifications (e.g. new submission, challenge expiring soon). + Participation is recorded automatically when a user submits a splat for a challenge, + or manually when they tap "I'll do this" in the app. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + challenge = models.ForeignKey(Challenge, on_delete=models.CASCADE, related_name="participants") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="challenge_participations" + ) + # The splat this user submitted for the challenge, if any + submitted_splat = models.OneToOneField( + "splats.Splat", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="challenge_participation", + ) + joined_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.user_id} → {self.challenge_id}" + + class Meta: + db_table = "challenge_participants" + unique_together = [("challenge", "user")] + indexes = [ + models.Index(fields=["user", "joined_at"]), + ] diff --git a/backend/apps/challenges/serializers.py b/backend/apps/challenges/serializers.py new file mode 100644 index 0000000..35b88d9 --- /dev/null +++ b/backend/apps/challenges/serializers.py @@ -0,0 +1,102 @@ +from rest_framework import serializers +from rest_framework_gis.fields import GeometryField +from rest_framework_gis.serializers import GeoFeatureModelSerializer + +from apps.utils.storage import preview_url +from .models import Challenge, ChallengeParticipant + + +class ChallengeCreateSerializer(serializers.ModelSerializer): + # Accepts GeoJSON Polygon from the client + region = GeometryField(precision=6) + + class Meta: + model = Challenge + fields = ["title", "description", "region", "max_submissions", "expires_at"] + + def validate_region(self, value): + if value.geom_type != "Polygon": + raise serializers.ValidationError("region must be a GeoJSON Polygon.") + return value + + +class ChallengeUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Challenge + fields = ["title", "description", "expires_at", "status"] + + +class ChallengeMapSerializer(GeoFeatureModelSerializer): + """ + GeoJSON FeatureCollection using region_centroid as geometry. + Used for the map pin list — does not include the full region polygon. + """ + + class Meta: + model = Challenge + geo_field = "region_centroid" + fields = [ + "id", "title", "status", + "submission_count", "max_submissions", + "expires_at", "created_at", + ] + + +class ChallengeSplatPreviewSerializer(serializers.Serializer): + id = serializers.UUIDField() + preview_url = serializers.SerializerMethodField() + created_at = serializers.DateTimeField() + + def get_preview_url(self, obj): + return preview_url(obj.preview_key) + + +class ChallengeDetailSerializer(serializers.ModelSerializer): + region = serializers.SerializerMethodField() + region_centroid = serializers.SerializerMethodField() + creator_username = serializers.CharField(source="creator.username", read_only=True) + participant_count = serializers.SerializerMethodField() + is_participating = serializers.SerializerMethodField() + preview_splats = serializers.SerializerMethodField() + + class Meta: + model = Challenge + fields = [ + "id", "title", "description", "status", + "creator_username", + "region", "region_centroid", + "max_submissions", "submission_count", + "participant_count", "is_participating", + "preview_splats", + "expires_at", "created_at", "updated_at", + ] + + def get_region(self, obj): + return obj.region.geojson if obj.region else None + + def get_region_centroid(self, obj): + return obj.region_centroid.geojson if obj.region_centroid else None + + def get_participant_count(self, obj): + return obj.participants.count() + + def get_is_participating(self, obj): + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return False + return obj.participants.filter(user=request.user).exists() + + def get_preview_splats(self, obj): + from apps.splats.models import Splat + qs = ( + Splat.objects.filter(challenge=obj, is_published=True) + .order_by("-created_at")[:5] + ) + return ChallengeSplatPreviewSerializer(qs, many=True).data + + +class ChallengeParticipantSerializer(serializers.ModelSerializer): + class Meta: + model = ChallengeParticipant + fields = ["id", "joined_at"] + read_only_fields = fields diff --git a/backend/apps/challenges/tasks.py b/backend/apps/challenges/tasks.py new file mode 100644 index 0000000..38f6d87 --- /dev/null +++ b/backend/apps/challenges/tasks.py @@ -0,0 +1,34 @@ +import logging +from celery import shared_task + +logger = logging.getLogger(__name__) + + +@shared_task(queue="default") +def notify_challenge_expiring(challenge_id: str): + """ + Send an FCM notification to all participants of a challenge that is + expiring soon. Scheduled by celery-beat. + """ + from apps.challenges.models import Challenge, ChallengeParticipant + from apps.utils.fcm import send_notification + + try: + challenge = Challenge.objects.get(pk=challenge_id, status=Challenge.Status.ACTIVE) + except Challenge.DoesNotExist: + return + + participants = ChallengeParticipant.objects.filter( + challenge=challenge, + submitted_splat__isnull=True, # only those who haven't submitted yet + ).select_related("user") + + for participant in participants: + send_notification( + participant.user.fcm_token, + title="Challenge expiring soon!", + body=f'"{challenge.title}" is closing soon. Don\'t miss your chance!', + data={"challenge_id": str(challenge.id), "type": "challenge_expiring"}, + ) + + logger.info("Sent expiry notifications for challenge %s to %d participants", challenge_id, participants.count()) diff --git a/backend/apps/challenges/urls.py b/backend/apps/challenges/urls.py new file mode 100644 index 0000000..790d125 --- /dev/null +++ b/backend/apps/challenges/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from .views import ( + ChallengeListCreateView, + ChallengeDetailView, + ChallengeParticipateView, + ChallengeSplatsView, +) + +urlpatterns = [ + path("", ChallengeListCreateView.as_view(), name="challenge-list-create"), + path("/", ChallengeDetailView.as_view(), name="challenge-detail"), + path("/participate/", ChallengeParticipateView.as_view(), name="challenge-participate"), + path("/splats/", ChallengeSplatsView.as_view(), name="challenge-splats"), +] diff --git a/backend/apps/challenges/views.py b/backend/apps/challenges/views.py new file mode 100644 index 0000000..640df06 --- /dev/null +++ b/backend/apps/challenges/views.py @@ -0,0 +1,170 @@ +from django.contrib.gis.geos import Point, Polygon +from django.contrib.gis.measure import D +from rest_framework import generics, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.common.permissions import IsCreator +from apps.utils.fcm import send_notification +from .models import Challenge, ChallengeParticipant +from .serializers import ( + ChallengeCreateSerializer, + ChallengeUpdateSerializer, + ChallengeDetailSerializer, + ChallengeMapSerializer, + ChallengeParticipantSerializer, +) + + +def _parse_bbox(bbox_str): + try: + min_lon, min_lat, max_lon, max_lat = [float(x) for x in bbox_str.split(",")] + poly = Polygon.from_bbox((min_lon, min_lat, max_lon, max_lat)) + poly.srid = 4326 + return poly, None + except Exception: + return None, "bbox must be four comma-separated floats: minLon,minLat,maxLon,maxLat" + + +class ChallengeListCreateView(APIView): + def get(self, request): + qs = Challenge.objects.all() + + status_filter = request.query_params.get("status", "active") + qs = qs.filter(status=status_filter) + + bbox_str = request.query_params.get("bbox") + if bbox_str: + bbox, error = _parse_bbox(bbox_str) + if error: + return Response({"error": "invalid_bbox", "detail": error}, status=status.HTTP_400_BAD_REQUEST) + qs = qs.filter(region_centroid__within=bbox) + + near_str = request.query_params.get("near") + if near_str: + try: + lat, lon, radius_m = [float(x) for x in near_str.split(",")] + except Exception: + return Response( + {"error": "invalid_near", "detail": "near must be lat,lon,radius_m"}, + status=status.HTTP_400_BAD_REQUEST, + ) + center = Point(lon, lat, srid=4326) + qs = qs.filter(region_centroid__distance_lte=(center, D(m=radius_m))) + + return Response(ChallengeMapSerializer(qs, many=True).data) + + def post(self, request): + serializer = ChallengeCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + challenge = serializer.save(creator=request.user) + + return Response( + ChallengeDetailSerializer(challenge, context={"request": request}).data, + status=status.HTTP_201_CREATED, + ) + + +class ChallengeDetailView(APIView): + def _get_challenge(self, pk): + try: + return Challenge.objects.select_related("creator").prefetch_related("participants").get(pk=pk) + except Challenge.DoesNotExist: + return None + + def get(self, request, pk): + challenge = self._get_challenge(pk) + if not challenge: + return Response(status=status.HTTP_404_NOT_FOUND) + return Response(ChallengeDetailSerializer(challenge, context={"request": request}).data) + + def patch(self, request, pk): + challenge = self._get_challenge(pk) + if not challenge: + return Response(status=status.HTTP_404_NOT_FOUND) + + permission = IsCreator() + if not permission.has_object_permission(request, self, challenge): + return Response(status=status.HTTP_403_FORBIDDEN) + + serializer = ChallengeUpdateSerializer(challenge, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(ChallengeDetailSerializer(challenge, context={"request": request}).data) + + def delete(self, request, pk): + challenge = self._get_challenge(pk) + if not challenge: + return Response(status=status.HTTP_404_NOT_FOUND) + + permission = IsCreator() + if not permission.has_object_permission(request, self, challenge): + return Response(status=status.HTTP_403_FORBIDDEN) + + challenge.status = Challenge.Status.CLOSED + challenge.save(update_fields=["status", "updated_at"]) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ChallengeParticipateView(APIView): + def post(self, request, pk): + try: + challenge = Challenge.objects.select_related("creator").get(pk=pk) + except Challenge.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if challenge.status != Challenge.Status.ACTIVE: + return Response( + {"error": "challenge_closed", "detail": "This challenge is no longer active."}, + status=status.HTTP_403_FORBIDDEN, + ) + + participant, created = ChallengeParticipant.objects.get_or_create( + challenge=challenge, + user=request.user, + ) + + if created: + # Notify the challenge creator + send_notification( + challenge.creator.fcm_token, + title="Someone accepted your challenge!", + body=f'A user is heading out for "{challenge.title}".', + data={"challenge_id": str(challenge.id), "type": "challenge_accepted"}, + ) + + response_status = status.HTTP_201_CREATED if created else status.HTTP_200_OK + return Response(ChallengeParticipantSerializer(participant).data, status=response_status) + + def delete(self, request, pk): + try: + participant = ChallengeParticipant.objects.get( + challenge_id=pk, + user=request.user, + ) + except ChallengeParticipant.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if participant.submitted_splat_id: + return Response( + {"error": "cannot_leave", "detail": "You cannot remove yourself after submitting a splat."}, + status=status.HTTP_403_FORBIDDEN, + ) + + participant.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ChallengeSplatsView(generics.ListAPIView): + from apps.splats.serializers import SplatMapSerializer + serializer_class = SplatMapSerializer + + def get_queryset(self): + from apps.splats.models import Splat + return Splat.objects.filter( + challenge_id=self.kwargs["pk"], + is_published=True, + ).order_by("-created_at") diff --git a/backend/apps/common/__init__.py b/backend/apps/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/common/permissions.py b/backend/apps/common/permissions.py new file mode 100644 index 0000000..e23eb47 --- /dev/null +++ b/backend/apps/common/permissions.py @@ -0,0 +1,35 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsOwner(BasePermission): + """Object-level: allow access only to the object's owner.""" + + def has_object_permission(self, request, view, obj): + return obj.owner == request.user + + +class IsCreator(BasePermission): + """Object-level: allow access only to the object's creator.""" + + def has_object_permission(self, request, view, obj): + return obj.creator == request.user + + +class IsOwnerOrReadOnly(BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + return obj.owner == request.user + + +class WebhookPermission(BasePermission): + """ + Validates the X-Webhook-Secret header against settings.WEBHOOK_SECRET. + Used on the RunPod callback endpoint. + """ + + def has_permission(self, request, view): + from django.conf import settings + + secret = request.headers.get("X-Webhook-Secret", "") + return bool(settings.WEBHOOK_SECRET) and secret == settings.WEBHOOK_SECRET diff --git a/backend/apps/jobs/__init__.py b/backend/apps/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/jobs/apps.py b/backend/apps/jobs/apps.py new file mode 100644 index 0000000..670f3ba --- /dev/null +++ b/backend/apps/jobs/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class JobsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.jobs" diff --git a/backend/apps/jobs/migrations/0001_initial.py b/backend/apps/jobs/migrations/0001_initial.py new file mode 100644 index 0000000..4019904 --- /dev/null +++ b/backend/apps/jobs/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.4 on 2026-04-06 03:08 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SplatJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('queued', 'Queued'), ('running', 'Running'), ('succeeded', 'Succeeded'), ('failed', 'Failed')], db_index=True, default='queued', max_length=20)), + ('current_step', models.CharField(blank=True, choices=[('extracting_frames', 'Extracting frames'), ('running_colmap', 'Running COLMAP'), ('training_gsplat', 'Training gsplat'), ('exporting', 'Exporting .ksplat'), ('quality_check', 'Quality check')], default='', max_length=30)), + ('progress', models.PositiveSmallIntegerField(default=0)), + ('runpod_job_id', models.CharField(blank=True, db_index=True, default='', max_length=255)), + ('celery_task_id', models.CharField(blank=True, default='', max_length=255)), + ('retry_count', models.PositiveSmallIntegerField(default=0)), + ('error_message', models.TextField(blank=True, default='')), + ('pipeline_logs', models.JSONField(blank=True, default=dict)), + ('colmap_points', models.PositiveIntegerField(blank=True, null=True)), + ('queued_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'db_table': 'splat_jobs', + }, + ), + ] diff --git a/backend/apps/jobs/migrations/0002_initial.py b/backend/apps/jobs/migrations/0002_initial.py new file mode 100644 index 0000000..9dbb9cc --- /dev/null +++ b/backend/apps/jobs/migrations/0002_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.4 on 2026-04-06 03:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('jobs', '0001_initial'), + ('splats', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='splatjob', + name='splat', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='job', to='splats.splat'), + ), + ] diff --git a/backend/apps/jobs/migrations/0003_initial.py b/backend/apps/jobs/migrations/0003_initial.py new file mode 100644 index 0000000..678ea16 --- /dev/null +++ b/backend/apps/jobs/migrations/0003_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.4 on 2026-04-06 03:08 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('jobs', '0002_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='splatjob', + name='submitted_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='splat_jobs', to=settings.AUTH_USER_MODEL), + ), + migrations.AddIndex( + model_name='splatjob', + index=models.Index(fields=['status', 'queued_at'], name='splat_jobs_status_73c52b_idx'), + ), + ] diff --git a/backend/apps/jobs/migrations/__init__.py b/backend/apps/jobs/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/jobs/models.py b/backend/apps/jobs/models.py new file mode 100644 index 0000000..5d1ed7b --- /dev/null +++ b/backend/apps/jobs/models.py @@ -0,0 +1,61 @@ +import uuid +from django.db import models +from django.conf import settings + + +class SplatJob(models.Model): + class Status(models.TextChoices): + QUEUED = "queued", "Queued" + RUNNING = "running", "Running" + SUCCEEDED = "succeeded", "Succeeded" + FAILED = "failed", "Failed" + + class Step(models.TextChoices): + EXTRACTING_FRAMES = "extracting_frames", "Extracting frames" + RUNNING_COLMAP = "running_colmap", "Running COLMAP" + TRAINING_GSPLAT = "training_gsplat", "Training gsplat" + EXPORTING = "exporting", "Exporting .ksplat" + QUALITY_CHECK = "quality_check", "Quality check" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + splat = models.OneToOneField("splats.Splat", on_delete=models.CASCADE, related_name="job") + submitted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="splat_jobs" + ) + + status = models.CharField(max_length=20, choices=Status.choices, default=Status.QUEUED, db_index=True) + current_step = models.CharField(max_length=30, choices=Step.choices, blank=True, default="") + + # 0–100 overall progress, updated by the RunPod webhook on each step transition + progress = models.PositiveSmallIntegerField(default=0) + + # RunPod serverless job ID — used to poll status and match incoming webhooks + runpod_job_id = models.CharField(max_length=255, blank=True, default="", db_index=True) + celery_task_id = models.CharField(max_length=255, blank=True, default="") + + # Number of times this job has been requeued after a transient failure + retry_count = models.PositiveSmallIntegerField(default=0) + + error_message = models.TextField(blank=True, default="") + + # Structured log output from each pipeline step, keyed by Step value. + # Populated by the RunPod webhook on each step completion. + # Example: {"extracting_frames": {"frames": 1350, "duration_s": 8.2}, ...} + pipeline_logs = models.JSONField(default=dict, blank=True) + + # COLMAP sparse reconstruction quality signal. + # Low point count (< ~500) usually means the splat will be poor quality. + colmap_points = models.PositiveIntegerField(null=True, blank=True) + + queued_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return f"Job {self.id} for splat {self.splat_id} [{self.status}]" + + class Meta: + db_table = "splat_jobs" + indexes = [ + models.Index(fields=["status", "queued_at"]), + ] diff --git a/backend/apps/jobs/serializers.py b/backend/apps/jobs/serializers.py new file mode 100644 index 0000000..afd5d27 --- /dev/null +++ b/backend/apps/jobs/serializers.py @@ -0,0 +1,41 @@ +from rest_framework import serializers +from .models import SplatJob + + +class SplatJobSerializer(serializers.ModelSerializer): + class Meta: + model = SplatJob + fields = [ + "id", "status", "current_step", "progress", + "retry_count", "error_message", + "colmap_points", + "queued_at", "started_at", "finished_at", + ] + read_only_fields = fields + + +class WebhookInputSerializer(serializers.Serializer): + """Validates the payload sent by RunPod to POST /jobs/webhook/.""" + + class OutputSerializer(serializers.Serializer): + splat_key = serializers.CharField(required=False, default="") + preview_key = serializers.CharField(required=False, default="") + splat_file_size = serializers.IntegerField(required=False, allow_null=True) + colmap_points = serializers.IntegerField(required=False, allow_null=True) + quality_score = serializers.FloatField(required=False, allow_null=True) + frame_count = serializers.IntegerField(required=False, allow_null=True) + # GeoJSON point [lon, lat] + location = serializers.ListField(child=serializers.FloatField(), required=False, allow_null=True) + altitude = serializers.FloatField(required=False, allow_null=True) + heading = serializers.FloatField(required=False, allow_null=True) + # GeoJSON polygon for coverage + coverage = serializers.JSONField(required=False, allow_null=True) + + STATUS_CHOICES = ["succeeded", "failed", "step_complete"] + + job_id = serializers.CharField() # RunPod job ID + status = serializers.ChoiceField(choices=STATUS_CHOICES) + step = serializers.CharField(required=False, default="") + progress = serializers.IntegerField(min_value=0, max_value=100, required=False, default=0) + output = OutputSerializer(required=False, default=dict) + error = serializers.CharField(required=False, default="") diff --git a/backend/apps/jobs/tasks.py b/backend/apps/jobs/tasks.py new file mode 100644 index 0000000..1d3e15f --- /dev/null +++ b/backend/apps/jobs/tasks.py @@ -0,0 +1,70 @@ +import logging + +import requests +from celery import shared_task +from django.conf import settings +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=60, queue="splat_jobs") +def dispatch_splat_job(self, splat_job_id: str): + """ + Submit a splatting job to the RunPod serverless endpoint. + Stores the RunPod job ID on the SplatJob record so incoming + webhooks can be matched back to it. + """ + from apps.jobs.models import SplatJob + from apps.splats.models import Splat + from apps.utils.storage import generate_presigned_get_url + + try: + job = SplatJob.objects.select_related("splat").get(id=splat_job_id) + except SplatJob.DoesNotExist: + logger.error("SplatJob %s not found", splat_job_id) + return + + splat = job.splat + + # Generate a time-limited download URL for RunPod to fetch the video + video_url = generate_presigned_get_url(splat.video_key, expires_in=7200) + if video_url is None: + # Development — no real storage, bail out gracefully + logger.info("Skipping RunPod dispatch in dev (no S3 storage): job %s", splat_job_id) + return + + webhook_url = f"{settings.API_BASE_URL}/api/v1/jobs/webhook/" + + payload = { + "input": { + "video_url": video_url, + "splat_id": str(splat.id), + "job_id": str(job.id), + "webhook_url": webhook_url, + "webhook_secret": settings.WEBHOOK_SECRET, + } + } + + try: + response = requests.post( + f"https://api.runpod.io/v2/{settings.RUNPOD_ENDPOINT_ID}/run", + json=payload, + headers={"Authorization": f"Bearer {settings.RUNPOD_API_KEY}"}, + timeout=15, + ) + response.raise_for_status() + except requests.RequestException as exc: + logger.exception("RunPod dispatch failed for job %s", splat_job_id) + raise self.retry(exc=exc) + + runpod_job_id = response.json()["id"] + + SplatJob.objects.filter(pk=job.pk).update( + runpod_job_id=runpod_job_id, + status=SplatJob.Status.RUNNING, + started_at=timezone.now(), + ) + Splat.objects.filter(pk=splat.pk).update(status=Splat.Status.PROCESSING) + + logger.info("Dispatched RunPod job %s for splat %s", runpod_job_id, splat.id) diff --git a/backend/apps/jobs/urls.py b/backend/apps/jobs/urls.py new file mode 100644 index 0000000..9844678 --- /dev/null +++ b/backend/apps/jobs/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import JobDetailView, JobWebhookView + +urlpatterns = [ + path("/", JobDetailView.as_view(), name="job-detail"), + path("webhook/", JobWebhookView.as_view(), name="job-webhook"), +] diff --git a/backend/apps/jobs/views.py b/backend/apps/jobs/views.py new file mode 100644 index 0000000..3f819d5 --- /dev/null +++ b/backend/apps/jobs/views.py @@ -0,0 +1,217 @@ +import logging + +from django.contrib.gis.geos import Point, GEOSGeometry +from django.utils import timezone +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.common.permissions import WebhookPermission +from .models import SplatJob +from .serializers import SplatJobSerializer, WebhookInputSerializer + +logger = logging.getLogger(__name__) + + +class JobDetailView(APIView): + def get(self, request, pk): + try: + job = SplatJob.objects.select_related("splat__owner").get(pk=pk) + except SplatJob.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if job.splat.owner != request.user: + return Response(status=status.HTTP_404_NOT_FOUND) + + return Response(SplatJobSerializer(job).data) + + +class JobWebhookView(APIView): + authentication_classes = [] + permission_classes = [WebhookPermission] + + def post(self, request): + serializer = WebhookInputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data + + try: + job = SplatJob.objects.select_related("splat__owner", "splat__challenge__creator").get( + runpod_job_id=payload["job_id"] + ) + except SplatJob.DoesNotExist: + logger.warning("Webhook received for unknown RunPod job ID: %s", payload["job_id"]) + return Response(status=status.HTTP_404_NOT_FOUND) + + webhook_status = payload["status"] + + if webhook_status == "step_complete": + _handle_step_complete(job, payload) + + elif webhook_status == "succeeded": + _handle_succeeded(job, payload) + + elif webhook_status == "failed": + _handle_failed(job, payload) + + return Response(status=status.HTTP_200_OK) + + +# --------------------------------------------------------------------------- +# Internal webhook handlers +# --------------------------------------------------------------------------- + +def _handle_step_complete(job, payload): + step = payload.get("step", "") + progress = payload.get("progress", job.progress) + + logs = dict(job.pipeline_logs) + logs[step] = payload.get("output", {}) + + SplatJob.objects.filter(pk=job.pk).update( + current_step=step, + progress=progress, + pipeline_logs=logs, + ) + + +def _handle_succeeded(job, payload): + from apps.splats.models import Splat + from apps.utils.fcm import send_notification + from django.conf import settings + + output = payload.get("output", {}) + splat = job.splat + + # Update spatial fields + splat_updates = { + "splat_key": output.get("splat_key", ""), + "preview_key": output.get("preview_key", ""), + "splat_file_size": output.get("splat_file_size"), + "quality_score": output.get("quality_score"), + "frame_count": output.get("frame_count"), + } + + location_coords = output.get("location") + if location_coords: + splat_updates["location"] = Point(location_coords[0], location_coords[1], srid=4326) + + splat_updates["altitude"] = output.get("altitude") + splat_updates["heading"] = output.get("heading") + + coverage_geojson = output.get("coverage") + if coverage_geojson: + try: + import json + splat_updates["coverage"] = GEOSGeometry(json.dumps(coverage_geojson), srid=4326) + except Exception: + logger.exception("Failed to parse coverage GeoJSON for splat %s", splat.id) + + # Quality gate + thresholds = settings.SPLAT_QUALITY_THRESHOLDS + colmap_points = output.get("colmap_points") or 0 + quality_score = output.get("quality_score") or 0.0 + frame_count = output.get("frame_count") or 0 + + passed = ( + colmap_points >= thresholds["min_colmap_points"] + and quality_score >= thresholds["min_quality_score"] + and frame_count >= thresholds["min_frame_count"] + ) + + splat_updates["status"] = Splat.Status.READY + splat_updates["is_published"] = passed + + Splat.objects.filter(pk=splat.pk).update(**splat_updates) + + SplatJob.objects.filter(pk=job.pk).update( + status=SplatJob.Status.SUCCEEDED, + progress=100, + current_step=SplatJob.Step.QUALITY_CHECK, + colmap_points=colmap_points, + pipeline_logs={**job.pipeline_logs, "quality_gate": {"passed": passed}}, + finished_at=timezone.now(), + ) + + # Notify splat owner + if passed: + send_notification( + splat.owner.fcm_token, + title="Your splat is ready!", + body="Your recording has been processed and is now visible on the map.", + data={"splat_id": str(splat.id), "type": "splat_ready"}, + ) + else: + send_notification( + splat.owner.fcm_token, + title="Splat processing complete", + body="Your recording was processed but did not meet quality thresholds.", + data={"splat_id": str(splat.id), "type": "splat_quality_failed"}, + ) + + # If this splat is linked to a challenge, update submission count and notify + if passed and splat.challenge_id: + _handle_challenge_submission(splat) + + +def _handle_failed(job, payload): + from apps.splats.models import Splat + from apps.utils.fcm import send_notification + + error_message = payload.get("error", "Unknown pipeline error") + + SplatJob.objects.filter(pk=job.pk).update( + status=SplatJob.Status.FAILED, + error_message=error_message, + finished_at=timezone.now(), + ) + Splat.objects.filter(pk=job.splat_id).update(status=Splat.Status.FAILED) + + send_notification( + job.splat.owner.fcm_token, + title="Recording failed", + body="There was a problem processing your recording. Please try again.", + data={"splat_id": str(job.splat_id), "type": "splat_failed"}, + ) + + logger.error("Splat job %s failed: %s", job.id, error_message) + + +def _handle_challenge_submission(splat): + """ + Increment challenge submission count and dispatch FCM notifications + to the challenge creator and all other participants. + """ + from apps.challenges.models import Challenge, ChallengeParticipant + from apps.utils.fcm import send_notification + + Challenge.objects.filter(pk=splat.challenge_id).update( + submission_count=Challenge.objects.filter(pk=splat.challenge_id).values("submission_count")[0]["submission_count"] + 1 + ) + + # Re-fetch to check max_submissions + challenge = Challenge.objects.get(pk=splat.challenge_id) + if challenge.max_submissions and challenge.submission_count >= challenge.max_submissions: + Challenge.objects.filter(pk=challenge.pk).update(status=Challenge.Status.CLOSED) + + # Notify creator + send_notification( + challenge.creator.fcm_token, + title="New submission to your challenge!", + body=f'Someone submitted a splat for "{challenge.title}".', + data={"challenge_id": str(challenge.id), "splat_id": str(splat.id), "type": "challenge_submission"}, + ) + + # Notify other participants (excluding the submitter) + participants = ( + ChallengeParticipant.objects.filter(challenge=challenge) + .exclude(user=splat.owner) + .select_related("user") + ) + for participant in participants: + send_notification( + participant.user.fcm_token, + title="New splat on a challenge you joined", + body=f'A new recording was submitted for "{challenge.title}".', + data={"challenge_id": str(challenge.id), "type": "challenge_new_splat"}, + ) diff --git a/backend/apps/splats/__init__.py b/backend/apps/splats/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/splats/apps.py b/backend/apps/splats/apps.py new file mode 100644 index 0000000..22fd2fe --- /dev/null +++ b/backend/apps/splats/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SplatsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.splats" diff --git a/backend/apps/splats/migrations/0001_initial.py b/backend/apps/splats/migrations/0001_initial.py new file mode 100644 index 0000000..3f95eda --- /dev/null +++ b/backend/apps/splats/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.4 on 2026-04-06 03:08 + +import django.contrib.gis.db.models.fields +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('challenges', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Splat', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('ready', 'Ready'), ('failed', 'Failed')], db_index=True, default='pending', max_length=20)), + ('is_published', models.BooleanField(db_index=True, default=False)), + ('location', django.contrib.gis.db.models.fields.PointField(blank=True, geography=True, null=True, srid=4326)), + ('coverage', django.contrib.gis.db.models.fields.PolygonField(blank=True, geography=True, null=True, srid=4326)), + ('heading', models.FloatField(blank=True, null=True)), + ('altitude', models.FloatField(blank=True, null=True)), + ('video_key', models.CharField(blank=True, default='', max_length=500)), + ('splat_key', models.CharField(blank=True, default='', max_length=500)), + ('preview_key', models.CharField(blank=True, default='', max_length=500)), + ('splat_file_size', models.PositiveBigIntegerField(blank=True, null=True)), + ('quality_score', models.FloatField(blank=True, null=True)), + ('frame_count', models.PositiveIntegerField(blank=True, null=True)), + ('capture_metadata', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('challenge', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='splats', to='challenges.challenge')), + ], + options={ + 'db_table': 'splats', + }, + ), + ] diff --git a/backend/apps/splats/migrations/0002_initial.py b/backend/apps/splats/migrations/0002_initial.py new file mode 100644 index 0000000..2e15256 --- /dev/null +++ b/backend/apps/splats/migrations/0002_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.4 on 2026-04-06 03:08 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('splats', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='splat', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='splats', to=settings.AUTH_USER_MODEL), + ), + migrations.AddIndex( + model_name='splat', + index=models.Index(fields=['owner', 'status'], name='splats_owner_i_939cfa_idx'), + ), + migrations.AddIndex( + model_name='splat', + index=models.Index(fields=['challenge', 'is_published'], name='splats_challen_c6ea3d_idx'), + ), + migrations.AddIndex( + model_name='splat', + index=models.Index(fields=['created_at'], name='splats_created_695e22_idx'), + ), + ] diff --git a/backend/apps/splats/migrations/__init__.py b/backend/apps/splats/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/splats/models.py b/backend/apps/splats/models.py new file mode 100644 index 0000000..3dee386 --- /dev/null +++ b/backend/apps/splats/models.py @@ -0,0 +1,83 @@ +import uuid +from django.contrib.gis.db import models +from django.conf import settings + + +class Splat(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" # created, awaiting video upload confirmation + PROCESSING = "processing", "Processing" # RunPod job running + READY = "ready", "Ready" # pipeline done, quality check passed + FAILED = "failed", "Failed" # pipeline error or quality check failed + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="splats" + ) + challenge = models.ForeignKey( + "challenges.Challenge", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="splats", + ) + + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING, db_index=True) + + # Visible on the map only after pipeline succeeds and quality gate passes + is_published = models.BooleanField(default=False, db_index=True) + + # --- Geo fields --- + # Anchor point (centroid) used for map pin and proximity queries + location = models.PointField(geography=True, null=True, blank=True) + # Footprint polygon of the splat's coverage on the ground + coverage = models.PolygonField(geography=True, null=True, blank=True) + # Compass bearing of the camera at recording start (0–360°), used to orient + # the splat when rendering it on the map + heading = models.FloatField(null=True, blank=True) + # Elevation above sea level in metres, for Cesium 3D positioning + altitude = models.FloatField(null=True, blank=True) + + # --- Wasabi storage keys --- + # Set at Splat creation so the presigned upload URL can be generated immediately. + # splat_key and preview_key are populated by the pipeline on completion. + video_key = models.CharField(max_length=500, blank=True, default="") + splat_key = models.CharField(max_length=500, blank=True, default="") + preview_key = models.CharField(max_length=500, blank=True, default="") + splat_file_size = models.PositiveBigIntegerField(null=True, blank=True) # bytes + + # Pipeline output quality signals + quality_score = models.FloatField(null=True, blank=True) # 0.0–1.0 + frame_count = models.PositiveIntegerField(null=True, blank=True) + + # Per-frame GPS/IMU data from Vision Camera, passed as-is to the pipeline. + # Expected shape: + # { + # "fps": 30, + # "duration_seconds": 45.2, + # "device_model": "iPhone 15 Pro", + # "frames": [ + # { + # "timestamp": 0.033, + # "lat": 52.520008, "lon": 13.404954, + # "altitude_m": 34.2, + # "heading_deg": 178.3, "pitch_deg": -12.5, "roll_deg": 2.1, + # "accuracy_m": 3.0 + # }, ... + # ] + # } + capture_metadata = models.JSONField(default=dict, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Splat {self.id} [{self.status}] by {self.owner_id}" + + class Meta: + db_table = "splats" + indexes = [ + models.Index(fields=["owner", "status"]), + models.Index(fields=["challenge", "is_published"]), + models.Index(fields=["created_at"]), + ] diff --git a/backend/apps/splats/serializers.py b/backend/apps/splats/serializers.py new file mode 100644 index 0000000..b9a8458 --- /dev/null +++ b/backend/apps/splats/serializers.py @@ -0,0 +1,109 @@ +from django.conf import settings +from django.utils import timezone +from rest_framework import serializers +from rest_framework_gis.serializers import GeoFeatureModelSerializer + +from apps.utils.storage import generate_presigned_put_url, preview_url +from .models import Splat + + +class SplatJobSummarySerializer(serializers.Serializer): + """Inlined job status — used in SplatMineSerializer.""" + id = serializers.UUIDField() + status = serializers.CharField() + current_step = serializers.CharField() + progress = serializers.IntegerField() + error_message = serializers.CharField() + queued_at = serializers.DateTimeField() + started_at = serializers.DateTimeField() + finished_at = serializers.DateTimeField() + + +class SplatCreateSerializer(serializers.ModelSerializer): + challenge_id = serializers.UUIDField(required=False, allow_null=True) + capture_metadata = serializers.JSONField() + + class Meta: + model = Splat + fields = ["challenge_id", "capture_metadata"] + + def validate_capture_metadata(self, value): + frames = value.get("frames", []) + min_frames = settings.MIN_CAPTURE_FRAMES + if len(frames) < min_frames: + raise serializers.ValidationError( + f"capture_metadata must contain at least {min_frames} frames; " + f"got {len(frames)}." + ) + return value + + +class SplatCreateResponseSerializer(serializers.Serializer): + """Shape of the POST /splats/ response — not a ModelSerializer.""" + id = serializers.UUIDField() + status = serializers.CharField() + upload_url = serializers.CharField(allow_null=True) + upload_expires_at = serializers.DateTimeField() + + +class SplatMapSerializer(GeoFeatureModelSerializer): + """GeoJSON FeatureCollection for the map tile endpoint.""" + preview_url = serializers.SerializerMethodField() + + class Meta: + model = Splat + geo_field = "location" + fields = ["id", "heading", "altitude", "preview_url", "splat_file_size", "created_at"] + + def get_preview_url(self, obj): + return preview_url(obj.preview_key) + + +class SplatDetailSerializer(serializers.ModelSerializer): + coverage = serializers.SerializerMethodField() + preview_url = serializers.SerializerMethodField() + owner_username = serializers.CharField(source="owner.username", read_only=True) + challenge_id = serializers.UUIDField(read_only=True) + + class Meta: + model = Splat + fields = [ + "id", "owner_username", "challenge_id", "status", "is_published", + "location", "coverage", "heading", "altitude", + "preview_url", "splat_file_size", + "quality_score", "frame_count", + "created_at", "updated_at", + ] + + def get_coverage(self, obj): + if obj.coverage: + return obj.coverage.geojson + return None + + def get_preview_url(self, obj): + return preview_url(obj.preview_key) + + +class SplatMineSerializer(serializers.ModelSerializer): + job = serializers.SerializerMethodField() + preview_url = serializers.SerializerMethodField() + + class Meta: + model = Splat + fields = [ + "id", "status", "is_published", + "challenge_id", "preview_url", + "quality_score", "frame_count", + "created_at", "updated_at", + "job", + ] + + def get_job(self, obj): + try: + j = obj.job + except Splat.job.RelatedObjectDoesNotExist: + return None + return SplatJobSummarySerializer(j).data + + def get_preview_url(self, obj): + return preview_url(obj.preview_key) diff --git a/backend/apps/splats/tasks.py b/backend/apps/splats/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/splats/urls.py b/backend/apps/splats/urls.py new file mode 100644 index 0000000..118d680 --- /dev/null +++ b/backend/apps/splats/urls.py @@ -0,0 +1,18 @@ +from django.urls import path +from .views import ( + SplatCreateView, + SplatConfirmUploadView, + SplatDetailView, + SplatDownloadURLView, + SplatMapView, + SplatMineView, +) + +urlpatterns = [ + path("", SplatMapView.as_view(), name="splat-map"), + path("create/", SplatCreateView.as_view(), name="splat-create"), + path("mine/", SplatMineView.as_view(), name="splat-mine"), + path("/", SplatDetailView.as_view(), name="splat-detail"), + path("/confirm-upload/", SplatConfirmUploadView.as_view(), name="splat-confirm-upload"), + path("/download-url/", SplatDownloadURLView.as_view(), name="splat-download-url"), +] diff --git a/backend/apps/splats/views.py b/backend/apps/splats/views.py new file mode 100644 index 0000000..c46983a --- /dev/null +++ b/backend/apps/splats/views.py @@ -0,0 +1,195 @@ +import uuid +from datetime import timedelta + +from django.conf import settings +from django.contrib.gis.geos import Point, Polygon +from django.utils import timezone +from rest_framework import generics, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.common.permissions import IsOwner +from apps.utils.storage import generate_presigned_put_url, generate_presigned_get_url, object_exists +from .models import Splat +from .serializers import ( + SplatCreateSerializer, + SplatCreateResponseSerializer, + SplatDetailSerializer, + SplatMapSerializer, + SplatMineSerializer, +) + + +def _parse_bbox(bbox_str): + """Parse 'minLon,minLat,maxLon,maxLat' into a GEOS Polygon (SRID 4326).""" + try: + parts = [float(x) for x in bbox_str.split(",")] + except (ValueError, AttributeError): + return None, "bbox must be four comma-separated floats: minLon,minLat,maxLon,maxLat" + + if len(parts) != 4: + return None, "bbox must have exactly 4 values" + + min_lon, min_lat, max_lon, max_lat = parts + + if (max_lon - min_lon) > settings.MAX_BBOX_DEGREES or (max_lat - min_lat) > settings.MAX_BBOX_DEGREES: + return None, ( + f"bbox too large. Each side must be ≤ {settings.MAX_BBOX_DEGREES}° " + f"(≈{int(settings.MAX_BBOX_DEGREES * 111)} km). Zoom in and try again." + ) + + poly = Polygon.from_bbox((min_lon, min_lat, max_lon, max_lat)) + poly.srid = 4326 + return poly, None + + +class SplatCreateView(APIView): + def post(self, request): + serializer = SplatCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + challenge = None + challenge_id = data.get("challenge_id") + if challenge_id: + from apps.challenges.models import Challenge + try: + challenge = Challenge.objects.get(pk=challenge_id, status=Challenge.Status.ACTIVE) + except Challenge.DoesNotExist: + return Response( + {"error": "challenge_not_found", "detail": "Challenge does not exist or is closed."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Geofence check: first frame GPS must be inside the challenge region + frames = data["capture_metadata"].get("frames", []) + first = frames[0] + user_point = Point(first["lon"], first["lat"], srid=4326) + + from apps.challenges.models import Challenge as C + inside = C.objects.filter(pk=challenge.pk, region__contains=user_point).exists() + if not inside: + return Response( + { + "error": "outside_challenge_region", + "detail": "Your location is not within the challenge region.", + }, + status=status.HTTP_403_FORBIDDEN, + ) + + if challenge.max_submissions and challenge.submission_count >= challenge.max_submissions: + return Response( + {"error": "submission_limit_reached", "detail": "This challenge has reached its submission limit."}, + status=status.HTTP_403_FORBIDDEN, + ) + + splat_id = uuid.uuid4() + video_key = f"videos/{splat_id}/raw.mp4" + + splat = Splat.objects.create( + id=splat_id, + owner=request.user, + challenge=challenge, + capture_metadata=data["capture_metadata"], + video_key=video_key, + ) + + upload_url = generate_presigned_put_url(video_key, content_type="video/mp4", expires_in=3600) + expires_at = timezone.now() + timedelta(seconds=3600) + + response_data = { + "id": splat.id, + "status": splat.status, + "upload_url": upload_url, + "upload_expires_at": expires_at, + } + return Response(SplatCreateResponseSerializer(response_data).data, status=status.HTTP_201_CREATED) + + +class SplatConfirmUploadView(APIView): + def post(self, request, pk): + try: + splat = Splat.objects.select_related("owner").get(pk=pk, owner=request.user) + except Splat.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if splat.status != Splat.Status.PENDING: + return Response( + {"error": "upload_already_confirmed", "detail": "This splat has already been submitted for processing."}, + status=status.HTTP_409_CONFLICT, + ) + + if not object_exists(splat.video_key): + return Response( + {"error": "video_not_found_in_storage", "detail": "Video file was not found. Please upload the video first."}, + status=status.HTTP_409_CONFLICT, + ) + + # Lazy import avoids circular dependency at module load time + from apps.jobs.models import SplatJob + from apps.jobs.tasks import dispatch_splat_job + + job = SplatJob.objects.create(splat=splat, submitted_by=request.user) + dispatch_splat_job.delay(str(job.id)) + + return Response({"job_id": str(job.id), "status": job.status}, status=status.HTTP_202_ACCEPTED) + + +class SplatMapView(APIView): + def get(self, request): + bbox_str = request.query_params.get("bbox") + if not bbox_str: + return Response( + {"error": "missing_parameter", "detail": "bbox is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + bbox, error = _parse_bbox(bbox_str) + if error: + return Response({"error": "invalid_bbox", "detail": error}, status=status.HTTP_400_BAD_REQUEST) + + qs = Splat.objects.filter(is_published=True, location__within=bbox).select_related("owner") + + challenge_id = request.query_params.get("challenge_id") + if challenge_id: + qs = qs.filter(challenge_id=challenge_id) + + serializer = SplatMapSerializer(qs, many=True) + return Response(serializer.data) + + +class SplatDetailView(generics.RetrieveAPIView): + serializer_class = SplatDetailSerializer + queryset = Splat.objects.filter(is_published=True).select_related("owner") + lookup_field = "pk" + + +class SplatDownloadURLView(APIView): + def get(self, request, pk): + try: + splat = Splat.objects.get(pk=pk, is_published=True) + except Splat.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if not splat.splat_key: + return Response( + {"error": "splat_not_ready", "detail": "Splat file is not available yet."}, + status=status.HTTP_404_NOT_FOUND, + ) + + expires_in = 3600 + url = generate_presigned_get_url(splat.splat_key, expires_in=expires_in) + expires_at = timezone.now() + timedelta(seconds=expires_in) + + return Response({"url": url, "expires_at": expires_at}) + + +class SplatMineView(generics.ListAPIView): + serializer_class = SplatMineSerializer + + def get_queryset(self): + return ( + Splat.objects.filter(owner=self.request.user) + .select_related("job") + .order_by("-created_at") + ) diff --git a/backend/apps/users/__init__.py b/backend/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/users/apps.py b/backend/apps/users/apps.py new file mode 100644 index 0000000..37ba421 --- /dev/null +++ b/backend/apps/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.users" diff --git a/backend/apps/users/migrations/0001_initial.py b/backend/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..209d144 --- /dev/null +++ b/backend/apps/users/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.1.4 on 2026-04-06 03:08 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('oidc_sub', models.CharField(blank=True, max_length=255, null=True, unique=True)), + ('fcm_token', models.TextField(blank=True, default='')), + ('avatar_url', models.URLField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'db_table': 'users', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/backend/apps/users/migrations/__init__.py b/backend/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py new file mode 100644 index 0000000..19c01d7 --- /dev/null +++ b/backend/apps/users/models.py @@ -0,0 +1,18 @@ +import uuid +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class User(AbstractUser): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # sub claim from Authentik OIDC token — used to match incoming JWT to a User row + oidc_sub = models.CharField(max_length=255, unique=True, null=True, blank=True) + fcm_token = models.TextField(blank=True, default="") + avatar_url = models.URLField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.username + + class Meta: + db_table = "users" diff --git a/backend/apps/users/serializers.py b/backend/apps/users/serializers.py new file mode 100644 index 0000000..47970a0 --- /dev/null +++ b/backend/apps/users/serializers.py @@ -0,0 +1,19 @@ +from rest_framework import serializers +from .models import User + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "username", "avatar_url", "created_at"] + read_only_fields = fields + + +class UserUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["avatar_url"] + + +class FCMTokenSerializer(serializers.Serializer): + token = serializers.CharField(max_length=512) diff --git a/backend/apps/users/tasks.py b/backend/apps/users/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/users/urls.py b/backend/apps/users/urls.py new file mode 100644 index 0000000..30be86e --- /dev/null +++ b/backend/apps/users/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import MeView, FCMTokenView + +urlpatterns = [ + path("me/", MeView.as_view(), name="user-me"), + path("me/fcm-token/", FCMTokenView.as_view(), name="user-fcm-token"), +] diff --git a/backend/apps/users/views.py b/backend/apps/users/views.py new file mode 100644 index 0000000..1222c0e --- /dev/null +++ b/backend/apps/users/views.py @@ -0,0 +1,25 @@ +from rest_framework import generics, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import User +from .serializers import UserSerializer, UserUpdateSerializer, FCMTokenSerializer + + +class MeView(generics.RetrieveUpdateAPIView): + def get_serializer_class(self): + if self.request.method == "PATCH": + return UserUpdateSerializer + return UserSerializer + + def get_object(self): + return self.request.user + + +class FCMTokenView(APIView): + def put(self, request): + serializer = FCMTokenSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + request.user.fcm_token = serializer.validated_data["token"] + request.user.save(update_fields=["fcm_token"]) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/apps/utils/__init__.py b/backend/apps/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/utils/fcm.py b/backend/apps/utils/fcm.py new file mode 100644 index 0000000..27ad9b0 --- /dev/null +++ b/backend/apps/utils/fcm.py @@ -0,0 +1,55 @@ +import logging +from django.conf import settings + +logger = logging.getLogger(__name__) + +_app = None + + +def _get_app(): + global _app + if _app is not None: + return _app + + credentials_file = settings.FIREBASE_CREDENTIALS_FILE + if not credentials_file: + return None + + import firebase_admin + from firebase_admin import credentials + + try: + cred = credentials.Certificate(credentials_file) + _app = firebase_admin.initialize_app(cred) + except Exception: + logger.exception("Failed to initialise Firebase app") + return None + + return _app + + +def send_notification(fcm_token, *, title, body, data=None): + """ + Send a single FCM push notification. + Silently no-ops if Firebase is not configured (e.g. in development). + `data` values must all be strings. + """ + if not fcm_token: + return + + app = _get_app() + if app is None: + logger.debug("FCM not configured — skipping notification: %s", title) + return + + from firebase_admin import messaging + + message = messaging.Message( + notification=messaging.Notification(title=title, body=body), + data={k: str(v) for k, v in (data or {}).items()}, + token=fcm_token, + ) + try: + messaging.send(message) + except Exception: + logger.exception("Failed to send FCM notification to token %s", fcm_token[:10]) diff --git a/backend/apps/utils/storage.py b/backend/apps/utils/storage.py new file mode 100644 index 0000000..25c5d45 --- /dev/null +++ b/backend/apps/utils/storage.py @@ -0,0 +1,77 @@ +import boto3 +from botocore.exceptions import ClientError +from django.conf import settings + + +def _is_s3_storage(): + return "S3Boto3Storage" in settings.DEFAULT_FILE_STORAGE + + +def _get_client(): + return boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_S3_REGION_NAME, + ) + + +def generate_presigned_put_url(key, content_type="video/mp4", expires_in=3600): + """ + Return a presigned PUT URL the client can use to upload directly to Wasabi. + In development (local filesystem storage) returns None — callers should handle this. + """ + if not _is_s3_storage(): + return None + client = _get_client() + return client.generate_presigned_url( + "put_object", + Params={ + "Bucket": settings.AWS_STORAGE_BUCKET_NAME, + "Key": key, + "ContentType": content_type, + }, + ExpiresIn=expires_in, + ) + + +def generate_presigned_get_url(key, expires_in=3600): + """Return a presigned GET URL for downloading a private Wasabi object.""" + if not _is_s3_storage(): + return None + client = _get_client() + return client.generate_presigned_url( + "get_object", + Params={ + "Bucket": settings.AWS_STORAGE_BUCKET_NAME, + "Key": key, + }, + ExpiresIn=expires_in, + ) + + +def object_exists(key): + """Return True if the object exists in the Wasabi bucket.""" + if not _is_s3_storage(): + # In dev, assume upload happened (no real Wasabi) + return True + client = _get_client() + try: + client.head_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key) + return True + except ClientError: + return False + + +def preview_url(key): + """ + Return the public URL for a preview image. + Uses Cloudflare CDN in production; falls back to a presigned URL. + """ + if not key: + return None + cdn = settings.CDN_BASE_URL + if cdn: + return f"{cdn.rstrip('/')}/{key}" + return generate_presigned_get_url(key, expires_in=86400) diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..370372a --- /dev/null +++ b/backend/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ["celery_app"] diff --git a/backend/config/api_urls.py b/backend/config/api_urls.py new file mode 100644 index 0000000..b00b308 --- /dev/null +++ b/backend/config/api_urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include + +urlpatterns = [ + path("users/", include("apps.users.urls")), + path("splats/", include("apps.splats.urls")), + path("challenges/", include("apps.challenges.urls")), + path("jobs/", include("apps.jobs.urls")), +] diff --git a/backend/config/celery.py b/backend/config/celery.py new file mode 100644 index 0000000..29576c5 --- /dev/null +++ b/backend/config/celery.py @@ -0,0 +1,8 @@ +import os +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + +app = Celery("splatmap") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/backend/config/settings/__init__.py b/backend/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py new file mode 100644 index 0000000..10cd45c --- /dev/null +++ b/backend/config/settings/base.py @@ -0,0 +1,165 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = os.environ["SECRET_KEY"] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.gis", + # Third-party + "rest_framework", + "rest_framework_gis", + "corsheaders", + "mozilla_django_oidc", + "django_celery_results", + "django_celery_beat", + "storages", + # Apps + "apps.users", + "apps.splats", + "apps.challenges", + "apps.jobs", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.contrib.gis.db.backends.postgis", + "NAME": os.environ.get("POSTGRES_DB", "splatmap"), + "USER": os.environ.get("POSTGRES_USER", "splatmap"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "splatmap"), + "HOST": os.environ.get("POSTGRES_HOST", "db"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + } +} + +AUTH_USER_MODEL = "users.User" + +AUTHENTICATION_BACKENDS = [ + "mozilla_django_oidc.auth.OIDCAuthenticationBackend", +] + +# Authentik OIDC +_OIDC_BASE = os.environ.get("OIDC_OP_BASE_URL", "") +OIDC_RP_CLIENT_ID = os.environ.get("OIDC_RP_CLIENT_ID", "") +OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET", "") +OIDC_OP_AUTHORIZATION_ENDPOINT = f"{_OIDC_BASE}/authorize/" +OIDC_OP_TOKEN_ENDPOINT = f"{_OIDC_BASE}/token/" +OIDC_OP_USER_ENDPOINT = f"{_OIDC_BASE}/userinfo/" +OIDC_OP_JWKS_ENDPOINT = f"{_OIDC_BASE}/jwks/" +OIDC_RP_SIGN_ALGO = "RS256" +OIDC_STORE_ACCESS_TOKEN = True +OIDC_STORE_ID_TOKEN = True + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "mozilla_django_oidc.contrib.drf.OIDCAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.CursorPagination", + "PAGE_SIZE": 50, +} + +# Celery +CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0") +CELERY_RESULT_BACKEND = "django-db" +CELERY_CACHE_BACKEND = "django-cache" +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = "UTC" +CELERY_TASK_ROUTES = { + "apps.jobs.tasks.*": {"queue": "splat_jobs"}, + "apps.challenges.tasks.*": {"queue": "default"}, +} + +# Wasabi / S3 +DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") +AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_STORAGE_BUCKET_NAME", "splatmap") +AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "https://s3.wasabisys.com") +AWS_S3_REGION_NAME = os.environ.get("AWS_S3_REGION_NAME", "us-east-1") +AWS_S3_FILE_OVERWRITE = False +AWS_DEFAULT_ACL = "private" +AWS_S3_SIGNATURE_VERSION = "s3v4" +AWS_PRESIGNED_EXPIRY = 3600 # seconds + +# Firebase +FIREBASE_CREDENTIALS_FILE = os.environ.get("FIREBASE_CREDENTIALS_FILE", "") + +# RunPod +RUNPOD_API_KEY = os.environ.get("RUNPOD_API_KEY", "") +RUNPOD_ENDPOINT_ID = os.environ.get("RUNPOD_ENDPOINT_ID", "") + +# Webhook authentication secret — sent by RunPod in X-Webhook-Secret header +WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "") + +# Public base URL of this API — sent to RunPod so it can call back +API_BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000") + +# Cloudflare CDN prefix in front of Wasabi — used for preview image URLs +CDN_BASE_URL = os.environ.get("CDN_BASE_URL", "") + +# Minimum number of frames required in capture_metadata to attempt reconstruction +MIN_CAPTURE_FRAMES = 100 + +# Thresholds for the quality gate after splatting pipeline completes. +# A splat only gets is_published=True if it passes all three. +SPLAT_QUALITY_THRESHOLDS = { + "min_colmap_points": 500, + "min_quality_score": 0.3, + "min_frame_count": 100, +} + +# Maximum side length in degrees for map tile bbox queries (~111 km per degree) +MAX_BBOX_DEGREES = 1.0 + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = False +USE_TZ = True + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/backend/config/settings/development.py b/backend/config/settings/development.py new file mode 100644 index 0000000..ec062a6 --- /dev/null +++ b/backend/config/settings/development.py @@ -0,0 +1,21 @@ +from .base import * + +DEBUG = True + +ALLOWED_HOSTS = ["*"] + +INSTALLED_APPS += ["debug_toolbar"] + +MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE + +INTERNAL_IPS = ["127.0.0.1"] + +CORS_ALLOW_ALL_ORIGINS = True + +# Use local filesystem in development to avoid needing real Wasabi credentials +DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage" +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +# Log Celery tasks to console +CELERY_TASK_ALWAYS_EAGER = False # set True to run tasks synchronously for debugging diff --git a/backend/config/settings/production.py b/backend/config/settings/production.py new file mode 100644 index 0000000..2827ce4 --- /dev/null +++ b/backend/config/settings/production.py @@ -0,0 +1,20 @@ +from .base import * +import sentry_sdk + +DEBUG = False + +ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",") + +CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "").split(",") + +MIDDLEWARE = ["whitenoise.middleware.WhiteNoiseMiddleware"] + MIDDLEWARE +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +SECURE_HSTS_SECONDS = 31536000 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +if dsn := os.environ.get("SENTRY_DSN"): + sentry_sdk.init(dsn=dsn, traces_sample_rate=0.2) diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..82ca8c8 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from django.urls import path, include +from django.conf import settings + +urlpatterns = [ + path("admin/", admin.site.urls), + path("oidc/", include("mozilla_django_oidc.urls")), + path("api/v1/", include("config.api_urls")), +] + +if settings.DEBUG: + import debug_toolbar + from django.conf.urls.static import static + + urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..42705ef --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,6 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +application = get_wsgi_application() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..ea68389 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError("Couldn't import Django.") from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt new file mode 100644 index 0000000..09bd231 --- /dev/null +++ b/backend/requirements/base.txt @@ -0,0 +1,16 @@ +Django==5.1.4 +djangorestframework==3.15.2 +djangorestframework-gis==1.1 +django-cors-headers==4.6.0 +psycopg2-binary==2.9.10 +celery[redis]==5.4.0 +django-celery-results==2.5.1 +django-celery-beat==2.7.0 +redis==5.2.1 +boto3==1.35.86 +django-storages==1.14.4 +mozilla-django-oidc==4.0.1 +PyJWT==2.10.1 +cryptography==44.0.0 +requests==2.32.3 +firebase-admin==6.6.0 diff --git a/backend/requirements/development.txt b/backend/requirements/development.txt new file mode 100644 index 0000000..e36023a --- /dev/null +++ b/backend/requirements/development.txt @@ -0,0 +1,6 @@ +-r base.txt +django-debug-toolbar==4.4.6 +ipython==8.30.0 +factory-boy==3.3.1 +pytest-django==4.9.0 +pytest-celery==1.1.3 diff --git a/backend/requirements/production.txt b/backend/requirements/production.txt new file mode 100644 index 0000000..ab1748b --- /dev/null +++ b/backend/requirements/production.txt @@ -0,0 +1,4 @@ +-r base.txt +gunicorn==23.0.0 +whitenoise==6.8.2 +sentry-sdk[django]==2.19.2 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4134487 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,85 @@ +services: + db: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_DB: ${POSTGRES_DB:-splatmap} + POSTGRES_USER: ${POSTGRES_USER:-splatmap} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-splatmap} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-splatmap}"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + web: + build: + context: ./backend + args: + REQUIREMENTS: development + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - ./backend:/app + ports: + - "8000:8000" + env_file: + - .env + environment: + DJANGO_SETTINGS_MODULE: config.settings.development + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + celery: + build: + context: ./backend + args: + REQUIREMENTS: development + command: celery -A config worker -l info -Q default,splat_jobs + volumes: + - ./backend:/app + env_file: + - .env + environment: + DJANGO_SETTINGS_MODULE: config.settings.development + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + celery-beat: + build: + context: ./backend + args: + REQUIREMENTS: development + command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler + volumes: + - ./backend:/app + env_file: + - .env + environment: + DJANGO_SETTINGS_MODULE: config.settings.development + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + +volumes: + postgres_data: diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ec0a738 --- /dev/null +++ b/web/index.html @@ -0,0 +1,26 @@ + + + + + + SplatMap + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..8c708a0 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2935 @@ +{ + "name": "splatmap-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "splatmap-web", + "version": "0.1.0", + "dependencies": { + "@mkkellogg/gaussian-splats-3d": "^0.4.6", + "axios": "^1.7.9", + "cesium": "^1.124.0", + "oidc-client-ts": "^3.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "three": "^0.171.0", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/geojson": "^7946.0.16", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@types/three": "^0.171.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vite-plugin-cesium": "^1.2.22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cesium/engine": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-24.0.0.tgz", + "integrity": "sha512-zJ2gl0tyw/FFhBtvp6UYw+0JQJb2J9EiTJYvVSndc6+6qPR5GHLFzFjA1msLLxucfcpc7uI9R2pXNEPluheR/g==", + "license": "Apache-2.0", + "dependencies": { + "@cesium/wasm-splats": "^0.1.0-alpha.2", + "@spz-loader/core": "0.3.1", + "@tweenjs/tween.js": "^25.0.0", + "@zip.js/zip.js": "^2.8.1", + "autolinker": "^4.0.0", + "bitmap-sdf": "^1.0.3", + "dompurify": "^3.3.0", + "draco3d": "^1.5.1", + "earcut": "^3.0.0", + "grapheme-splitter": "^1.0.4", + "jsep": "^1.3.8", + "kdbush": "^4.0.1", + "ktx-parse": "^1.0.0", + "lerc": "^2.0.0", + "mersenne-twister": "^1.1.0", + "meshoptimizer": "^1.0.1", + "pako": "^2.0.4", + "protobufjs": "^8.0.0", + "rbush": "^4.0.1", + "topojson-client": "^3.1.0", + "urijs": "^1.19.7" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@cesium/engine/node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, + "node_modules/@cesium/engine/node_modules/meshoptimizer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.0.tgz", + "integrity": "sha512-KYYsWvWduDIm86HVtTKW1luZSusEv+tgSqYvgSJcKzaHIUNKL9/qQ+48YKcLxknAE8GQAeVi68mgvmEGrjwqjA==", + "license": "MIT" + }, + "node_modules/@cesium/wasm-splats": { + "version": "0.1.0-alpha.2", + "resolved": "https://registry.npmjs.org/@cesium/wasm-splats/-/wasm-splats-0.1.0-alpha.2.tgz", + "integrity": "sha512-t9pMkknv31hhIbLpMa8yPvmqfpvs5UkUjgqlQv9SeO8VerCXOYnyP8/486BDaFrztM0A7FMbRjsXtNeKvqQghA==", + "license": "Apache-2.0" + }, + "node_modules/@cesium/widgets": { + "version": "14.5.0", + "resolved": "https://registry.npmjs.org/@cesium/widgets/-/widgets-14.5.0.tgz", + "integrity": "sha512-h/hKVooXyOtUQJUtrfyBFGMIXb+Q3RLwqE6FzXfzyC0JQuuThLXCz8nRzCPbyiRuAR/aAYT/jbbhQrUxfiWqhQ==", + "license": "Apache-2.0", + "dependencies": { + "@cesium/engine": "^24.0.0", + "nosleep.js": "^0.12.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mkkellogg/gaussian-splats-3d": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@mkkellogg/gaussian-splats-3d/-/gaussian-splats-3d-0.4.7.tgz", + "integrity": "sha512-0vy9/i9sJLFH/v3WJZ4axCsqjkToe8UsV3xY7bvK5EUC0akiRsWZODoCiSzpxhTLNyzSKTsyQKozIFeNA5RWRA==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.160.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@spz-loader/core": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@spz-loader/core/-/core-0.3.1.tgz", + "integrity": "sha512-8qJ1WIBXaJu8HjnJAjYniE0kYcr0kCe5Hp7kDzYiGVvvd7zyrOBwbF5imoW5mvwx1Qba0hxGEK5R9jEoaHKJFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=16", + "pnpm": ">=8" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.171.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.171.0.tgz", + "integrity": "sha512-oLuT1SAsT+CUg/wxUTFHo0K3NtJLnx9sJhZWQJp/0uXqFpzSk1hRHmvWvpaAWSfvx2db0lVKZ5/wV0I0isD2mQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", + "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/autolinker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-4.1.5.tgz", + "integrity": "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "engines": { + "pnpm": ">=10.10.0" + } + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz", + "integrity": "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bitmap-sdf": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", + "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cesium": { + "version": "1.140.0", + "resolved": "https://registry.npmjs.org/cesium/-/cesium-1.140.0.tgz", + "integrity": "sha512-3RvW0rvZWuXiS6regtNE5u9vt0uXohgpsRBIo6Qc922IIIamkitYiEdr4fg+u4qX4EoK9xS3BosCza7iPOExEQ==", + "license": "Apache-2.0", + "peer": true, + "workspaces": [ + "packages/engine", + "packages/widgets", + "packages/sandcastle" + ], + "dependencies": { + "@cesium/engine": "^24.0.0", + "@cesium/widgets": "^14.5.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "license": "MIT" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/ktx-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz", + "integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ==", + "license": "MIT" + }, + "node_modules/lerc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-2.0.0.tgz", + "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg==", + "license": "Apache-2.0" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mersenne-twister": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz", + "integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA==", + "license": "MIT" + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nosleep.js": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz", + "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==", + "license": "MIT" + }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/protobufjs": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.1.tgz", + "integrity": "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/three": { + "version": "0.171.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.171.0.tgz", + "integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==", + "license": "MIT", + "peer": true + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-cesium": { + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/vite-plugin-cesium/-/vite-plugin-cesium-1.2.23.tgz", + "integrity": "sha512-x9A8ZCEoegceXg/E+LnxKr0XBsI9CR4cgYWQ2Dd3cUEYwKcTnHQ3kBfpol7BUcGtgQnQos/mtVrRmuVQBXFjHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fs-extra": "^9.1.0", + "rollup-plugin-external-globals": "^0.6.1", + "serve-static": "^1.14.1" + }, + "peerDependencies": { + "cesium": "^1.95.0", + "vite": ">=2.7.1" + } + }, + "node_modules/vite-plugin-cesium/node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/vite-plugin-cesium/node_modules/rollup-plugin-external-globals": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-external-globals/-/rollup-plugin-external-globals-0.6.1.tgz", + "integrity": "sha512-mlp3KNa5sE4Sp9UUR2rjBrxjG79OyZAh/QC18RHIjM+iYkbBwNXSo8DHRMZWtzJTrH8GxQ+SJvCTN3i14uMXIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^4.0.0", + "estree-walker": "^2.0.1", + "is-reference": "^1.2.1", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^2.25.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8d25d76 --- /dev/null +++ b/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "splatmap-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@mkkellogg/gaussian-splats-3d": "^0.4.6", + "axios": "^1.7.9", + "cesium": "^1.124.0", + "oidc-client-ts": "^3.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "three": "^0.171.0", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/geojson": "^7946.0.16", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@types/three": "^0.171.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vite-plugin-cesium": "^1.2.22" + } +} diff --git a/web/public/auth/silent-callback.html b/web/public/auth/silent-callback.html new file mode 100644 index 0000000..28d852d --- /dev/null +++ b/web/public/auth/silent-callback.html @@ -0,0 +1,14 @@ + + + Silent renew + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..c09d308 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,39 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { AuthProvider } from './auth/AuthProvider' +import { CallbackPage } from './auth/CallbackPage' +import { CesiumViewer } from './cesium/CesiumViewer' +import { SplatLayer } from './splat/SplatLayer' +import { SplatRenderer } from './splat/SplatRenderer' +import { ChallengeLayer } from './challenges/ChallengeLayer' +import { ChallengePanel } from './challenges/ChallengePanel' +import { ChallengeCreator } from './challenges/ChallengeCreator' +import { MapOverlay } from './ui/MapOverlay' + +function MapPage() { + return ( + + {/* Imperative Cesium layers — render no DOM, manage entities */} + + + {/* Three.js splat overlay — portalled canvas above Cesium */} + + {/* React UI — z-indexed above Cesium canvas */} + + + + + ) +} + +export default function App() { + return ( + + + + } /> + } /> + + + + ) +} diff --git a/web/src/api/challenges.ts b/web/src/api/challenges.ts new file mode 100644 index 0000000..922d25e --- /dev/null +++ b/web/src/api/challenges.ts @@ -0,0 +1,61 @@ +import { apiClient } from './client' +import { bboxToString } from '../types/geo' +import type { BBox } from '../types/geo' +import type { + ChallengeCreateBody, + ChallengeDetail, + ChallengeMapProperties, + ChallengeParticipant, + SplatMapProperties, +} from '../types/api' + +export async function fetchChallenges( + options: { bbox?: BBox; status?: string; near?: { lat: number; lon: number; radiusM: number } } = {}, +): Promise> { + const params: Record = {} + if (options.bbox) params.bbox = bboxToString(options.bbox) + if (options.status) params.status = options.status + if (options.near) { + params.near = `${options.near.lat},${options.near.lon},${options.near.radiusM}` + } + const { data } = await apiClient.get('/challenges/', { params }) + return data +} + +export async function fetchChallengeDetail(id: string): Promise { + const { data } = await apiClient.get(`/challenges/${id}/`) + return data +} + +export async function createChallenge(body: ChallengeCreateBody): Promise { + const { data } = await apiClient.post('/challenges/', body) + return data +} + +export async function updateChallenge( + id: string, + body: Partial>, +): Promise { + const { data } = await apiClient.patch(`/challenges/${id}/`, body) + return data +} + +export async function closeChallenge(id: string): Promise { + await apiClient.delete(`/challenges/${id}/`) +} + +export async function participateInChallenge(id: string): Promise { + const { data } = await apiClient.post(`/challenges/${id}/participate/`) + return data +} + +export async function leaveChallenge(id: string): Promise { + await apiClient.delete(`/challenges/${id}/participate/`) +} + +export async function fetchChallengeSplats( + id: string, +): Promise> { + const { data } = await apiClient.get(`/challenges/${id}/splats/`) + return data +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..390bb18 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,14 @@ +import axios from 'axios' +import { userManager } from '../auth/userManager' + +export const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api/v1', +}) + +apiClient.interceptors.request.use(async (config) => { + const user = await userManager.getUser() + if (user?.access_token) { + config.headers.Authorization = `Bearer ${user.access_token}` + } + return config +}) diff --git a/web/src/api/splats.ts b/web/src/api/splats.ts new file mode 100644 index 0000000..48cf903 --- /dev/null +++ b/web/src/api/splats.ts @@ -0,0 +1,24 @@ +import { apiClient } from './client' +import { bboxToString } from '../types/geo' +import type { BBox } from '../types/geo' +import type { SplatDetail, SplatDownloadUrl, SplatMapProperties } from '../types/api' + +export async function fetchSplats( + bbox: BBox, + challengeId?: string, +): Promise> { + const params: Record = { bbox: bboxToString(bbox) } + if (challengeId) params.challenge_id = challengeId + const { data } = await apiClient.get('/splats/', { params }) + return data +} + +export async function fetchSplatDetail(id: string): Promise { + const { data } = await apiClient.get(`/splats/${id}/`) + return data +} + +export async function fetchSplatDownloadUrl(id: string): Promise { + const { data } = await apiClient.get(`/splats/${id}/download-url/`) + return data +} diff --git a/web/src/auth/AuthProvider.tsx b/web/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..0658415 --- /dev/null +++ b/web/src/auth/AuthProvider.tsx @@ -0,0 +1,61 @@ +import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' +import { useAuthStore } from '../store/authStore' +import { userManager } from './userManager' + +const DEV_SKIP_AUTH = import.meta.env.VITE_DEV_SKIP_AUTH === 'true' + +interface Props { + children: React.ReactNode +} + +export function AuthProvider({ children }: Props) { + const { setUser, setLoading } = useAuthStore() + const location = useLocation() + + useEffect(() => { + // Dev bypass: skip OIDC entirely when Authentik is not running locally. + // API calls will return 401 but the map and UI will render. + if (DEV_SKIP_AUTH) { + setLoading(false) + return + } + + let cancelled = false + + async function init() { + setLoading(true) + try { + const user = await userManager.getUser() + if (cancelled) return + + if (user && !user.expired) { + setUser(user) + } else if (location.pathname !== '/auth/callback') { + await userManager.signinRedirect({ + state: { returnTo: location.pathname }, + }) + } + } finally { + if (!cancelled) setLoading(false) + } + } + + init() + + const onUserLoaded = userManager.events.addUserLoaded((user) => { + if (!cancelled) setUser(user) + }) + const onUserUnloaded = userManager.events.addUserUnloaded(() => { + if (!cancelled) setUser(null) + }) + + return () => { + cancelled = true + userManager.events.removeUserLoaded(onUserLoaded as never) + userManager.events.removeUserUnloaded(onUserUnloaded as never) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return <>{children} +} diff --git a/web/src/auth/CallbackPage.tsx b/web/src/auth/CallbackPage.tsx new file mode 100644 index 0000000..8826722 --- /dev/null +++ b/web/src/auth/CallbackPage.tsx @@ -0,0 +1,30 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { userManager } from './userManager' +import { useAuthStore } from '../store/authStore' + +export function CallbackPage() { + const navigate = useNavigate() + const { setUser } = useAuthStore() + + useEffect(() => { + userManager + .signinRedirectCallback() + .then((user) => { + setUser(user) + const state = user.state as { returnTo?: string } | undefined + navigate(state?.returnTo ?? '/', { replace: true }) + }) + .catch(() => { + // If the callback fails (e.g. page refreshed on the callback URL), + // kick off a fresh login instead of showing a blank screen. + userManager.signinRedirect() + }) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+ Signing in… +
+ ) +} diff --git a/web/src/auth/userManager.ts b/web/src/auth/userManager.ts new file mode 100644 index 0000000..69bb623 --- /dev/null +++ b/web/src/auth/userManager.ts @@ -0,0 +1,12 @@ +import { UserManager, WebStorageStateStore } from 'oidc-client-ts' + +export const userManager = new UserManager({ + authority: import.meta.env.VITE_OIDC_AUTHORITY, + client_id: import.meta.env.VITE_OIDC_CLIENT_ID, + redirect_uri: `${window.location.origin}/auth/callback`, + silent_redirect_uri: `${window.location.origin}/auth/silent-callback.html`, + scope: 'openid profile email', + response_type: 'code', + userStore: new WebStorageStateStore({ store: window.localStorage }), + automaticSilentRenew: true, +}) diff --git a/web/src/cesium/CesiumViewer.tsx b/web/src/cesium/CesiumViewer.tsx new file mode 100644 index 0000000..05b0037 --- /dev/null +++ b/web/src/cesium/CesiumViewer.tsx @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from 'react' +import * as Cesium from 'cesium' +import 'cesium/Build/Cesium/Widgets/widgets.css' +import { CesiumContext } from './cesiumContext' + +interface Props { + children?: React.ReactNode +} + +export function CesiumViewer({ children }: Props) { + const containerRef = useRef(null) + const [viewer, setViewer] = useState(null) + + useEffect(() => { + // Guard: only create if the container is mounted and no viewer yet + if (!containerRef.current || viewer) return + + Cesium.Ion.defaultAccessToken = import.meta.env.VITE_CESIUM_ION_TOKEN ?? '' + + const v = new Cesium.Viewer(containerRef.current, { + terrainProvider: new Cesium.EllipsoidTerrainProvider(), + homeButton: false, + baseLayerPicker: false, + navigationHelpButton: false, + animation: false, + timeline: false, + geocoder: false, + sceneModePicker: false, + fullscreenButton: false, + infoBox: false, + selectionIndicator: false, + }) + + // Async: upgrade to world terrain after initial load + Cesium.createWorldTerrainAsync() + .then((tp) => { + if (!v.isDestroyed()) v.terrainProvider = tp + }) + .catch(() => {/* non-fatal: fall back to ellipsoid */}) + + // Hide Cesium's own credit container — we'll add our own if needed + const creditContainer = v.cesiumWidget.creditContainer as HTMLElement + creditContainer.style.display = 'none' + + setViewer(v) + + return () => { + if (!v.isDestroyed()) v.destroy() + setViewer(null) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <> + {/* Cesium mounts itself into this div and fills it completely */} +
+ {/* Provide viewer to all children; only render children once viewer is ready */} + {viewer && ( + + {children} + + )} + + ) +} diff --git a/web/src/cesium/cesiumContext.ts b/web/src/cesium/cesiumContext.ts new file mode 100644 index 0000000..28d6ff9 --- /dev/null +++ b/web/src/cesium/cesiumContext.ts @@ -0,0 +1,14 @@ +import { createContext, useContext } from 'react' +import type * as CesiumType from 'cesium' + +type Viewer = InstanceType + +export const CesiumContext = createContext(null) + +export function useCesiumViewer(): Viewer { + const viewer = useContext(CesiumContext) + if (!viewer) { + throw new Error('useCesiumViewer must be used inside ') + } + return viewer +} diff --git a/web/src/cesium/geoUtils.ts b/web/src/cesium/geoUtils.ts new file mode 100644 index 0000000..a2ec58b --- /dev/null +++ b/web/src/cesium/geoUtils.ts @@ -0,0 +1,63 @@ +import * as Cesium from 'cesium' +import * as THREE from 'three' + +/** + * Build a Three.js Matrix4 that positions and orients a local scene + * at the given geographic coordinate. + * + * The returned matrix transforms from local ENU space (metres from the + * anchor point, X=East, Y=North, Z=Up) to Cesium ECEF space (metres + * from Earth centre). Apply it to a Three.js Object3D.matrixWorld and + * set matrixAutoUpdate = false. + * + * @param lon Longitude in degrees + * @param lat Latitude in degrees + * @param alt Altitude in metres above WGS-84 ellipsoid + * @param headingDeg Clockwise heading in degrees (0 = North, 90 = East) + */ +export function buildSplatWorldMatrix( + lon: number, + lat: number, + alt: number, + headingDeg: number, +): THREE.Matrix4 { + const position = Cesium.Cartesian3.fromDegrees(lon, lat, alt) + + // 4×4 column-major matrix: local ENU → ECEF + const enuToEcef = Cesium.Transforms.eastNorthUpToFixedFrame(position) + + // Apply a rotation around local Up (Z in ENU) for heading. + // Cesium heading is clockwise from North, which is –Z rotation in ENU. + const headingRad = Cesium.Math.toRadians(-headingDeg) + const headingRotation = Cesium.Matrix4.fromRotationTranslation( + Cesium.Matrix3.fromRotationZ(headingRad), + ) + const finalCesiumMatrix = new Cesium.Matrix4() + Cesium.Matrix4.multiply(enuToEcef, headingRotation, finalCesiumMatrix) + + // Cesium Matrix4 is a Float64Array in column-major order. + // Three.js Matrix4 uses Float32Array, also column-major. + // Direct cast works since both use the same element layout. + const threeMatrix = new THREE.Matrix4() + threeMatrix.set( + finalCesiumMatrix[0], finalCesiumMatrix[4], finalCesiumMatrix[8], finalCesiumMatrix[12], + finalCesiumMatrix[1], finalCesiumMatrix[5], finalCesiumMatrix[9], finalCesiumMatrix[13], + finalCesiumMatrix[2], finalCesiumMatrix[6], finalCesiumMatrix[10], finalCesiumMatrix[14], + finalCesiumMatrix[3], finalCesiumMatrix[7], finalCesiumMatrix[11], finalCesiumMatrix[15], + ) + return threeMatrix +} + +/** + * Convert a Cesium Rectangle (radians) to a bbox tuple (degrees). + */ +export function rectangleToBbox( + rect: Cesium.Rectangle, +): [number, number, number, number] { + return [ + Cesium.Math.toDegrees(rect.west), + Cesium.Math.toDegrees(rect.south), + Cesium.Math.toDegrees(rect.east), + Cesium.Math.toDegrees(rect.north), + ] +} diff --git a/web/src/cesium/useCesiumCamera.ts b/web/src/cesium/useCesiumCamera.ts new file mode 100644 index 0000000..b1ea1ad --- /dev/null +++ b/web/src/cesium/useCesiumCamera.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import { useCesiumViewer } from './cesiumContext' +import { useMapStore } from '../store/mapStore' +import { rectangleToBbox } from './geoUtils' + +/** + * Attaches a scene.preUpdate listener that writes camera height and + * the current view bbox to mapStore on every frame. + * + * Throttled so the store update fires at most once per 200 ms to avoid + * triggering expensive API queries on every rendered frame. + */ +export function useCesiumCamera() { + const viewer = useCesiumViewer() + const setCameraState = useMapStore((s) => s.setCameraState) + + useEffect(() => { + let lastFired = 0 + const THROTTLE_MS = 200 + + const removeListener = viewer.scene.preUpdate.addEventListener(() => { + const now = Date.now() + if (now - lastFired < THROTTLE_MS) return + lastFired = now + + const height = viewer.camera.positionCartographic.height + const rect = viewer.camera.computeViewRectangle() + if (rect) { + setCameraState(height, rectangleToBbox(rect)) + } + }) + + return () => { + removeListener() + } + }, [viewer, setCameraState]) +} diff --git a/web/src/challenges/ChallengeCreator.module.css b/web/src/challenges/ChallengeCreator.module.css new file mode 100644 index 0000000..4bd3548 --- /dev/null +++ b/web/src/challenges/ChallengeCreator.module.css @@ -0,0 +1,76 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.modal { + background: #1a1a2e; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 12px; + padding: 28px; + width: 100%; + max-width: 480px; + color: #fff; +} + +.heading { margin: 0 0 6px; font-size: 20px; } +.sub { margin: 0 0 20px; color: rgba(255,255,255,0.55); font-size: 14px; } + +.form { display: flex; flex-direction: column; gap: 16px; } + +.label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 14px; + color: rgba(255,255,255,0.7); +} + +.input { + padding: 9px 12px; + background: rgba(255,255,255,0.07); + border: 1px solid rgba(255,255,255,0.15); + border-radius: 6px; + color: #fff; + font-size: 15px; + outline: none; + resize: vertical; +} +.input:focus { border-color: #6366f1; } +.input::placeholder { color: rgba(255,255,255,0.3); } + +.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + +.error { color: #f87171; font-size: 14px; margin: 0; } + +.actions { display: flex; gap: 10px; justify-content: flex-end; } + +.cancelBtn { + padding: 10px 18px; + background: none; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 8px; + color: rgba(255,255,255,0.7); + cursor: pointer; + font-size: 14px; +} +.cancelBtn:hover { border-color: rgba(255,255,255,0.4); color: #fff; } + +.submitBtn { + padding: 10px 20px; + background: #6366f1; + border: none; + border-radius: 8px; + color: #fff; + font-weight: 600; + cursor: pointer; + font-size: 14px; +} +.submitBtn:hover:not(:disabled) { background: #4f46e5; } +.submitBtn:disabled { opacity: 0.5; cursor: default; } diff --git a/web/src/challenges/ChallengeCreator.tsx b/web/src/challenges/ChallengeCreator.tsx new file mode 100644 index 0000000..a8577a4 --- /dev/null +++ b/web/src/challenges/ChallengeCreator.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react' +import { useChallengeStore } from '../store/challengeStore' +import { createChallenge } from '../api/challenges' +import styles from './ChallengeCreator.module.css' + +export function ChallengeCreator() { + const { draftPolygon, setDraftPolygon, setSelectedChallengeId } = useChallengeStore() + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [maxSubmissions, setMaxSubmissions] = useState('') + const [expiresAt, setExpiresAt] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + if (!draftPolygon) return null + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!title.trim() || !draftPolygon) return + + setSaving(true) + setError('') + try { + const challenge = await createChallenge({ + title: title.trim(), + description: description.trim() || undefined, + region: draftPolygon, + max_submissions: maxSubmissions ? parseInt(maxSubmissions, 10) : null, + expires_at: expiresAt || null, + }) + setDraftPolygon(null) + setSelectedChallengeId(challenge.id) + setTitle('') + setDescription('') + setMaxSubmissions('') + setExpiresAt('') + } catch { + setError('Failed to create challenge. Please try again.') + } finally { + setSaving(false) + } + } + + return ( +
+
+

Create Challenge

+

+ Your polygon has been captured. Fill in the details below. +

+ +
+ + +