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 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
+19 -2
View File
@@ -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")
+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) 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()
+101 -14
View File
@@ -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]] = []