feat: add Gaussian simulation workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-08-26 02:09:44 +03:00
parent b111406cf8
commit d943ec853c
22 changed files with 3422 additions and 17 deletions
+11
View File
@@ -48,6 +48,7 @@ from k1link.sessions import (
SessionRecordingPreparationManager,
SessionStore,
)
from k1link.simulation.projects import SimulationProjectService, SimulationProjectStore
from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router
from k1link.web.artifact_health_api import build_artifact_health_router
from k1link.web.compute_contour_api import build_compute_contour_router
@@ -153,6 +154,7 @@ from k1link.web.runtime_readiness import (
)
from k1link.web.session_api import build_session_router
from k1link.web.simulation_world_provider_api import build_simulation_world_provider_router
from k1link.web.simulation_projects_api import build_simulation_projects_router
from k1link.web.system_telemetry_api import build_system_telemetry_router
from k1link.web.viewer_diagnostics_api import build_viewer_diagnostics_router
@@ -201,6 +203,8 @@ plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
session_store = SessionStore(REPOSITORY_ROOT)
simulation_project_store = SimulationProjectStore(session_store.data_dir)
simulation_project_service = SimulationProjectService(simulation_project_store)
session_artifact_gateway = configured_artifact_gateway(session_store.data_dir)
lidar_local_surface_read_service = K1LocalSurfaceReadService(
session_store.data_dir / "lidar-read-cache"
@@ -478,6 +482,7 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
try:
configure_scanner_diagnostics(session_store.data_dir / "logs")
session_recording_preparation_manager.start()
simulation_project_service.recover_pending()
# Recovery is intentionally a one-shot startup phase. The archive
# helper owns a cross-process lease, while ordinary catalog requests
# only perform discovery and therefore never touch a live writer.
@@ -1310,6 +1315,12 @@ app.include_router(
)
)
app.include_router(build_simulation_world_provider_router())
app.include_router(
build_simulation_projects_router(
store=simulation_project_store,
service=simulation_project_service,
)
)
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
app.include_router(
build_viewer_diagnostics_router(
+215
View File
@@ -0,0 +1,215 @@
"""Mission Core API for durable Gaussian simulation projects."""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, Response
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field
from k1link.simulation.gaussian_pipeline_gateway import GaussianPipelineGatewayError
from k1link.simulation.projects import (
MAX_UPLOAD_CHUNK_BYTES,
PROJECT_SCHEMA,
SimulationProjectConflictError,
SimulationProjectError,
SimulationProjectNotFoundError,
SimulationProjectService,
SimulationProjectStore,
)
class SimulationSourceFileCreate(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
logical_path: str = Field(min_length=1, max_length=1024)
byte_length: int = Field(gt=0)
class SimulationProjectCreate(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
schema_version: Literal["missioncore.simulation-project-create/v1"]
name: str = Field(min_length=1, max_length=120)
scene_type: Literal["interior", "outdoor", "object"]
source_kind: Literal["archive", "folder"]
files: list[SimulationSourceFileCreate] = Field(min_length=1, max_length=10_000)
class SimulationProjectUpdate(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
schema_version: Literal["missioncore.simulation-project-update/v1"]
name: str = Field(min_length=1, max_length=120)
scene_type: Literal["interior", "outdoor", "object"]
class SimulationProjectDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: Literal["missioncore.simulation-project/v1"] = PROJECT_SCHEMA
project_id: str
name: str
scene_type: Literal["interior", "outdoor", "object"]
status: Literal["uploading", "queued", "processing", "importing", "ready", "failed"]
source: dict[str, Any]
provider: dict[str, Any]
artifacts: list[dict[str, Any]]
world_manifest: dict[str, Any] | None
error: str | None
created_at_utc: str
updated_at_utc: str
class SimulationProjectPage(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
schema_version: Literal["missioncore.simulation-project-page/v1"] = (
"missioncore.simulation-project-page/v1"
)
projects: list[SimulationProjectDocument]
def build_simulation_projects_router(
*,
store: SimulationProjectStore,
service: SimulationProjectService,
) -> APIRouter:
router = APIRouter(prefix="/api/v1/simulation-worlds", tags=["simulation-worlds"])
@router.get("/projects", response_model=SimulationProjectPage)
def list_projects() -> SimulationProjectPage:
return SimulationProjectPage(projects=store.list())
@router.post("/projects", response_model=SimulationProjectDocument, status_code=201)
def create_project(request: SimulationProjectCreate) -> dict[str, Any]:
try:
return store.create(
name=request.name,
scene_type=request.scene_type,
source_kind=request.source_kind,
files=[item.model_dump() for item in request.files],
)
except SimulationProjectError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/projects/{project_id}", response_model=SimulationProjectDocument)
def get_project(project_id: str) -> dict[str, Any]:
try:
return store.get(project_id)
except SimulationProjectNotFoundError as exc:
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
@router.patch("/projects/{project_id}", response_model=SimulationProjectDocument)
def update_project(
project_id: str,
request: SimulationProjectUpdate,
) -> dict[str, Any]:
try:
return store.update_metadata(
project_id,
name=request.name,
scene_type=request.scene_type,
)
except SimulationProjectNotFoundError as exc:
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
except SimulationProjectError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.head("/projects/{project_id}/source/{file_id}")
def source_upload_state(project_id: str, file_id: str) -> Response:
try:
offset, byte_length = store.upload_state(project_id, file_id)
return Response(status_code=204, headers={
"Upload-Offset": str(offset),
"Upload-Length": str(byte_length),
"Cache-Control": "no-store",
})
except SimulationProjectNotFoundError as exc:
raise HTTPException(status_code=404, detail="Исходный файл не найден.") from exc
@router.patch("/projects/{project_id}/source/{file_id}")
async def append_source_upload(
project_id: str,
file_id: str,
request: Request,
upload_offset: str | None = Header(default=None, alias="Upload-Offset"),
) -> Response:
if upload_offset is None or not upload_offset.isdigit():
raise HTTPException(status_code=400, detail="Некорректное смещение загрузки.")
content_length = request.headers.get("content-length")
if (
content_length is None
or not content_length.isdigit()
or not 0 < int(content_length) <= MAX_UPLOAD_CHUNK_BYTES
):
raise HTTPException(status_code=413, detail="Некорректный размер блока загрузки.")
payload = await request.body()
if len(payload) != int(content_length):
raise HTTPException(status_code=400, detail="Неполный блок загрузки.")
try:
project = store.append_upload(
project_id,
file_id,
offset=int(upload_offset),
payload=payload,
)
source_file = next(
item for item in project["source"]["files"] if item["file_id"] == file_id
)
return Response(status_code=204, headers={
"Upload-Offset": str(source_file["uploaded_bytes"]),
"Upload-Length": str(source_file["byte_length"]),
"Cache-Control": "no-store",
})
except SimulationProjectNotFoundError as exc:
raise HTTPException(status_code=404, detail="Исходный файл не найден.") from exc
except SimulationProjectConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except SimulationProjectError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/projects/{project_id}/build", response_model=SimulationProjectDocument)
def build_project(
project_id: str,
background_tasks: BackgroundTasks,
) -> dict[str, Any]:
try:
project = store.begin_build(project_id)
background_tasks.add_task(service.process, project_id)
return project
except SimulationProjectNotFoundError as exc:
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
except SimulationProjectConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.delete("/projects/{project_id}", status_code=204)
def delete_project(project_id: str) -> Response:
try:
service.delete(project_id)
return Response(status_code=204, headers={"Cache-Control": "no-store"})
except SimulationProjectNotFoundError as exc:
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
except SimulationProjectConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except GaussianPipelineGatewayError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.get("/projects/{project_id}/artifacts/{logical_path:path}")
def get_artifact(project_id: str, logical_path: str) -> FileResponse:
try:
filename, descriptor = store.artifact_path(project_id, logical_path)
return FileResponse(
filename,
media_type=str(descriptor["media_type"]),
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"ETag": f'"sha256-{descriptor["sha256"]}"',
"X-Content-Type-Options": "nosniff",
},
)
except SimulationProjectNotFoundError as exc:
raise HTTPException(status_code=404, detail="Артефакт сцены не найден.") from exc
return router