"""Durable Mission Core catalog and orchestration for portable Gaussian worlds.""" from __future__ import annotations import hashlib import json import math import os import re import shutil import threading import time from collections import deque from collections.abc import Callable from contextlib import suppress from pathlib import Path from typing import Any, Final, TypeVar from urllib.parse import quote from uuid import uuid4 from k1link.artifacts import utc_now_iso from k1link.simulation.gaussian_pipeline_gateway import ( BUILD_REQUEST_SCHEMA, GaussianPipelineGateway, GaussianPipelineGatewayError, GaussianPipelineUnavailableError, configured_gaussian_pipeline_gateway, discover_gaussian_source_bundle, is_xgrids_source_mesh_path, ) PROJECT_SCHEMA: Final = "missioncore.simulation-project/v1" WORLD_MANIFEST_SCHEMA: Final = "missioncore.simulation-world-manifest/v1" VIEWER_SETTINGS_SCHEMA: Final = "missioncore.simulation-viewer-settings/v3" PROJECT_ID_PATTERN: Final = re.compile(r"^sim-[a-f0-9]{32}$") SOURCE_FILE_ID_PATTERN: Final = re.compile(r"^src-[0-9]{5}-[a-f0-9]{8}$") PROVIDER_JOB_ID_PATTERN: Final = re.compile(r"^gsp-[0-9]{14}-[a-f0-9]{8}$") PROVIDER_TIMESTAMP_PATTERN: Final = re.compile( r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$" ) MAX_SOURCE_FILES: Final = 10_000 MAX_SOURCE_BYTES: Final = 16 * 1024 * 1024 * 1024 MAX_UPLOAD_CHUNK_BYTES: Final = 16 * 1024 * 1024 TERMINAL_STATES: Final = {"ready", "failed"} ACTIVE_STATES: Final = {"queued", "processing", "importing"} PROVIDER_JOB_STATES: Final = { "queued", "verifying_source", "inspecting", "building_preview", "building_streamed_sog", "building_collision", "ready", "failed", } PROVIDER_POLL_TIMEOUT_SECONDS: Final = 2 * 60 * 60 + 5 * 60 PROVIDER_UNAVAILABLE_RETRY_LIMIT: Final = 6 _T = TypeVar("_T") class SimulationProjectError(RuntimeError): """The project request violates its durable catalog contract.""" class SimulationProjectNotFoundError(SimulationProjectError): """The requested project or source member does not exist.""" class SimulationProjectConflictError(SimulationProjectError): """The requested transition conflicts with current project state.""" class _SimulationProcessingCancelled(RuntimeError): """Internal cooperative stop after an operator deletes an active project.""" class SimulationProjectStore: def __init__(self, data_dir: Path) -> None: self.root = data_dir.expanduser().resolve() / "simulation-worlds" self.projects_root = self.root / "projects" self.projects_root.mkdir(mode=0o700, parents=True, exist_ok=True) self._lock = threading.RLock() def create( self, *, name: str, scene_type: str, source_kind: str, files: list[dict[str, object]], ) -> dict[str, Any]: display_name = _project_name(name) if scene_type not in {"interior", "outdoor", "object"}: raise SimulationProjectError("simulation scene type is invalid") if source_kind not in {"archive", "folder"}: raise SimulationProjectError("simulation source kind is invalid") if not files or len(files) > MAX_SOURCE_FILES: raise SimulationProjectError("simulation source file count is outside limits") normalized: list[dict[str, object]] = [] logical_paths: set[str] = set() total_bytes = 0 for index, raw in enumerate(files): if set(raw) != {"logical_path", "byte_length"}: raise SimulationProjectError("simulation source file contract is invalid") logical_path = _logical_path(raw.get("logical_path")) byte_length = raw.get("byte_length") if ( not isinstance(byte_length, int) or isinstance(byte_length, bool) or byte_length <= 0 or byte_length > MAX_SOURCE_BYTES ): raise SimulationProjectError("simulation source file size is invalid") if logical_path in logical_paths: raise SimulationProjectError("simulation source paths must be unique") logical_paths.add(logical_path) total_bytes += byte_length normalized.append( { "file_id": f"src-{index:05d}-{uuid4().hex[:8]}", "logical_path": logical_path, "byte_length": byte_length, "uploaded_bytes": 0, "sha256": None, } ) if total_bytes > MAX_SOURCE_BYTES: raise SimulationProjectError("simulation source exceeds byte admission") if source_kind == "archive": if len(normalized) != 1 or "/" in str(normalized[0]["logical_path"]): raise SimulationProjectError("archive source must be one top-level file") if Path(str(normalized[0]["logical_path"])).suffix.lower() not in { ".zip", ".rar", ".7z", }: raise SimulationProjectError("archive source must be zip, rar or 7z") project_id = f"sim-{uuid4().hex}" now = utc_now_iso() document: dict[str, Any] = { "schema_version": PROJECT_SCHEMA, "project_id": project_id, "name": display_name, "scene_type": scene_type, "status": "uploading", "source": { "kind": source_kind, "total_byte_length": total_bytes, "uploaded_byte_length": 0, "files": normalized, "bundle_sha256": None, }, "provider": { "provider_id": "gaussian-pipeline", "job_id": None, "job_created_at_utc": None, "state": None, "state_started_at_utc": None, "progress": None, "runtime": None, }, "artifacts": [], "world_manifest": None, "viewer_settings": _default_viewer_settings(), "error": None, "created_at_utc": now, "updated_at_utc": now, } with self._lock: project_root = self._project_root(project_id) project_root.mkdir(mode=0o700, parents=False, exist_ok=False) (project_root / "source").mkdir(mode=0o700) (project_root / "artifacts").mkdir(mode=0o700) self._write(document) return document def list(self) -> list[dict[str, Any]]: with self._lock: projects: list[dict[str, Any]] = [] for entry in self.projects_root.iterdir(): if not entry.is_dir() or PROJECT_ID_PATTERN.fullmatch(entry.name) is None: continue projects.append(self._read(entry.name)) projects.sort( key=lambda item: (str(item["created_at_utc"]), str(item["project_id"])), reverse=True, ) return projects def get(self, project_id: str) -> dict[str, Any]: with self._lock: return self._read(project_id) def update_metadata( self, project_id: str, *, name: str, scene_type: str, ) -> dict[str, Any]: display_name = _project_name(name) if scene_type not in {"interior", "outdoor", "object"}: raise SimulationProjectError("simulation scene type is invalid") with self._lock: document = self._read(project_id) document["name"] = display_name document["scene_type"] = scene_type document["updated_at_utc"] = utc_now_iso() self._write(document) return document def update_viewer_settings( self, project_id: str, settings: dict[str, Any], ) -> dict[str, Any]: normalized = _viewer_settings(settings) with self._lock: document = self._read(project_id) document["viewer_settings"] = normalized document["updated_at_utc"] = utc_now_iso() self._write(document) return document def upload_state(self, project_id: str, file_id: str) -> tuple[int, int]: with self._lock: document = self._read(project_id) source_file = _source_file(document, file_id) return int(source_file["uploaded_bytes"]), int(source_file["byte_length"]) def append_upload( self, project_id: str, file_id: str, *, offset: int, payload: bytes, ) -> dict[str, Any]: if not payload or len(payload) > MAX_UPLOAD_CHUNK_BYTES: raise SimulationProjectError("simulation upload chunk is outside limits") with self._lock: document = self._read(project_id) if document["status"] != "uploading": raise SimulationProjectConflictError("simulation source upload is closed") source_file = _source_file(document, file_id) uploaded = int(source_file["uploaded_bytes"]) byte_length = int(source_file["byte_length"]) if offset != uploaded: raise SimulationProjectConflictError("simulation upload offset does not match") if uploaded + len(payload) > byte_length: raise SimulationProjectError("simulation upload exceeds declared file size") target = self._source_path(project_id, str(source_file["logical_path"])) target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) mode = "xb" if uploaded == 0 else "r+b" with target.open(mode) as stream: if uploaded: stream.seek(uploaded) stream.write(payload) stream.flush() os.fsync(stream.fileno()) uploaded += len(payload) source_file["uploaded_bytes"] = uploaded if uploaded == byte_length: source_file["sha256"] = _sha256(target) document["source"]["uploaded_byte_length"] = sum( int(item["uploaded_bytes"]) for item in document["source"]["files"] ) document["updated_at_utc"] = utc_now_iso() self._write(document) return document def begin_build(self, project_id: str) -> dict[str, Any]: with self._lock: document = self._read(project_id) if document["status"] not in {"uploading", "failed", "ready"}: raise SimulationProjectConflictError("simulation project cannot start a build") if any( int(item["uploaded_bytes"]) != int(item["byte_length"]) for item in document["source"]["files"] ): raise SimulationProjectConflictError("simulation source upload is incomplete") document["status"] = "queued" document["provider"] = { "provider_id": "gaussian-pipeline", "job_id": None, "job_created_at_utc": None, "state": None, "state_started_at_utc": None, "progress": None, "runtime": None, } document["artifacts"] = [] document["world_manifest"] = None document["error"] = None document["updated_at_utc"] = utc_now_iso() self._write(document) return document def update_processing( self, project_id: str, *, status: str, provider_job_id: str | None = None, provider_job_created_at_utc: str | None = None, provider_state: str | None = None, provider_state_started_at_utc: str | None = None, progress: object = None, bundle_sha256: str | None = None, ) -> dict[str, Any]: if status not in {"queued", "processing", "importing"}: raise SimulationProjectError("simulation processing status is invalid") with self._lock: document = self._read(project_id) document["status"] = status if provider_job_id is not None: if PROVIDER_JOB_ID_PATTERN.fullmatch(provider_job_id) is None: raise SimulationProjectError("simulation provider job id is invalid") document["provider"]["job_id"] = provider_job_id if provider_job_created_at_utc is not None: document["provider"]["job_created_at_utc"] = _provider_timestamp( provider_job_created_at_utc, "job creation", ) if provider_state is not None: previous_state = document["provider"].get("state") document["provider"]["state"] = provider_state if ( previous_state != provider_state or document["provider"].get("state_started_at_utc") is None ): document["provider"]["state_started_at_utc"] = ( _provider_timestamp( provider_state_started_at_utc, "state start", ) if provider_state_started_at_utc is not None else utc_now_iso() ) if progress is not None: document["provider"]["progress"] = progress if bundle_sha256 is not None: document["source"]["bundle_sha256"] = bundle_sha256 document["error"] = None document["updated_at_utc"] = utc_now_iso() self._write(document) return document def complete( self, project_id: str, *, result: dict[str, Any], artifacts: list[dict[str, Any]], world_manifest: dict[str, Any], provider_job_id: str | None = None, provider_progress: object = None, ) -> dict[str, Any]: with self._lock: document = self._read(project_id) if provider_job_id is not None: if PROVIDER_JOB_ID_PATTERN.fullmatch(provider_job_id) is None: raise SimulationProjectError("simulation provider job id is invalid") document["provider"]["job_id"] = provider_job_id if provider_progress is not None: document["provider"]["progress"] = provider_progress document["status"] = "ready" document["provider"]["state"] = "ready" document["provider"]["runtime"] = result.get("runtime") document["artifacts"] = artifacts document["world_manifest"] = world_manifest document["error"] = None document["updated_at_utc"] = utc_now_iso() self._write(document) return document def fail(self, project_id: str, message: str) -> dict[str, Any]: with self._lock: document = self._read(project_id) document["status"] = "failed" document["error"] = message[:2048] document["updated_at_utc"] = utc_now_iso() self._write(document) return document def artifact_path(self, project_id: str, logical_path: str) -> tuple[Path, dict[str, Any]]: safe = _logical_path(logical_path) with self._lock: document = self._read(project_id) descriptor = next( (item for item in document["artifacts"] if item.get("logical_path") == safe), None, ) if descriptor is None: raise SimulationProjectNotFoundError("simulation artifact is unavailable") target = self._artifact_path(project_id, safe) if not target.is_file() or target.is_symlink(): raise SimulationProjectNotFoundError("simulation artifact is unavailable") return target, descriptor def delete(self, project_id: str, *, allow_active: bool = False) -> None: with self._lock: document = self._read(project_id) if ( not allow_active and document["status"] not in TERMINAL_STATES and document["status"] != "uploading" ): raise SimulationProjectConflictError("active simulation project cannot be deleted") shutil.rmtree(self._project_root(project_id)) def source_root(self, project_id: str) -> Path: self.get(project_id) return self._project_root(project_id) / "source" def artifacts_root(self, project_id: str) -> Path: self.get(project_id) return self._project_root(project_id) / "artifacts" def _read(self, project_id: str) -> dict[str, Any]: project_root = self._project_root(project_id) try: document = json.loads((project_root / "project.json").read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise SimulationProjectNotFoundError("simulation project is unavailable") from exc if ( not isinstance(document, dict) or document.get("schema_version") != PROJECT_SCHEMA or document.get("project_id") != project_id ): raise SimulationProjectError("persisted simulation project identity is invalid") document["viewer_settings"] = _migrate_viewer_settings(document.get("viewer_settings")) provider = document.get("provider") if isinstance(provider, dict): provider.setdefault("job_created_at_utc", None) provider.setdefault("state_started_at_utc", None) return document def _write(self, document: dict[str, Any]) -> None: project_id = str(document["project_id"]) destination = self._project_root(project_id) / "project.json" temporary = destination.with_name(f".project-{uuid4().hex}.tmp") with temporary.open("x", encoding="utf-8") as stream: json.dump(document, stream, ensure_ascii=False, indent=2) stream.write("\n") stream.flush() os.fsync(stream.fileno()) temporary.replace(destination) def _project_root(self, project_id: str) -> Path: if PROJECT_ID_PATTERN.fullmatch(project_id) is None: raise SimulationProjectNotFoundError("simulation project is unavailable") return self.projects_root / project_id def _source_path(self, project_id: str, logical_path: str) -> Path: return _confined_path(self._project_root(project_id) / "source", logical_path) def _artifact_path(self, project_id: str, logical_path: str) -> Path: return _confined_path(self._project_root(project_id) / "artifacts", logical_path) class SimulationProjectService: def __init__( self, store: SimulationProjectStore, provider_factory=configured_gaussian_pipeline_gateway, ) -> None: self.store = store self.provider_factory = provider_factory self._condition = threading.Condition() self._queue: deque[str] = deque() self._queued_ids: set[str] = set() self._active_project_id: str | None = None self._worker: threading.Thread | None = None self._cancel_events: dict[str, threading.Event] = {} self._inflight_jobs: dict[str, str] = {} def recover_pending(self) -> int: pending = sorted( [project for project in self.store.list() if project.get("status") in ACTIVE_STATES], key=lambda project: ( str(project.get("created_at_utc")), str(project.get("project_id")), ), ) for project in pending: self.enqueue(str(project["project_id"])) return len(pending) def enqueue(self, project_id: str) -> None: project = self.store.get(project_id) if project.get("status") not in ACTIVE_STATES: raise SimulationProjectConflictError( "simulation project is not ready for queued processing" ) with self._condition: if project_id in self._queued_ids or project_id == self._active_project_id: return event = self._cancel_events.setdefault(project_id, threading.Event()) event.clear() self._queue.append(project_id) self._queued_ids.add(project_id) if self._worker is None or not self._worker.is_alive(): self._worker = threading.Thread( target=self._run_queue, name="simulation-build-queue", daemon=True, ) self._worker.start() self._condition.notify_all() def _run_queue(self) -> None: while True: with self._condition: if not self._queue: self._worker = None self._condition.notify_all() return project_id = self._queue.popleft() self._queued_ids.discard(project_id) self._active_project_id = project_id try: self.process(project_id) finally: with self._condition: self._active_project_id = None self._inflight_jobs.pop(project_id, None) self._cancel_events.pop(project_id, None) self._condition.notify_all() def begin_build(self, project_id: str) -> dict[str, Any]: project = self.store.get(project_id) if project.get("status") == "failed": job_id = project["provider"].get("job_id") provider_state = project["provider"].get("state") if ( isinstance(job_id, str) and isinstance(provider_state, str) and provider_state in PROVIDER_JOB_STATES - {"failed"} ): provider = self.provider_factory() if provider is None: raise SimulationProjectConflictError( "Gaussian Pipeline недоступен для продолжения сборки." ) try: job = _retry_provider_unavailable(lambda: provider.get_job(job_id)) finally: provider.close() current_state = job.get("state") if isinstance(current_state, str) and current_state in PROVIDER_JOB_STATES - { "failed" }: return self.store.update_processing( project_id, status="processing", provider_job_id=job_id, provider_job_created_at_utc=_optional_provider_timestamp( job.get("created_at_utc") ), provider_state=current_state, provider_state_started_at_utc=_optional_provider_timestamp( job.get("updated_at_utc") ), progress=job.get("progress"), ) if project.get("status") in {"failed", "ready"}: job_id = project["provider"].get("job_id") if isinstance(job_id, str): provider = self.provider_factory() if provider is None: raise SimulationProjectConflictError( "Gaussian Pipeline недоступен для повторной сборки." ) try: _retry_provider_unavailable(lambda: provider.delete_job(job_id)) finally: provider.close() return self.store.begin_build(project_id) def process(self, project_id: str) -> None: provider: GaussianPipelineGateway | None = None try: self._raise_if_cancelled(project_id) project = self.store.get(project_id) provider = self.provider_factory() if provider is None: raise SimulationProjectError("Gaussian Pipeline не настроен.") _retry_provider_unavailable(provider.capabilities) self._raise_if_cancelled(project_id) existing_job_id = project["provider"].get("job_id") if isinstance(existing_job_id, str): job_id = existing_job_id self._register_inflight_job(project_id, job_id, provider) else: if project["status"] != "queued": raise SimulationProjectError( "Активная Gaussian-сборка потеряла provider job id после перезапуска." ) source_root = self.store.source_root(project_id) if project["source"]["kind"] == "archive": source_file = project["source"]["files"][0] archive = provider.upload_source_archive( _confined_path(source_root, str(source_file["logical_path"])) ) source = provider.normalize_archive(archive) else: entrypoint, source_format = discover_gaussian_source_bundle(source_root) source = provider.upload_source_bundle( source_root, entrypoint=entrypoint, source_format=source_format, ) source_mesh_available = any( is_xgrids_source_mesh_path(member.logical_path) for member in source.members ) collision_profile = ( { "scene_type": project["scene_type"], "seed_position": [0.0, 0.0, 0.0], "capsule_height": 0.4, "capsule_radius": 0.4, "voxel_size": 0.05, "mesh_shape": "source", } if source_mesh_available else None ) request = { "schema_version": BUILD_REQUEST_SCHEMA, "idempotency_key": f"missioncore-{project_id}", "source": source.to_dict(), "outputs": { "preview_sog": True, "streamed_sog": True, "collision": source_mesh_available, }, "preview_lod": "coarsest", "collision_profile": collision_profile, } submitted = provider.submit_build(request) job_id = submitted.get("job_id") if not isinstance(job_id, str): raise SimulationProjectError("Gaussian Pipeline не вернул job id.") self._register_inflight_job(project_id, job_id, provider) self.store.update_processing( project_id, status="processing", provider_job_id=job_id, provider_job_created_at_utc=_optional_provider_timestamp( submitted.get("created_at_utc") ), provider_state=str(submitted.get("state") or "queued"), provider_state_started_at_utc=_optional_provider_timestamp( submitted.get("updated_at_utc") ), progress=submitted.get("progress"), bundle_sha256=source.bundle_sha256, ) deadline = time.monotonic() + PROVIDER_POLL_TIMEOUT_SECONDS while True: self._raise_if_cancelled(project_id) if time.monotonic() >= deadline: raise SimulationProjectError( "Gaussian Pipeline превысил лимит ожидания сборки." ) job = _retry_provider_unavailable(lambda: provider.get_job(job_id)) state = job.get("state") if not isinstance(state, str) or state not in PROVIDER_JOB_STATES: raise SimulationProjectError( "Gaussian Pipeline вернул неизвестное состояние сборки." ) self.store.update_processing( project_id, status="processing", provider_job_created_at_utc=_optional_provider_timestamp( job.get("created_at_utc") ), provider_state=str(state or "unknown"), provider_state_started_at_utc=_optional_provider_timestamp( job.get("updated_at_utc") ), progress=job.get("progress"), ) if state == "failed": error = job.get("error") message = error.get("message") if isinstance(error, dict) else None raise SimulationProjectError( str(message or "Gaussian Pipeline завершил сборку с ошибкой.") ) if state == "ready": break time.sleep(2.0) self.store.update_processing( project_id, status="importing", provider_state="ready", ) result = _retry_provider_unavailable(lambda: provider.get_result(job_id)) self._raise_if_cancelled(project_id) artifacts = _artifact_descriptors(result.get("artifacts")) artifacts_root = self.store.artifacts_root(project_id) for descriptor in artifacts: self._raise_if_cancelled(project_id) _retry_provider_unavailable( lambda descriptor=descriptor: provider.download_artifact( job_id, descriptor, _confined_path( artifacts_root, str(descriptor["logical_path"]), ), ) ) world_manifest = _world_manifest(project_id, artifacts) self.store.complete( project_id, result=result, artifacts=artifacts, world_manifest=world_manifest, ) except _SimulationProcessingCancelled: pass except (GaussianPipelineGatewayError, SimulationProjectError, OSError) as exc: with suppress(SimulationProjectError): self.store.fail(project_id, str(exc)) finally: with self._condition: self._inflight_jobs.pop(project_id, None) if provider is not None: provider.close() def delete(self, project_id: str) -> None: project = self.store.get(project_id) with self._condition: cancel = self._cancel_events.setdefault(project_id, threading.Event()) cancel.set() if project_id in self._queued_ids: self._queue = deque(item for item in self._queue if item != project_id) self._queued_ids.discard(project_id) job_id = self._inflight_jobs.pop( project_id, project["provider"].get("job_id"), ) self._condition.notify_all() if isinstance(job_id, str): provider = self.provider_factory() if provider is None: with suppress(SimulationProjectError): self.store.fail( project_id, "Gaussian Pipeline недоступен для удаления серверных артефактов.", ) raise SimulationProjectConflictError( "Gaussian Pipeline недоступен для удаления серверных артефактов." ) try: provider.delete_job(job_id) except GaussianPipelineGatewayError: with suppress(SimulationProjectError): self.store.fail( project_id, "Не удалось удалить активную сборку на Worker 006.", ) raise finally: provider.close() self.store.delete(project_id, allow_active=True) with self._condition: self._cancel_events.pop(project_id, None) self._condition.notify_all() def _raise_if_cancelled(self, project_id: str) -> None: with self._condition: event = self._cancel_events.get(project_id) if event is not None and event.is_set(): raise _SimulationProcessingCancelled(project_id) def _register_inflight_job( self, project_id: str, job_id: str, provider: GaussianPipelineGateway, ) -> None: with self._condition: event = self._cancel_events.get(project_id) cancelled = event is not None and event.is_set() if not cancelled: self._inflight_jobs[project_id] = job_id if cancelled: with suppress(GaussianPipelineGatewayError): provider.delete_job(job_id) raise _SimulationProcessingCancelled(project_id) def _retry_provider_unavailable(operation: Callable[[], _T]) -> _T: delay_seconds = 1.0 for attempt in range(PROVIDER_UNAVAILABLE_RETRY_LIMIT): try: return operation() except GaussianPipelineUnavailableError: if attempt + 1 >= PROVIDER_UNAVAILABLE_RETRY_LIMIT: raise time.sleep(delay_seconds) delay_seconds = min(delay_seconds * 2.0, 10.0) raise AssertionError("provider retry loop exhausted without returning or raising") def _artifact_descriptors(value: object) -> list[dict[str, Any]]: if not isinstance(value, list) or not value: raise SimulationProjectError("Gaussian Pipeline не вернул артефакты сцены.") descriptors: list[dict[str, Any]] = [] for item in value: if not isinstance(item, dict): raise SimulationProjectError("Gaussian artifact descriptor is invalid") logical_path = _logical_path(item.get("logical_path")) sha256 = item.get("sha256") byte_length = item.get("byte_length") media_type = item.get("media_type") role = item.get("role") if ( not isinstance(sha256, str) or re.fullmatch(r"[a-f0-9]{64}", sha256) is None or not isinstance(byte_length, int) or isinstance(byte_length, bool) or byte_length < 0 or not isinstance(media_type, str) or not isinstance(role, str) ): raise SimulationProjectError("Gaussian artifact fields are invalid") descriptors.append( { "role": role, "logical_path": logical_path, "media_type": media_type, "sha256": sha256, "byte_length": byte_length, } ) return descriptors def _world_manifest(project_id: str, artifacts: list[dict[str, Any]]) -> dict[str, Any]: def url_for(role: str) -> str | None: descriptor = next((item for item in artifacts if item["role"] == role), None) if descriptor is None: return None encoded = "/".join( quote(part, safe="") for part in str(descriptor["logical_path"]).split("/") ) return f"/api/v1/simulation-worlds/projects/{project_id}/artifacts/{encoded}" rover_collision_mesh_url = url_for("rover-collision-mesh") or url_for("rover-terrain-mesh") collision_mesh_url = rover_collision_mesh_url or url_for("collision-mesh") return { "schema_version": WORLD_MANIFEST_SCHEMA, "project_id": project_id, "visual": { "preview_sog_url": url_for("preview"), "streamed_sog_url": url_for("stream-manifest"), }, "collision": { "mesh_url": collision_mesh_url, "available": collision_mesh_url is not None, }, "transforms": { "world_from_visual": [1, 0, 0, 0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1], "world_from_collision": ( [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] if rover_collision_mesh_url is not None else [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1] ), }, } def _default_viewer_settings() -> dict[str, Any]: return { "schema_version": VIEWER_SETTINGS_SCHEMA, "quality": "high", "visual": {"rotation_degrees": {"x": 180.0, "y": 0.0, "z": 0.0}}, "collision": {"rotation_degrees": {"x": 0.0, "y": 0.0, "z": 0.0}}, "camera": { "invert_horizontal": True, "invert_vertical": False, }, "ugv": _default_ugv_settings(), } def _default_ugv_settings() -> dict[str, Any]: return { "preset_name": "UGV 100 кг", "mass_kg": 100.0, "dimensions_m": { "length": 1.0, "width": 0.8, "height": 0.4, "ground_clearance": 0.15, }, "max_speed_mps": 1.2, "max_turn_rate_degrees": 45.0, "invert_steering": False, } def _viewer_settings(value: object) -> dict[str, Any]: if not isinstance(value, dict) or set(value) != { "schema_version", "quality", "visual", "collision", "camera", "ugv", }: raise SimulationProjectError("simulation viewer settings contract is invalid") if value.get("schema_version") != VIEWER_SETTINGS_SCHEMA: raise SimulationProjectError("simulation viewer settings version is unsupported") quality = value.get("quality") if quality not in {"low", "medium", "high", "ultra", "maximum"}: raise SimulationProjectError("simulation viewer quality is invalid") def layer(name: str) -> dict[str, Any]: raw = value.get(name) if not isinstance(raw, dict) or set(raw) != {"rotation_degrees"}: raise SimulationProjectError(f"simulation {name} transform is invalid") raw_rotation = raw.get("rotation_degrees") if not isinstance(raw_rotation, dict) or set(raw_rotation) != {"x", "y", "z"}: raise SimulationProjectError(f"simulation {name} transform is invalid") rotation: dict[str, float] = {} for axis in ("x", "y", "z"): angle = raw_rotation.get(axis) if ( not isinstance(angle, (int, float)) or isinstance(angle, bool) or not math.isfinite(angle) or angle < -360.0 or angle > 360.0 ): raise SimulationProjectError(f"simulation {name} transform is invalid") rotation[axis] = float(angle) return {"rotation_degrees": rotation} camera = value.get("camera") if not isinstance(camera, dict) or set(camera) != {"invert_horizontal", "invert_vertical"}: raise SimulationProjectError("simulation camera settings are invalid") horizontal = camera.get("invert_horizontal") vertical = camera.get("invert_vertical") if not isinstance(horizontal, bool) or not isinstance(vertical, bool): raise SimulationProjectError("simulation camera settings are invalid") ugv = value.get("ugv") if not isinstance(ugv, dict) or set(ugv) != { "preset_name", "mass_kg", "dimensions_m", "max_speed_mps", "max_turn_rate_degrees", "invert_steering", }: raise SimulationProjectError("simulation UGV settings are invalid") preset_name = " ".join(str(ugv.get("preset_name", "")).split()) if not 1 <= len(preset_name) <= 80: raise SimulationProjectError("simulation UGV preset name is invalid") dimensions = ugv.get("dimensions_m") if not isinstance(dimensions, dict) or set(dimensions) != { "length", "width", "height", "ground_clearance", }: raise SimulationProjectError("simulation UGV dimensions are invalid") def bounded_number( raw: object, *, minimum: float, maximum: float, label: str, ) -> float: if ( not isinstance(raw, (int, float)) or isinstance(raw, bool) or not math.isfinite(raw) or raw < minimum or raw > maximum ): raise SimulationProjectError(f"simulation UGV {label} is invalid") return float(raw) mass_kg = bounded_number( ugv.get("mass_kg"), minimum=0.01, maximum=1_000_000_000, label="mass" ) length = bounded_number( dimensions.get("length"), minimum=0.01, maximum=1_000_000_000, label="length" ) width = bounded_number( dimensions.get("width"), minimum=0.01, maximum=1_000_000_000, label="width" ) height = bounded_number( dimensions.get("height"), minimum=0.07, maximum=1_000_000_000, label="height" ) ground_clearance = bounded_number( dimensions.get("ground_clearance"), minimum=0.01, maximum=1_000_000_000, label="ground clearance", ) if ground_clearance >= height - 0.05: raise SimulationProjectError("simulation UGV ground clearance exceeds its height") max_speed_mps = bounded_number( ugv.get("max_speed_mps"), minimum=0, maximum=1_000_000_000, label="maximum speed" ) max_turn_rate_degrees = bounded_number( ugv.get("max_turn_rate_degrees"), minimum=0, maximum=1_000_000_000, label="maximum turn rate", ) invert_steering = ugv.get("invert_steering") if not isinstance(invert_steering, bool): raise SimulationProjectError("simulation UGV steering inversion is invalid") return { "schema_version": VIEWER_SETTINGS_SCHEMA, "quality": quality, "visual": layer("visual"), "collision": layer("collision"), "camera": { "invert_horizontal": horizontal, "invert_vertical": vertical, }, "ugv": { "preset_name": preset_name, "mass_kg": mass_kg, "dimensions_m": { "length": length, "width": width, "height": height, "ground_clearance": ground_clearance, }, "max_speed_mps": max_speed_mps, "max_turn_rate_degrees": max_turn_rate_degrees, "invert_steering": invert_steering, }, } def _migrate_viewer_settings(value: object) -> dict[str, Any]: if not isinstance(value, dict): return _default_viewer_settings() if value.get("schema_version") == VIEWER_SETTINGS_SCHEMA: return _viewer_settings(value) if value.get("schema_version") == "missioncore.simulation-viewer-settings/v2": migrated_v2 = { **value, "schema_version": VIEWER_SETTINGS_SCHEMA, "ugv": _default_ugv_settings(), } try: return _viewer_settings(migrated_v2) except SimulationProjectError: return _default_viewer_settings() if value.get("schema_version") != "missioncore.simulation-viewer-settings/v1": return _default_viewer_settings() def legacy_layer(name: str) -> dict[str, Any]: raw = value.get(name) rotation = {"x": 0.0, "y": 0.0, "z": 0.0} if isinstance(raw, dict) and raw.get("inverted") is True: axis = raw.get("axis") if axis in rotation: rotation[axis] = 180.0 return {"rotation_degrees": rotation} camera = value.get("camera") if not isinstance(camera, dict): camera = {} migrated = { "schema_version": VIEWER_SETTINGS_SCHEMA, "quality": value.get("quality", "high"), "visual": legacy_layer("visual"), "collision": legacy_layer("collision"), "camera": { "invert_horizontal": camera.get("invert_horizontal", True), "invert_vertical": camera.get("invert_vertical", False), }, "ugv": _default_ugv_settings(), } try: return _viewer_settings(migrated) except SimulationProjectError: return _default_viewer_settings() def _project_name(value: str) -> str: normalized = " ".join(value.split()) if not 1 <= len(normalized) <= 120: raise SimulationProjectError("simulation project name is invalid") return normalized def _provider_timestamp(value: str, label: str) -> str: if PROVIDER_TIMESTAMP_PATTERN.fullmatch(value) is None: raise SimulationProjectError(f"simulation provider {label} timestamp is invalid") return value def _optional_provider_timestamp(value: object) -> str | None: if isinstance(value, str) and PROVIDER_TIMESTAMP_PATTERN.fullmatch(value) is not None: return value return None def _logical_path(value: object) -> str: if not isinstance(value, str) or not value or len(value) > 1024: raise SimulationProjectError("simulation logical path is invalid") if value.startswith("/") or "\\" in value or "\x00" in value: raise SimulationProjectError("simulation logical path is unsafe") parts = value.split("/") if any(part in {"", ".", ".."} for part in parts): raise SimulationProjectError("simulation logical path is unsafe") return value def _source_file(document: dict[str, Any], file_id: str) -> dict[str, Any]: if SOURCE_FILE_ID_PATTERN.fullmatch(file_id) is None: raise SimulationProjectNotFoundError("simulation source file is unavailable") source_file = next( (item for item in document["source"]["files"] if item.get("file_id") == file_id), None, ) if source_file is None: raise SimulationProjectNotFoundError("simulation source file is unavailable") return source_file def _confined_path(root: Path, logical_path: str) -> Path: safe = _logical_path(logical_path) candidate = root.joinpath(*safe.split("/")).resolve() resolved_root = root.resolve() if not candidate.is_relative_to(resolved_root): raise SimulationProjectError("simulation path escaped its project root") return candidate def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest()