feat(simulation): auto-build source mesh collision
This commit is contained in:
@@ -58,12 +58,10 @@ Mission Core backend
|
|||||||
DC Gaussian Pipeline
|
DC Gaussian Pipeline
|
||||||
├─ TUS bundle or archive admission
|
├─ TUS bundle or archive admission
|
||||||
├─ secure ZIP/RAR/7z normalization
|
├─ secure ZIP/RAR/7z normalization
|
||||||
└─ native Vulkan SplatTransform visual build on Worker 006
|
├─ native Vulkan SplatTransform visual build on Worker 006
|
||||||
|
└─ optional Mesh_Files/*.ply source-collision path
|
||||||
Optional physical-mesh pipeline (separate job; experimental)
|
├─ conservative small-hole repair and topology audit
|
||||||
├─ source-mesh discovery or explicit mesh generation
|
└─ mandatory Draco, digest-bound publication
|
||||||
├─ physics-oriented cleanup and geometry budget
|
|
||||||
└─ compressed, digest-bound publication
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The browser never receives the Worker token. Mission Core does not embed archive-format behavior
|
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
|
on the RTX 4090. It does not install into or share the Python, CUDA, Triton or computer-vision
|
||||||
environments on the host.
|
environments on the host.
|
||||||
|
|
||||||
Project processing is split at a durable product boundary. The mandatory first job builds only the
|
Project processing never generates collision geometry from Gaussian data by default. When the
|
||||||
preview and streamed Gaussian assets required for visual inspection. It never generates collision
|
normalized source contains exactly one PLY below `Mesh_Files`, the same queued build automatically
|
||||||
geometry, so a location can reach `ready` without paying the time, GPU-memory and storage cost of a
|
selects the provider's `source` collision profile. The original indexed mesh is retained; only
|
||||||
physical mesh.
|
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
|
The conservative repair rejects outer borders, branched boundaries, large or non-planar loops,
|
||||||
candidate source is a mesh already present in the uploaded export (for example a PLY in
|
vertical openings, mixed orientation and failed/self-intersecting triangulations. Ambiguous holes
|
||||||
`Mesh_Files`); generation from the Gaussian cloud is a fallback experiment, not the default path.
|
remain open and are counted in the report instead of being silently capped. Explicit mesh
|
||||||
The second-stage contract, cleanup method and acceptance gates are intentionally separate from the
|
generation from Gaussian data remains a later experiment, not a fallback in this ingestion path.
|
||||||
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.
|
|
||||||
|
|
||||||
Visual and collision layers retain independent X/Y/Z correction settings, while PlayCanvas world,
|
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
|
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;
|
- encrypted, linked, traversing, duplicate and over-limit archive entries fail closed;
|
||||||
- project status is durable and reflects provider state without fabricated percentages;
|
- 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;
|
- 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;
|
- the build always requests preview and streamed Gaussian outputs;
|
||||||
- a visual project reaches ready state without a collision artifact;
|
- exactly one `Mesh_Files/*.ply` automatically selects repaired source-mesh collision plus Draco;
|
||||||
- physical mesh preparation is a separate explicit job and never blocks visual inspection;
|
- 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
|
- edit changes project metadata; delete removes both the Mission Core project and terminal provider
|
||||||
job;
|
job;
|
||||||
- a ready project mounts direct PlayCanvas Engine and loads Streamed SOG with preview fallback;
|
- 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")
|
return _logical_path(logical, "descriptor member")
|
||||||
|
|
||||||
members = {entrypoint}
|
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":
|
if source_format == "lcc":
|
||||||
members.update({related("index.bin"), related("data.bin")})
|
members.update({related("index.bin"), related("data.bin")})
|
||||||
file_type = document.get("fileType")
|
file_type = document.get("fileType")
|
||||||
@@ -810,6 +828,11 @@ def _discover_bundle_members(root: Path, entrypoint: str, source_format: str) ->
|
|||||||
return members
|
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:
|
def _logical_path(value: str, label: str) -> str:
|
||||||
if (
|
if (
|
||||||
not value
|
not value
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from k1link.simulation.gaussian_pipeline_gateway import (
|
|||||||
GaussianPipelineUnavailableError,
|
GaussianPipelineUnavailableError,
|
||||||
configured_gaussian_pipeline_gateway,
|
configured_gaussian_pipeline_gateway,
|
||||||
discover_gaussian_source_bundle,
|
discover_gaussian_source_bundle,
|
||||||
|
is_xgrids_source_mesh_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
PROJECT_SCHEMA: Final = "missioncore.simulation-project/v1"
|
PROJECT_SCHEMA: Final = "missioncore.simulation-project/v1"
|
||||||
@@ -610,6 +611,22 @@ class SimulationProjectService:
|
|||||||
entrypoint=entrypoint,
|
entrypoint=entrypoint,
|
||||||
source_format=source_format,
|
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 = {
|
request = {
|
||||||
"schema_version": BUILD_REQUEST_SCHEMA,
|
"schema_version": BUILD_REQUEST_SCHEMA,
|
||||||
"idempotency_key": f"missioncore-{project_id}",
|
"idempotency_key": f"missioncore-{project_id}",
|
||||||
@@ -617,10 +634,10 @@ class SimulationProjectService:
|
|||||||
"outputs": {
|
"outputs": {
|
||||||
"preview_sog": True,
|
"preview_sog": True,
|
||||||
"streamed_sog": True,
|
"streamed_sog": True,
|
||||||
"collision": False,
|
"collision": source_mesh_available,
|
||||||
},
|
},
|
||||||
"preview_lod": "coarsest",
|
"preview_lod": "coarsest",
|
||||||
"collision_profile": None,
|
"collision_profile": collision_profile,
|
||||||
}
|
}
|
||||||
submitted = provider.submit_build(request)
|
submitted = provider.submit_build(request)
|
||||||
job_id = submitted.get("job_id")
|
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)
|
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:
|
def test_gateway_uploads_and_normalizes_archive_with_tus(tmp_path: Path) -> None:
|
||||||
archive_bytes = b"portable-archive"
|
archive_bytes = b"portable-archive"
|
||||||
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
|
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:
|
class _ReadyProvider:
|
||||||
def __init__(self) -> None:
|
def __init__(self, *, source_mesh: bool = False) -> None:
|
||||||
self.deleted: list[str] = []
|
self.deleted: list[str] = []
|
||||||
self.upload_calls = 0
|
self.upload_calls = 0
|
||||||
self.submit_calls = 0
|
self.submit_calls = 0
|
||||||
self.submitted_document: dict[str, object] | None = None
|
self.submitted_document: dict[str, object] | None = None
|
||||||
|
self.source_mesh = source_mesh
|
||||||
|
|
||||||
def capabilities(self) -> dict[str, object]:
|
def capabilities(self) -> dict[str, object]:
|
||||||
return {"outputs": ["preview.sog", "streamed-sog"]}
|
return {"outputs": ["preview.sog", "streamed-sog"]}
|
||||||
@@ -173,17 +174,26 @@ class _ReadyProvider:
|
|||||||
source_format: str,
|
source_format: str,
|
||||||
) -> GaussianSourceBundleUpload:
|
) -> GaussianSourceBundleUpload:
|
||||||
self.upload_calls += 1
|
self.upload_calls += 1
|
||||||
members = (
|
members = [
|
||||||
GaussianSourceMemberUpload("upload-1", entrypoint, "a" * 64, 23),
|
GaussianSourceMemberUpload("upload-1", entrypoint, "a" * 64, 23),
|
||||||
GaussianSourceMemberUpload("upload-2", "export/data.bin", "b" * 64, 4),
|
GaussianSourceMemberUpload("upload-2", "export/data.bin", "b" * 64, 4),
|
||||||
GaussianSourceMemberUpload("upload-3", "export/index.bin", "c" * 64, 5),
|
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(
|
return GaussianSourceBundleUpload(
|
||||||
format=source_format,
|
format=source_format,
|
||||||
entrypoint=entrypoint,
|
entrypoint=entrypoint,
|
||||||
bundle_sha256="d" * 64,
|
bundle_sha256="d" * 64,
|
||||||
total_byte_length=32,
|
total_byte_length=sum(member.byte_length for member in members),
|
||||||
members=members,
|
members=tuple(members),
|
||||||
)
|
)
|
||||||
|
|
||||||
def submit_build(self, document: dict[str, object]) -> dict[str, object]:
|
def submit_build(self, document: dict[str, object]) -> dict[str, object]:
|
||||||
@@ -208,14 +218,7 @@ class _ReadyProvider:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_result(self, _job_id: str) -> dict[str, object]:
|
def get_result(self, _job_id: str) -> dict[str, object]:
|
||||||
return {
|
artifacts = [
|
||||||
"schema_version": "gaussian-pipeline.build-result/v1",
|
|
||||||
"job_id": "gsp-20260826000000-deadbeef",
|
|
||||||
"runtime": {
|
|
||||||
"source_revision": "e" * 40,
|
|
||||||
"image_digest": f"sha256:{'f' * 64}",
|
|
||||||
},
|
|
||||||
"artifacts": [
|
|
||||||
{
|
{
|
||||||
"role": "preview",
|
"role": "preview",
|
||||||
"logical_path": "preview.sog",
|
"logical_path": "preview.sog",
|
||||||
@@ -230,7 +233,32 @@ class _ReadyProvider:
|
|||||||
"sha256": "2" * 64,
|
"sha256": "2" * 64,
|
||||||
"byte_length": 2,
|
"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",
|
||||||
|
"runtime": {
|
||||||
|
"source_revision": "e" * 40,
|
||||||
|
"image_digest": f"sha256:{'f' * 64}",
|
||||||
|
},
|
||||||
|
"artifacts": artifacts,
|
||||||
}
|
}
|
||||||
|
|
||||||
def download_artifact(
|
def download_artifact(
|
||||||
@@ -240,7 +268,13 @@ class _ReadyProvider:
|
|||||||
destination: Path,
|
destination: Path,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
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
|
return destination
|
||||||
|
|
||||||
def delete_job(self, job_id: str) -> None:
|
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"]
|
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:
|
def test_service_queue_processes_projects_strictly_one_at_a_time(tmp_path: Path) -> None:
|
||||||
store = SimulationProjectStore(tmp_path)
|
store = SimulationProjectStore(tmp_path)
|
||||||
projects: list[dict[str, Any]] = []
|
projects: list[dict[str, Any]] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user