feat(simulation): auto-build source mesh collision
This commit is contained in:
@@ -58,12 +58,10 @@ Mission Core backend
|
||||
DC Gaussian Pipeline
|
||||
├─ TUS bundle or archive admission
|
||||
├─ secure ZIP/RAR/7z normalization
|
||||
└─ native Vulkan SplatTransform visual build on Worker 006
|
||||
|
||||
Optional physical-mesh pipeline (separate job; experimental)
|
||||
├─ source-mesh discovery or explicit mesh generation
|
||||
├─ physics-oriented cleanup and geometry budget
|
||||
└─ compressed, digest-bound publication
|
||||
├─ native Vulkan SplatTransform visual build on Worker 006
|
||||
└─ optional Mesh_Files/*.ply source-collision path
|
||||
├─ conservative small-hole repair and topology audit
|
||||
└─ mandatory Draco, digest-bound publication
|
||||
```
|
||||
|
||||
The browser never receives the Worker token. Mission Core does not embed archive-format behavior
|
||||
@@ -81,18 +79,18 @@ SplatTransform GPU command through a confined filesystem spool to the pinned nat
|
||||
on the RTX 4090. It does not install into or share the Python, CUDA, Triton or computer-vision
|
||||
environments on the host.
|
||||
|
||||
Project processing is split at a durable product boundary. The mandatory first job builds only the
|
||||
preview and streamed Gaussian assets required for visual inspection. It never generates collision
|
||||
geometry, so a location can reach `ready` without paying the time, GPU-memory and storage cost of a
|
||||
physical mesh.
|
||||
Project processing never generates collision geometry from Gaussian data by default. When the
|
||||
normalized source contains exactly one PLY below `Mesh_Files`, the same queued build automatically
|
||||
selects the provider's `source` collision profile. The original indexed mesh is retained; only
|
||||
strictly admitted small internal boundary loops on approximately planar Z-up surfaces receive new
|
||||
triangles. Vertices and existing faces are never moved, welded, smoothed, simplified or remeshed.
|
||||
The derived GLB then passes the mandatory Draco publication gate and carries a separate repair
|
||||
report. A source with no admitted PLY remains visual-only and still reaches `ready` normally.
|
||||
|
||||
Physical geometry is an optional second job started only after the visual world is ready. Its first
|
||||
candidate source is a mesh already present in the uploaded export (for example a PLY in
|
||||
`Mesh_Files`); generation from the Gaussian cloud is a fallback experiment, not the default path.
|
||||
The second-stage contract, cleanup method and acceptance gates are intentionally separate from the
|
||||
visual build. When that stage publishes a GLB, simplification still controls decoded physics cost
|
||||
and Draco controls transfer/storage bytes; compression is not treated as a replacement for a
|
||||
physics mesh budget.
|
||||
The conservative repair rejects outer borders, branched boundaries, large or non-planar loops,
|
||||
vertical openings, mixed orientation and failed/self-intersecting triangulations. Ambiguous holes
|
||||
remain open and are counted in the report instead of being silently capped. Explicit mesh
|
||||
generation from Gaussian data remains a later experiment, not a fallback in this ingestion path.
|
||||
|
||||
Visual and collision layers retain independent X/Y/Z correction settings, while PlayCanvas world,
|
||||
camera, navigation and future physics stay in the canonical Y-up coordinate system. Quality, both
|
||||
@@ -115,9 +113,10 @@ Primary implementation references:
|
||||
- encrypted, linked, traversing, duplicate and over-limit archive entries fail closed;
|
||||
- project status is durable and reflects provider state without fabricated percentages;
|
||||
- ready artifacts are imported digest-bound and served from Mission Core same-origin URLs;
|
||||
- the mandatory build requests preview and streamed Gaussian outputs with collision disabled;
|
||||
- a visual project reaches ready state without a collision artifact;
|
||||
- physical mesh preparation is a separate explicit job and never blocks visual inspection;
|
||||
- the build always requests preview and streamed Gaussian outputs;
|
||||
- exactly one `Mesh_Files/*.ply` automatically selects repaired source-mesh collision plus Draco;
|
||||
- archives without that mesh remain visual-only and reach ready without a collision artifact;
|
||||
- generated Gaussian/voxel collision is never used as an implicit fallback;
|
||||
- edit changes project metadata; delete removes both the Mission Core project and terminal provider
|
||||
job;
|
||||
- a ready project mounts direct PlayCanvas Engine and loads Streamed SOG with preview fallback;
|
||||
|
||||
@@ -767,6 +767,24 @@ def _discover_bundle_members(root: Path, entrypoint: str, source_format: str) ->
|
||||
return _logical_path(logical, "descriptor member")
|
||||
|
||||
members = {entrypoint}
|
||||
source_meshes: list[str] = []
|
||||
for candidate in root.rglob("*"):
|
||||
if candidate.is_symlink():
|
||||
raise GaussianPipelineIntegrityError(
|
||||
"Gaussian source bundle contains a symlink"
|
||||
)
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
logical_path = candidate.relative_to(root).as_posix()
|
||||
if is_xgrids_source_mesh_path(logical_path):
|
||||
source_meshes.append(logical_path)
|
||||
source_meshes.sort(key=lambda value: value.encode("utf-8"))
|
||||
if len(source_meshes) > 1:
|
||||
raise GaussianPipelineIntegrityError(
|
||||
"Gaussian source bundle must contain at most one PLY mesh inside Mesh_Files"
|
||||
)
|
||||
if source_meshes:
|
||||
members.add(source_meshes[0])
|
||||
if source_format == "lcc":
|
||||
members.update({related("index.bin"), related("data.bin")})
|
||||
file_type = document.get("fileType")
|
||||
@@ -810,6 +828,11 @@ def _discover_bundle_members(root: Path, entrypoint: str, source_format: str) ->
|
||||
return members
|
||||
|
||||
|
||||
def is_xgrids_source_mesh_path(logical_path: str) -> bool:
|
||||
parts = logical_path.lower().replace("\\", "/").split("/")
|
||||
return "mesh_files" in parts and parts[-1].endswith(".ply")
|
||||
|
||||
|
||||
def _logical_path(value: str, label: str) -> str:
|
||||
if (
|
||||
not value
|
||||
|
||||
@@ -26,6 +26,7 @@ from k1link.simulation.gaussian_pipeline_gateway import (
|
||||
GaussianPipelineUnavailableError,
|
||||
configured_gaussian_pipeline_gateway,
|
||||
discover_gaussian_source_bundle,
|
||||
is_xgrids_source_mesh_path,
|
||||
)
|
||||
|
||||
PROJECT_SCHEMA: Final = "missioncore.simulation-project/v1"
|
||||
@@ -610,6 +611,22 @@ class SimulationProjectService:
|
||||
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}",
|
||||
@@ -617,10 +634,10 @@ class SimulationProjectService:
|
||||
"outputs": {
|
||||
"preview_sog": True,
|
||||
"streamed_sog": True,
|
||||
"collision": False,
|
||||
"collision": source_mesh_available,
|
||||
},
|
||||
"preview_lod": "coarsest",
|
||||
"collision_profile": None,
|
||||
"collision_profile": collision_profile,
|
||||
}
|
||||
submitted = provider.submit_build(request)
|
||||
job_id = submitted.get("job_id")
|
||||
|
||||
@@ -117,6 +117,28 @@ def test_gateway_uploads_lcc_bundle_with_tus_and_reads_provider_contract(tmp_pat
|
||||
assert descriptor.total_byte_length == sum(member.byte_length for member in descriptor.members)
|
||||
|
||||
|
||||
def test_folder_discovery_retains_one_nested_xgrids_source_mesh(tmp_path: Path) -> None:
|
||||
root = tmp_path / "export"
|
||||
scene = root / "LCC_Results"
|
||||
mesh = root / "Mesh_Files"
|
||||
scene.mkdir(parents=True)
|
||||
mesh.mkdir()
|
||||
(scene / "scan.lcc").write_text(
|
||||
json.dumps({"fileType": "Portable"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(scene / "index.bin").write_bytes(b"index")
|
||||
(scene / "data.bin").write_bytes(b"data")
|
||||
(mesh / "scan.ply").write_bytes(b"ply")
|
||||
|
||||
members = _discover_bundle_members(root, "LCC_Results/scan.lcc", "lcc")
|
||||
|
||||
assert "Mesh_Files/scan.ply" in members
|
||||
(mesh / "duplicate.ply").write_bytes(b"ply")
|
||||
with pytest.raises(GaussianPipelineIntegrityError, match="at most one"):
|
||||
_discover_bundle_members(root, "LCC_Results/scan.lcc", "lcc")
|
||||
|
||||
|
||||
def test_gateway_uploads_and_normalizes_archive_with_tus(tmp_path: Path) -> None:
|
||||
archive_bytes = b"portable-archive"
|
||||
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
|
||||
|
||||
@@ -156,11 +156,12 @@ def test_store_preserves_provider_job_and_stage_timing(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
class _ReadyProvider:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *, source_mesh: bool = False) -> None:
|
||||
self.deleted: list[str] = []
|
||||
self.upload_calls = 0
|
||||
self.submit_calls = 0
|
||||
self.submitted_document: dict[str, object] | None = None
|
||||
self.source_mesh = source_mesh
|
||||
|
||||
def capabilities(self) -> dict[str, object]:
|
||||
return {"outputs": ["preview.sog", "streamed-sog"]}
|
||||
@@ -173,17 +174,26 @@ class _ReadyProvider:
|
||||
source_format: str,
|
||||
) -> GaussianSourceBundleUpload:
|
||||
self.upload_calls += 1
|
||||
members = (
|
||||
members = [
|
||||
GaussianSourceMemberUpload("upload-1", entrypoint, "a" * 64, 23),
|
||||
GaussianSourceMemberUpload("upload-2", "export/data.bin", "b" * 64, 4),
|
||||
GaussianSourceMemberUpload("upload-3", "export/index.bin", "c" * 64, 5),
|
||||
)
|
||||
]
|
||||
if self.source_mesh:
|
||||
members.append(
|
||||
GaussianSourceMemberUpload(
|
||||
"upload-4",
|
||||
"export/Mesh_Files/scene.ply",
|
||||
"e" * 64,
|
||||
3,
|
||||
)
|
||||
)
|
||||
return GaussianSourceBundleUpload(
|
||||
format=source_format,
|
||||
entrypoint=entrypoint,
|
||||
bundle_sha256="d" * 64,
|
||||
total_byte_length=32,
|
||||
members=members,
|
||||
total_byte_length=sum(member.byte_length for member in members),
|
||||
members=tuple(members),
|
||||
)
|
||||
|
||||
def submit_build(self, document: dict[str, object]) -> dict[str, object]:
|
||||
@@ -208,6 +218,39 @@ class _ReadyProvider:
|
||||
}
|
||||
|
||||
def get_result(self, _job_id: str) -> dict[str, object]:
|
||||
artifacts = [
|
||||
{
|
||||
"role": "preview",
|
||||
"logical_path": "preview.sog",
|
||||
"media_type": "application/octet-stream",
|
||||
"sha256": "1" * 64,
|
||||
"byte_length": 7,
|
||||
},
|
||||
{
|
||||
"role": "stream-manifest",
|
||||
"logical_path": "streamed/lod-meta.json",
|
||||
"media_type": "application/json",
|
||||
"sha256": "2" * 64,
|
||||
"byte_length": 2,
|
||||
},
|
||||
]
|
||||
if self.source_mesh:
|
||||
artifacts.extend([
|
||||
{
|
||||
"role": "collision-mesh",
|
||||
"logical_path": "collision/scene.collision.glb",
|
||||
"media_type": "model/gltf-binary",
|
||||
"sha256": "3" * 64,
|
||||
"byte_length": 3,
|
||||
},
|
||||
{
|
||||
"role": "collision-repair-report",
|
||||
"logical_path": "collision/scene.repair.json",
|
||||
"media_type": "application/json",
|
||||
"sha256": "4" * 64,
|
||||
"byte_length": 2,
|
||||
},
|
||||
])
|
||||
return {
|
||||
"schema_version": "gaussian-pipeline.build-result/v1",
|
||||
"job_id": "gsp-20260826000000-deadbeef",
|
||||
@@ -215,22 +258,7 @@ class _ReadyProvider:
|
||||
"source_revision": "e" * 40,
|
||||
"image_digest": f"sha256:{'f' * 64}",
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"role": "preview",
|
||||
"logical_path": "preview.sog",
|
||||
"media_type": "application/octet-stream",
|
||||
"sha256": "1" * 64,
|
||||
"byte_length": 7,
|
||||
},
|
||||
{
|
||||
"role": "stream-manifest",
|
||||
"logical_path": "streamed/lod-meta.json",
|
||||
"media_type": "application/json",
|
||||
"sha256": "2" * 64,
|
||||
"byte_length": 2,
|
||||
},
|
||||
],
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
|
||||
def download_artifact(
|
||||
@@ -240,7 +268,13 @@ class _ReadyProvider:
|
||||
destination: Path,
|
||||
) -> Path:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(b"preview" if descriptor["role"] == "preview" else b"{}")
|
||||
payload = {
|
||||
"preview": b"preview",
|
||||
"stream-manifest": b"{}",
|
||||
"collision-mesh": b"glb",
|
||||
"collision-repair-report": b"{}",
|
||||
}[str(descriptor["role"])]
|
||||
destination.write_bytes(payload)
|
||||
return destination
|
||||
|
||||
def delete_job(self, job_id: str) -> None:
|
||||
@@ -318,6 +352,59 @@ def test_service_builds_visual_world_without_automatic_collision(tmp_path: Path)
|
||||
assert provider.deleted == ["gsp-20260826000000-deadbeef"]
|
||||
|
||||
|
||||
def test_service_automatically_builds_repaired_source_mesh_collision(tmp_path: Path) -> None:
|
||||
store = SimulationProjectStore(tmp_path)
|
||||
files = [
|
||||
*_folder_files(),
|
||||
{"logical_path": "export/Mesh_Files/scene.ply", "byte_length": 3},
|
||||
]
|
||||
project = store.create(
|
||||
name="Source mesh scene",
|
||||
scene_type="outdoor",
|
||||
source_kind="folder",
|
||||
files=files,
|
||||
)
|
||||
payloads = {
|
||||
"export/scene.lcc": b'{"fileType":"Portable"}',
|
||||
"export/index.bin": b"index",
|
||||
"export/data.bin": b"data",
|
||||
"export/Mesh_Files/scene.ply": b"ply",
|
||||
}
|
||||
for source_file in project["source"]["files"]:
|
||||
store.append_upload(
|
||||
project["project_id"],
|
||||
source_file["file_id"],
|
||||
offset=0,
|
||||
payload=payloads[source_file["logical_path"]],
|
||||
)
|
||||
store.begin_build(project["project_id"])
|
||||
provider = _ReadyProvider(source_mesh=True)
|
||||
service = SimulationProjectService(store, provider_factory=lambda: provider) # type: ignore[arg-type]
|
||||
|
||||
service.process(project["project_id"])
|
||||
|
||||
ready = store.get(project["project_id"])
|
||||
assert ready["status"] == "ready"
|
||||
assert ready["world_manifest"]["collision"]["available"] is True
|
||||
assert ready["world_manifest"]["collision"]["mesh_url"].endswith(
|
||||
"/collision/scene.collision.glb"
|
||||
)
|
||||
assert provider.submitted_document is not None
|
||||
assert provider.submitted_document["outputs"] == {
|
||||
"preview_sog": True,
|
||||
"streamed_sog": True,
|
||||
"collision": True,
|
||||
}
|
||||
assert provider.submitted_document["collision_profile"] == {
|
||||
"scene_type": "outdoor",
|
||||
"seed_position": [0.0, 0.0, 0.0],
|
||||
"capsule_height": 0.4,
|
||||
"capsule_radius": 0.4,
|
||||
"voxel_size": 0.05,
|
||||
"mesh_shape": "source",
|
||||
}
|
||||
|
||||
|
||||
def test_service_queue_processes_projects_strictly_one_at_a_time(tmp_path: Path) -> None:
|
||||
store = SimulationProjectStore(tmp_path)
|
||||
projects: list[dict[str, Any]] = []
|
||||
|
||||
Reference in New Issue
Block a user