feat(simulation): add Gaussian UGV runtime pipeline

This commit is contained in:
DCCONSTRUCTIONS
2026-08-28 11:51:48 +03:00
parent 30080c51aa
commit 2ccf172319
21 changed files with 4685 additions and 628 deletions
+276 -34
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import json
from pathlib import Path
from threading import Event
from typing import Any
import pytest
@@ -14,6 +16,7 @@ from k1link.simulation.gaussian_pipeline_gateway import (
)
from k1link.simulation.projects import (
SimulationProjectConflictError,
SimulationProjectNotFoundError,
SimulationProjectService,
SimulationProjectStore,
_world_manifest,
@@ -29,6 +32,22 @@ def _folder_files() -> list[dict[str, object]]:
]
def _default_ugv_settings() -> dict[str, object]:
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 _upload_all(store: SimulationProjectStore, project: dict[str, Any]) -> None:
payloads = {
"export/scene.lcc": b'{"fileType":"Portable"}',
@@ -44,6 +63,19 @@ def _upload_all(store: SimulationProjectStore, project: dict[str, Any]) -> None:
)
def test_store_accepts_four_gib_archive_without_allocating_payload(tmp_path: Path) -> None:
store = SimulationProjectStore(tmp_path)
project = store.create(
name="Four GiB archive",
scene_type="outdoor",
source_kind="archive",
files=[{"logical_path": "four-gib.7z", "byte_length": 4 * 1024**3}],
)
assert project["status"] == "uploading"
assert project["source"]["total_byte_length"] == 4 * 1024**3
def test_store_persists_resumable_folder_upload_and_state_transitions(tmp_path: Path) -> None:
store = SimulationProjectStore(tmp_path)
project = store.create(
@@ -86,10 +118,43 @@ def test_store_persists_resumable_folder_upload_and_state_transitions(tmp_path:
)
queued = store.begin_build(project["project_id"])
assert queued["status"] == "queued"
assert queued["provider"]["job_created_at_utc"] is None
assert queued["provider"]["state_started_at_utc"] is None
with pytest.raises(SimulationProjectConflictError, match="active"):
store.delete(project["project_id"])
def test_store_preserves_provider_job_and_stage_timing(tmp_path: Path) -> None:
store = SimulationProjectStore(tmp_path)
project = store.create(
name="Timed scene",
scene_type="outdoor",
source_kind="folder",
files=_folder_files(),
)
_upload_all(store, project)
store.begin_build(project["project_id"])
first = store.update_processing(
project["project_id"],
status="processing",
provider_job_id="gsp-20260826000000-deadbeef",
provider_job_created_at_utc="2026-08-26T09:45:14.000Z",
provider_state="building_streamed_sog",
provider_state_started_at_utc="2026-08-26T09:48:20.000Z",
)
repeated = store.update_processing(
project["project_id"],
status="processing",
provider_state="building_streamed_sog",
provider_state_started_at_utc="2026-08-26T09:55:00.000Z",
)
assert first["provider"]["job_created_at_utc"] == "2026-08-26T09:45:14.000Z"
assert first["provider"]["state_started_at_utc"] == "2026-08-26T09:48:20.000Z"
assert repeated["provider"]["state_started_at_utc"] == "2026-08-26T09:48:20.000Z"
class _ReadyProvider:
def __init__(self) -> None:
self.deleted: list[str] = []
@@ -128,6 +193,8 @@ class _ReadyProvider:
"schema_version": "gaussian-pipeline.job/v1",
"job_id": "gsp-20260826000000-deadbeef",
"state": "queued",
"created_at_utc": "2026-08-26T09:45:14.000Z",
"updated_at_utc": "2026-08-26T09:45:14.000Z",
}
def get_job(self, _job_id: str) -> dict[str, object]:
@@ -135,6 +202,8 @@ class _ReadyProvider:
"schema_version": "gaussian-pipeline.job/v1",
"job_id": "gsp-20260826000000-deadbeef",
"state": "ready",
"created_at_utc": "2026-08-26T09:45:14.000Z",
"updated_at_utc": "2026-08-26T10:26:57.000Z",
"progress": {"completed_steps": 4, "total_steps": 4},
}
@@ -181,7 +250,7 @@ class _ReadyProvider:
return None
def test_service_materializes_world_manifest_and_deletes_both_copies(tmp_path: Path) -> None:
def test_service_builds_visual_world_without_automatic_collision(tmp_path: Path) -> None:
store = SimulationProjectStore(tmp_path)
project = store.create(
name="MOJJOKER",
@@ -197,13 +266,44 @@ def test_service_materializes_world_manifest_and_deletes_both_copies(tmp_path: P
ready = store.get(project["project_id"])
assert ready["status"] == "ready"
assert ready["provider"]["job_created_at_utc"] == "2026-08-26T09:45:14.000Z"
assert ready["world_manifest"]["visual"]["preview_sog_url"].endswith("/preview.sog")
assert ready["world_manifest"]["collision"]["available"] is False
assert ready["world_manifest"]["transforms"]["world_from_visual"] == [
1, 0, 0, 0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1,
1,
0,
0,
0,
0,
-1,
0,
0,
0,
0,
-1,
0,
0,
0,
0,
1,
]
assert ready["world_manifest"]["transforms"]["world_from_collision"] == [
-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1,
-1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
-1,
0,
0,
0,
0,
1,
]
assert provider.upload_calls == 1
assert provider.submit_calls == 1
@@ -211,20 +311,70 @@ def test_service_materializes_world_manifest_and_deletes_both_copies(tmp_path: P
assert provider.submitted_document["outputs"] == {
"preview_sog": True,
"streamed_sog": True,
"collision": True,
}
assert provider.submitted_document["collision_profile"] == {
"scene_type": "interior",
"seed_position": [0, 1, 0],
"capsule_height": 1.6,
"capsule_radius": 0.2,
"voxel_size": 0.1,
"mesh_shape": "smooth",
"collision": False,
}
assert provider.submitted_document["collision_profile"] is None
service.delete(project["project_id"])
assert provider.deleted == ["gsp-20260826000000-deadbeef"]
def test_service_queue_processes_projects_strictly_one_at_a_time(tmp_path: Path) -> None:
store = SimulationProjectStore(tmp_path)
projects: list[dict[str, Any]] = []
for name in ("First scene", "Second scene"):
project = store.create(
name=name,
scene_type="outdoor",
source_kind="folder",
files=_folder_files(),
)
_upload_all(store, project)
projects.append(store.begin_build(project["project_id"]))
service = SimulationProjectService(store, provider_factory=lambda: None)
first_started = Event()
release_first = Event()
second_started = Event()
completed = Event()
order: list[str] = []
def process(project_id: str) -> None:
order.append(project_id)
if project_id == projects[0]["project_id"]:
first_started.set()
assert release_first.wait(1.0)
else:
second_started.set()
completed.set()
service.process = process # type: ignore[method-assign]
service.enqueue(projects[0]["project_id"])
assert first_started.wait(1.0)
service.enqueue(projects[1]["project_id"])
assert not second_started.wait(0.05)
release_first.set()
assert completed.wait(1.0)
assert order == [projects[0]["project_id"], projects[1]["project_id"]]
def test_service_deletes_a_queued_project_before_worker_submission(tmp_path: Path) -> None:
store = SimulationProjectStore(tmp_path)
project = store.create(
name="Queued scene",
scene_type="outdoor",
source_kind="folder",
files=_folder_files(),
)
_upload_all(store, project)
store.begin_build(project["project_id"])
service = SimulationProjectService(store, provider_factory=lambda: None)
service.delete(project["project_id"])
with pytest.raises(SimulationProjectNotFoundError, match="unavailable"):
store.get(project["project_id"])
def test_world_manifest_prefers_combined_rover_collision_world() -> None:
manifest = _world_manifest(
"sim-" + "a" * 32,
@@ -245,9 +395,25 @@ def test_world_manifest_prefers_combined_rover_collision_world() -> None:
)
assert manifest["collision"]["available"] is True
assert manifest["collision"]["mesh_url"].endswith(
"/artifacts/rover/scene.collision.glb"
)
assert manifest["collision"]["mesh_url"].endswith("/artifacts/rover/scene.collision.glb")
assert manifest["transforms"]["world_from_collision"] == [
1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
1,
]
def test_service_resumes_a_persisted_provider_job_without_reupload(tmp_path: Path) -> None:
@@ -412,11 +578,12 @@ def test_ready_project_rebuilds_from_retained_source_and_preserves_viewer_settin
) # type: ignore[arg-type]
service.process(project["project_id"])
settings = {
"schema_version": "missioncore.simulation-viewer-settings/v1",
"schema_version": "missioncore.simulation-viewer-settings/v3",
"quality": "medium",
"visual": {"inverted": True, "axis": "z"},
"collision": {"inverted": True, "axis": "y"},
"visual": {"rotation_degrees": {"x": 0, "y": 0, "z": 90}},
"collision": {"rotation_degrees": {"x": 0, "y": -90, "z": 0}},
"camera": {"invert_horizontal": False, "invert_vertical": True},
"ugv": _default_ugv_settings(),
}
store.update_viewer_settings(project["project_id"], settings)
@@ -434,21 +601,25 @@ def test_api_exposes_same_origin_resumable_upload_contract(tmp_path: Path) -> No
app = FastAPI()
app.include_router(build_simulation_projects_router(store=store, service=service))
client = TestClient(app)
created = client.post("/api/v1/simulation-worlds/projects", json={
"schema_version": "missioncore.simulation-project-create/v1",
"name": "Archive scene",
"scene_type": "outdoor",
"source_kind": "archive",
"files": [{"logical_path": "scan.rar", "byte_length": 6}],
})
created = client.post(
"/api/v1/simulation-worlds/projects",
json={
"schema_version": "missioncore.simulation-project-create/v1",
"name": "Archive scene",
"scene_type": "outdoor",
"source_kind": "archive",
"files": [{"logical_path": "scan.rar", "byte_length": 6}],
},
)
assert created.status_code == 201
project = created.json()
assert project["viewer_settings"] == {
"schema_version": "missioncore.simulation-viewer-settings/v1",
"schema_version": "missioncore.simulation-viewer-settings/v3",
"quality": "high",
"visual": {"inverted": True, "axis": "x"},
"collision": {"inverted": True, "axis": "y"},
"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(),
}
source_file = project["source"]["files"][0]
upload_url = (
@@ -467,11 +638,18 @@ def test_api_exposes_same_origin_resumable_upload_contract(tmp_path: Path) -> No
assert catalog["projects"][0]["project_id"] == project["project_id"]
settings = {
"schema_version": "missioncore.simulation-viewer-settings/v1",
"schema_version": "missioncore.simulation-viewer-settings/v3",
"quality": "medium",
"visual": {"inverted": True, "axis": "z"},
"collision": {"inverted": False, "axis": "x"},
"visual": {"rotation_degrees": {"x": 0, "y": 0, "z": 90}},
"collision": {"rotation_degrees": {"x": -90, "y": 0, "z": 0}},
"camera": {"invert_horizontal": False, "invert_vertical": True},
"ugv": {
**_default_ugv_settings(),
"mass_kg": 1_000_000_000,
"max_speed_mps": 250.0,
"max_turn_rate_degrees": 720.0,
"invert_steering": True,
},
}
saved = client.put(
f"/api/v1/simulation-worlds/projects/{project['project_id']}/viewer-settings",
@@ -479,7 +657,71 @@ def test_api_exposes_same_origin_resumable_upload_contract(tmp_path: Path) -> No
)
assert saved.status_code == 200
assert saved.json()["viewer_settings"] == settings
persisted = client.get(
f"/api/v1/simulation-worlds/projects/{project['project_id']}"
).json()
persisted = client.get(f"/api/v1/simulation-worlds/projects/{project['project_id']}").json()
assert persisted["viewer_settings"] == settings
def test_catalog_migrates_legacy_layer_inversions_to_euler_rotations(tmp_path: Path) -> None:
store = SimulationProjectStore(tmp_path)
project = store.create(
name="Legacy scene",
scene_type="outdoor",
source_kind="folder",
files=_folder_files(),
)
project_path = (
tmp_path / "simulation-worlds" / "projects" / project["project_id"] / "project.json"
)
document = json.loads(project_path.read_text(encoding="utf-8"))
document["viewer_settings"] = {
"schema_version": "missioncore.simulation-viewer-settings/v1",
"quality": "ultra",
"visual": {"inverted": True, "axis": "z"},
"collision": {"inverted": False, "axis": "y"},
"camera": {"invert_horizontal": False, "invert_vertical": True},
}
project_path.write_text(json.dumps(document), encoding="utf-8")
migrated = store.get(project["project_id"])["viewer_settings"]
assert migrated == {
"schema_version": "missioncore.simulation-viewer-settings/v3",
"quality": "ultra",
"visual": {"rotation_degrees": {"x": 0.0, "y": 0.0, "z": 180.0}},
"collision": {"rotation_degrees": {"x": 0.0, "y": 0.0, "z": 0.0}},
"camera": {"invert_horizontal": False, "invert_vertical": True},
"ugv": _default_ugv_settings(),
}
def test_catalog_adds_default_ugv_preset_to_v2_viewer_settings(tmp_path: Path) -> None:
store = SimulationProjectStore(tmp_path)
project = store.create(
name="Current scene",
scene_type="outdoor",
source_kind="folder",
files=_folder_files(),
)
project_path = (
tmp_path / "simulation-worlds" / "projects" / project["project_id"] / "project.json"
)
document = json.loads(project_path.read_text(encoding="utf-8"))
document["viewer_settings"] = {
"schema_version": "missioncore.simulation-viewer-settings/v2",
"quality": "medium",
"visual": {"rotation_degrees": {"x": -180.0, "y": 0.0, "z": 0.0}},
"collision": {"rotation_degrees": {"x": -90.0, "y": 0.0, "z": 0.0}},
"camera": {"invert_horizontal": False, "invert_vertical": True},
}
project_path.write_text(json.dumps(document), encoding="utf-8")
migrated = store.get(project["project_id"])["viewer_settings"]
assert migrated == {
"schema_version": "missioncore.simulation-viewer-settings/v3",
"quality": "medium",
"visual": {"rotation_degrees": {"x": -180.0, "y": 0.0, "z": 0.0}},
"collision": {"rotation_degrees": {"x": -90.0, "y": 0.0, "z": 0.0}},
"camera": {"invert_horizontal": False, "invert_vertical": True},
"ugv": _default_ugv_settings(),
}