feat(simulation): optimize and persist collision viewing
This commit is contained in:
@@ -28,10 +28,18 @@ class MissionCoreWatchdogError(RuntimeError):
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MissionCoreWatchdogPolicy:
|
||||
startup_grace_seconds: float = 45.0
|
||||
# Cold startup on the 18 GB control laptop includes validating the durable
|
||||
# recorded-evidence catalog and can exceed two minutes under storage load.
|
||||
# This applies only before the first probe; steady-state failures retain the
|
||||
# much tighter bounded policy below.
|
||||
startup_grace_seconds: float = 300.0
|
||||
probe_interval_seconds: float = 2.0
|
||||
probe_timeout_seconds: float = 1.0
|
||||
consecutive_failure_limit: int = 3
|
||||
# Source hashing and resumable multi-gigabyte uploads can briefly delay the
|
||||
# synchronous readiness projection without making the service unhealthy.
|
||||
# Six bounded five-second probes still fail closed, but do not turn normal
|
||||
# storage pressure into a restart loop that discards upload progress.
|
||||
probe_timeout_seconds: float = 5.0
|
||||
consecutive_failure_limit: int = 6
|
||||
graceful_shutdown_seconds: float = 12.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
|
||||
@@ -26,6 +26,7 @@ from k1link.simulation.gaussian_pipeline_gateway import (
|
||||
|
||||
PROJECT_SCHEMA: Final = "missioncore.simulation-project/v1"
|
||||
WORLD_MANIFEST_SCHEMA: Final = "missioncore.simulation-world-manifest/v1"
|
||||
VIEWER_SETTINGS_SCHEMA: Final = "missioncore.simulation-viewer-settings/v1"
|
||||
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}$")
|
||||
@@ -142,6 +143,7 @@ class SimulationProjectStore:
|
||||
},
|
||||
"artifacts": [],
|
||||
"world_manifest": None,
|
||||
"viewer_settings": _default_viewer_settings(),
|
||||
"error": None,
|
||||
"created_at_utc": now,
|
||||
"updated_at_utc": now,
|
||||
@@ -189,6 +191,19 @@ class SimulationProjectStore:
|
||||
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)
|
||||
@@ -239,7 +254,7 @@ class SimulationProjectStore:
|
||||
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"}:
|
||||
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"])
|
||||
@@ -371,6 +386,7 @@ class SimulationProjectStore:
|
||||
or document.get("project_id") != project_id
|
||||
):
|
||||
raise SimulationProjectError("persisted simulation project identity is invalid")
|
||||
document.setdefault("viewer_settings", _default_viewer_settings())
|
||||
return document
|
||||
|
||||
def _write(self, document: dict[str, Any]) -> None:
|
||||
@@ -421,7 +437,7 @@ class SimulationProjectService:
|
||||
|
||||
def begin_build(self, project_id: str) -> dict[str, Any]:
|
||||
project = self.store.get(project_id)
|
||||
if project.get("status") == "failed":
|
||||
if project.get("status") in {"failed", "ready"}:
|
||||
job_id = project["provider"].get("job_id")
|
||||
if isinstance(job_id, str):
|
||||
provider = self.provider_factory()
|
||||
@@ -616,7 +632,7 @@ def _world_manifest(project_id: str, artifacts: list[dict[str, Any]]) -> dict[st
|
||||
},
|
||||
"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],
|
||||
"world_from_collision": [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -628,11 +644,66 @@ def _collision_profile(scene_type: str) -> dict[str, Any]:
|
||||
"seed_position": [0, 1, 0],
|
||||
"capsule_height": 1.6,
|
||||
"capsule_radius": 0.2,
|
||||
"voxel_size": 0.05,
|
||||
"voxel_size": 0.2 if scene_type == "outdoor" else 0.1,
|
||||
"mesh_shape": "smooth",
|
||||
}
|
||||
|
||||
|
||||
def _default_viewer_settings() -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": VIEWER_SETTINGS_SCHEMA,
|
||||
"quality": "high",
|
||||
"visual": {"inverted": True, "axis": "x"},
|
||||
"collision": {"inverted": True, "axis": "y"},
|
||||
"camera": {
|
||||
"invert_horizontal": True,
|
||||
"invert_vertical": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _viewer_settings(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or set(value) != {
|
||||
"schema_version", "quality", "visual", "collision", "camera"
|
||||
}:
|
||||
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) != {"inverted", "axis"}:
|
||||
raise SimulationProjectError(f"simulation {name} transform is invalid")
|
||||
inverted = raw.get("inverted")
|
||||
axis = raw.get("axis")
|
||||
if not isinstance(inverted, bool) or axis not in {"x", "y", "z"}:
|
||||
raise SimulationProjectError(f"simulation {name} transform is invalid")
|
||||
return {"inverted": inverted, "axis": axis}
|
||||
|
||||
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")
|
||||
return {
|
||||
"schema_version": VIEWER_SETTINGS_SCHEMA,
|
||||
"quality": quality,
|
||||
"visual": layer("visual"),
|
||||
"collision": layer("collision"),
|
||||
"camera": {
|
||||
"invert_horizontal": horizontal,
|
||||
"invert_vertical": vertical,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _project_name(value: str) -> str:
|
||||
normalized = " ".join(value.split())
|
||||
if not 1 <= len(normalized) <= 120:
|
||||
|
||||
@@ -12,6 +12,7 @@ from k1link.simulation.gaussian_pipeline_gateway import GaussianPipelineGatewayE
|
||||
from k1link.simulation.projects import (
|
||||
MAX_UPLOAD_CHUNK_BYTES,
|
||||
PROJECT_SCHEMA,
|
||||
VIEWER_SETTINGS_SCHEMA,
|
||||
SimulationProjectConflictError,
|
||||
SimulationProjectError,
|
||||
SimulationProjectNotFoundError,
|
||||
@@ -45,6 +46,30 @@ class SimulationProjectUpdate(BaseModel):
|
||||
scene_type: Literal["interior", "outdoor", "object"]
|
||||
|
||||
|
||||
class SimulationLayerViewerSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
inverted: bool
|
||||
axis: Literal["x", "y", "z"]
|
||||
|
||||
|
||||
class SimulationCameraViewerSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
invert_horizontal: bool
|
||||
invert_vertical: bool
|
||||
|
||||
|
||||
class SimulationViewerSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
schema_version: Literal["missioncore.simulation-viewer-settings/v1"] = VIEWER_SETTINGS_SCHEMA
|
||||
quality: Literal["low", "medium", "high", "ultra", "maximum"]
|
||||
visual: SimulationLayerViewerSettings
|
||||
collision: SimulationLayerViewerSettings
|
||||
camera: SimulationCameraViewerSettings
|
||||
|
||||
|
||||
class SimulationProjectDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -57,6 +82,7 @@ class SimulationProjectDocument(BaseModel):
|
||||
provider: dict[str, Any]
|
||||
artifacts: list[dict[str, Any]]
|
||||
world_manifest: dict[str, Any] | None
|
||||
viewer_settings: SimulationViewerSettings
|
||||
error: str | None
|
||||
created_at_utc: str
|
||||
updated_at_utc: str
|
||||
@@ -117,6 +143,21 @@ def build_simulation_projects_router(
|
||||
except SimulationProjectError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.put(
|
||||
"/projects/{project_id}/viewer-settings",
|
||||
response_model=SimulationProjectDocument,
|
||||
)
|
||||
def update_viewer_settings(
|
||||
project_id: str,
|
||||
request: SimulationViewerSettings,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return store.update_viewer_settings(project_id, request.model_dump())
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user