diff --git a/docs/20_SIMULATION_WORLD_SURFACE_BRIEF.md b/docs/20_SIMULATION_WORLD_SURFACE_BRIEF.md index a7da688..51be5c8 100644 --- a/docs/20_SIMULATION_WORLD_SURFACE_BRIEF.md +++ b/docs/20_SIMULATION_WORLD_SURFACE_BRIEF.md @@ -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; diff --git a/src/k1link/simulation/gaussian_pipeline_gateway.py b/src/k1link/simulation/gaussian_pipeline_gateway.py index 848e9b9..fe2b78c 100644 --- a/src/k1link/simulation/gaussian_pipeline_gateway.py +++ b/src/k1link/simulation/gaussian_pipeline_gateway.py @@ -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 diff --git a/src/k1link/simulation/projects.py b/src/k1link/simulation/projects.py index 41a4ca3..4db6700 100644 --- a/src/k1link/simulation/projects.py +++ b/src/k1link/simulation/projects.py @@ -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") diff --git a/tests/test_gaussian_pipeline_gateway.py b/tests/test_gaussian_pipeline_gateway.py index 272bbcb..1537e95 100644 --- a/tests/test_gaussian_pipeline_gateway.py +++ b/tests/test_gaussian_pipeline_gateway.py @@ -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() diff --git a/tests/test_simulation_projects.py b/tests/test_simulation_projects.py index 2069c21..cb33426 100644 --- a/tests/test_simulation_projects.py +++ b/tests/test_simulation_projects.py @@ -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]] = []