feat(simulation): auto-build source mesh collision

This commit is contained in:
DCCONSTRUCTIONS
2026-08-28 13:48:44 +03:00
parent 2ccf172319
commit 2e44e27967
5 changed files with 192 additions and 44 deletions
+19 -20
View File
@@ -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
+19 -2
View File
@@ -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")
+22
View File
@@ -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()
+109 -22
View File
@@ -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]] = []