Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion _bim-control/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ def _update_artifacts_from_conversion(data_root: Path, model_version_id: str, re
"project_id": project_id,
"model_version_id": model_version_id,
"artifact_type": "ifc",
"name": "原始 IFC",
"name": str(result.get("original_filename") or "原始 IFC"),
"url": source_url,
"mapping_url": None,
"status": "ready",
Expand Down
44 changes: 44 additions & 0 deletions _bim-control/tests/test_conversion_results_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,47 @@ def test_conversion_result_is_stored_and_reloaded(case_dir: Path):
assert usdc_artifacts
assert usdc_artifacts[0]["status"] == "ready"
assert usdc_artifacts[0]["url"] == payload["usdc_url"]


def test_conversion_result_uses_original_filename_for_source_artifact_name(case_dir: Path):
client = TestClient(create_app(data_root=case_dir / "data"))
original_filename = "許良宇圖書館建築_2026.ifc"
payload = {
"job_id": "conv_test_001",
"status": "succeeded",
"project_id": "project_demo_001",
"model_version_id": "version_demo_001",
"source_artifact_id": "artifact_src_test_001",
"source_url": "http://localhost:8005/objects/source.ifc",
"usdc_url": "http://localhost:8005/objects/model.usdc",
"mapping_url": "http://localhost:8005/objects/element_mapping.json",
"original_filename": original_filename,
}

response = client.post("/api/model-versions/version_demo_001/conversion-result", json=payload)

assert response.status_code == 200
artifacts = client.get("/api/model-versions/version_demo_001/artifacts").json()["items"]
source = next(item for item in artifacts if item["artifact_id"] == "artifact_src_test_001")
assert source["name"] == original_filename


def test_conversion_result_source_artifact_name_falls_back_without_original_filename(case_dir: Path):
client = TestClient(create_app(data_root=case_dir / "data"))
payload = {
"job_id": "conv_test_001",
"status": "succeeded",
"project_id": "project_demo_001",
"model_version_id": "version_demo_001",
"source_artifact_id": "artifact_src_test_001",
"source_url": "http://localhost:8005/objects/source.ifc",
"usdc_url": "http://localhost:8005/objects/model.usdc",
"mapping_url": "http://localhost:8005/objects/element_mapping.json",
}

response = client.post("/api/model-versions/version_demo_001/conversion-result", json=payload)

assert response.status_code == 200
artifacts = client.get("/api/model-versions/version_demo_001/artifacts").json()["items"]
source = next(item for item in artifacts if item["artifact_id"] == "artifact_src_test_001")
assert source["name"] == "原始 IFC"
4 changes: 4 additions & 0 deletions _worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,8 @@ GET /objects/{path}

`POST /api/artifacts` accepts either `content_base64`, `content_text`, `source_url`, or `signed_upload_url` plus lineage fields. File bytes are stored under `data/objects/tenants/...`.

Source artifact responses, source metadata, source index entries, and completed conversion results include `original_filename`, preserving the raw uploaded or selected IFC filename while keeping the on-disk object name sanitized for path safety.

`POST /api/conversions` creates a queued job. With the default `run_background=true`, FastAPI schedules an inline local adapter conversion that writes deterministic demo `model.usdc`, index JSON, mapping JSON, and `metadata.json`, then posts metadata to `_bim-control`.

The default worker adapter output is not a Kit-ready geometry conversion. Real IFC -> USDC validation uses the Kit/HOOPS converter in `bim-streaming-server/scripts/convert-ifc-to-usdc.ps1` and the stage inspection helper in `bim-streaming-server/scripts/inspect-usd-stage-and-quit.py`. The opt-in pytest smoke `test_real_ifc_files_convert_to_kit_openable_usdc_when_enabled` runs only when `WORKER_RUN_REAL_USDC_SMOKE=1` is set.
1 change: 1 addition & 0 deletions _worker/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ def create_conversion_from_ifc_source(
"conversion_job_id": job["conversion_job_id"],
"job_id": job["job_id"],
"status": job["status"],
"original_filename": source_artifact["original_filename"],
"conversion_url": f"/api/conversions/{job['conversion_job_id']}",
"result_url": f"/api/conversions/{job['conversion_job_id']}/result",
"readiness_url": f"/api/artifact-groups/{source_artifact['artifact_group_id']}/readiness",
Expand Down
5 changes: 5 additions & 0 deletions _worker/app/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def create_source_artifact(self, request: ArtifactIntakeRequest) -> dict[str, An
source_system = safe_id(request.source_system, "source_system")
artifact_group_id = safe_id(request.artifact_group_id or f"ag_{uuid4().hex[:12]}", "artifact_group_id")
source_artifact_id = f"artifact_src_{uuid4().hex[:12]}"
original_filename = request.filename
filename = safe_filename(request.filename)
content = self._content_bytes(request)
Comment on lines 58 to 61
sha256 = hashlib.sha256(content).hexdigest()
Expand Down Expand Up @@ -88,6 +89,7 @@ def create_source_artifact(self, request: ArtifactIntakeRequest) -> dict[str, An
"model_version_id": model_version_id,
"source_system": source_system,
"source_format": request.source_format,
"original_filename": original_filename,
"sha256": sha256,
"version_no": 1,
"uploaded_by": request.uploaded_by,
Expand All @@ -109,6 +111,7 @@ def create_source_artifact(self, request: ArtifactIntakeRequest) -> dict[str, An
"project_id": project_id,
"model_version_id": model_version_id,
"sha256": sha256,
"original_filename": original_filename,
"object_key": object_key.as_posix(),
"object_url": self.object_url(object_key.as_posix()),
"status": "uploaded",
Expand Down Expand Up @@ -248,6 +251,7 @@ def complete_conversion_job(self, conversion_job_id: str) -> dict[str, Any]:
"model_version_id": job["model_version_id"],
"source_artifact_id": job["source_artifact_id"],
"usdc_artifact_id": usdc_artifact_id,
"original_filename": source["metadata"].get("original_filename"),
"derived_artifact_ids": {
"model_usdc": usdc_artifact_id,
"ifc_index": f"artifact_ifc_index_{conversion_job_id.removeprefix('conv_')}",
Expand Down Expand Up @@ -362,6 +366,7 @@ def _upsert_source_index(self, source_artifact_id: str, metadata: dict[str, Any]
{
"source_artifact_id": source_artifact_id,
"artifact_group_id": metadata["artifact_group_id"],
"original_filename": metadata.get("original_filename"),
"object_key": object_key.as_posix(),
"object_url": self.object_url(object_key.as_posix()),
"metadata": metadata,
Expand Down
201 changes: 201 additions & 0 deletions _worker/tests/test_worker_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import base64
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from uuid import uuid4
Expand Down Expand Up @@ -32,6 +36,16 @@ def make_client(case_dir: Path, run_background: bool = True) -> TestClient:
return TestClient(create_app(settings=settings, run_background=run_background))


def real_ifc_storage_root() -> Path:
return Path(os.getenv("WORKER_REAL_IFC_STORAGE_ROOT", r"C:\Repos\active\iot\AI-BIM-governance\storage"))


def real_streaming_root() -> Path:
return Path(
os.getenv("WORKER_REAL_STREAMING_ROOT", r"C:\Repos\active\iot\AI-BIM-governance\bim-streaming-server")
)


def source_payload(**overrides):
payload = {
"tenant_id": "tenant_demo_001",
Expand Down Expand Up @@ -64,6 +78,28 @@ def test_source_artifact_upload_writes_versioned_object_layout(case_dir: Path):
assert b"ISO-10303-21" in object_response.content


def test_source_artifact_upload_preserves_original_filename_metadata_index_and_response(case_dir: Path):
client = make_client(case_dir)
original_filename = "許良宇圖書館建築_2026 - 複製 (1).ifc"

response = client.post("/api/artifacts", json=source_payload(filename=original_filename))

assert response.status_code == 200
body = response.json()
assert body["original_filename"] == original_filename
assert body["metadata"]["original_filename"] == original_filename
assert original_filename not in body["object_key"]

object_path = case_dir / "objects" / Path(*body["object_key"].split("/"))
metadata = json.loads((object_path.parents[1] / "metadata.json").read_text(encoding="utf-8"))
assert metadata["original_filename"] == original_filename

index = json.loads((case_dir / "objects" / "_index" / "source_artifacts.json").read_text(encoding="utf-8"))
entry = next(item for item in index["items"] if item["source_artifact_id"] == body["source_artifact_id"])
assert entry["original_filename"] == original_filename
assert entry["metadata"]["original_filename"] == original_filename


def test_object_download_allows_local_viewer_origin(case_dir: Path):
client = make_client(case_dir)
artifact = client.post("/api/artifacts", json=source_payload()).json()
Expand Down Expand Up @@ -148,6 +184,171 @@ def test_selected_dev_ifc_source_creates_artifact_and_conversion_job(case_dir: P
assert result.json()["status"] == "succeeded"


def test_selected_dev_ifc_source_conversion_preserves_original_filename(case_dir: Path):
storage = case_dir / "storage"
storage.mkdir()
original_filename = "許良宇圖書館建築_2026 - 複製 (2).ifc"
(storage / original_filename).write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
client = make_client(case_dir)
source = client.get("/api/dev/ifc-sources").json()["items"][0]

response = client.post(
f"/api/dev/ifc-sources/{source['source_id']}/conversions",
json={
"tenant_id": "tenant_demo_001",
"project_id": "project_demo_001",
"model_version_id": "version_demo_001",
"source_system": "dev_storage",
"uploaded_by": "dev_user_001",
},
)

assert response.status_code == 200
body = response.json()
assert body["source"]["filename"] == original_filename
assert body["original_filename"] == original_filename

result = client.get(body["result_url"])
assert result.status_code == 200
assert result.json()["original_filename"] == original_filename


def test_real_ifc_inputs_preserve_filename_through_worker_adapter_conversions(case_dir: Path):
"""Uses real IFC bytes with the current worker adapter, not a Kit-ready USDC converter."""
real_storage = real_ifc_storage_root()
real_ifc_files = sorted(real_storage.glob("*.ifc"), key=lambda path: path.name.casefold())
if len(real_ifc_files) < 2:
pytest.skip(f"Need at least two real IFC files under {real_storage}.")

storage = case_dir / "storage"
storage.mkdir()
selected_files = real_ifc_files[:2]
for source_file in selected_files:
shutil.copy2(source_file, storage / source_file.name)

client = make_client(case_dir)
sources = client.get("/api/dev/ifc-sources").json()["items"]
assert [source["filename"] for source in sources] == [source_file.name for source_file in selected_files]

conversions = []
for index, source in enumerate(sources):
response = client.post(
f"/api/dev/ifc-sources/{source['source_id']}/conversions",
json={
"tenant_id": "tenant_demo_001",
"project_id": "project_demo_001",
"model_version_id": "version_demo_001",
"source_system": "dev_storage",
"uploaded_by": "dev_user_001",
"artifact_group_id": f"ag_real_ifc_{index}",
},
)

assert response.status_code == 200
body = response.json()
result = client.get(body["result_url"])
assert result.status_code == 200
conversions.append((source, body, result.json()))

assert len({body["source_artifact_id"] for _, body, _ in conversions}) == len(selected_files)
assert len({body["conversion_job_id"] for _, body, _ in conversions}) == len(selected_files)
assert len({result["source_url"] for _, _, result in conversions}) == len(selected_files)

index = json.loads((case_dir / "objects" / "_index" / "source_artifacts.json").read_text(encoding="utf-8"))
index_by_artifact = {item["source_artifact_id"]: item for item in index["items"]}
for source, body, result in conversions:
assert body["original_filename"] == source["filename"]
assert result["status"] == "succeeded"
assert result["original_filename"] == source["filename"]
assert result["usdc_url"].endswith("/model.usdc")
assert index_by_artifact[body["source_artifact_id"]]["original_filename"] == source["filename"]
assert source["filename"] not in result["lineage"]["source_object_key"]


def test_real_ifc_files_convert_to_kit_openable_usdc_when_enabled(case_dir: Path):
if os.getenv("WORKER_RUN_REAL_USDC_SMOKE") != "1":
pytest.skip("Set WORKER_RUN_REAL_USDC_SMOKE=1 to run the Kit/HOOPS IFC-to-USDC smoke test.")

storage_root = real_ifc_storage_root()
streaming_root = real_streaming_root()
build_root = streaming_root / "_build" / "windows-x86_64" / "release"
converter_script = streaming_root / "scripts" / "convert-ifc-to-usdc.ps1"
inspect_script = streaming_root / "scripts" / "inspect-usd-stage-and-quit.py"
kit_exe = build_root / "kit" / "kit.exe"

for required_path in (storage_root, converter_script, inspect_script, kit_exe):
assert required_path.exists(), f"Required real converter path is missing: {required_path}"

real_ifc_files = sorted(storage_root.glob("*.ifc"), key=lambda path: path.name.casefold())
assert len(real_ifc_files) >= 2, f"Need at least two real IFC files under {storage_root}."

powershell = shutil.which("pwsh") or shutil.which("powershell")
assert powershell, "PowerShell is required to run convert-ifc-to-usdc.ps1."

output_dir = case_dir / "real-usdc"
output_dir.mkdir()
for source_file in real_ifc_files[:2]:
conversion = subprocess.run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(converter_script),
"-IfcPath",
str(source_file),
"-OutputDir",
str(output_dir),
"-KitExePath",
str(kit_exe),
"-TimeoutSeconds",
"900",
"-Force",
],
cwd=streaming_root,
capture_output=True,
text=True,
timeout=960,
check=False,
)
assert conversion.returncode == 0, conversion.stdout + conversion.stderr

output_path = output_dir / f"{source_file.stem}.usdc"
assert output_path.is_file()
assert output_path.stat().st_size > 1024 * 1024

inspect_output = output_dir / f"{source_file.stem}.stage.json"
stage_check = subprocess.run(
[
str(kit_exe),
"--ext-folder",
str(build_root / "exts"),
"--ext-folder",
str(build_root / "extscache"),
"--ext-folder",
str(build_root / "apps"),
"--no-window",
"--enable",
"omni.usd",
"--exec",
f'"{inspect_script}" --usd-path "{output_path}" --output-path "{inspect_output}"',
"--/app/fastShutdown=1",
"--info",
],
cwd=streaming_root,
capture_output=True,
text=True,
timeout=300,
check=False,
)
assert stage_check.returncode == 0, stage_check.stdout + stage_check.stderr

stage = json.loads(inspect_output.read_text(encoding="utf-8"))
assert stage["prim_count"] > 0
assert stage["root_layer"].replace("\\", "/").endswith(f"{source_file.stem}.usdc")


def test_selected_dev_ifc_source_rejects_stale_source_id(case_dir: Path):
storage = case_dir / "storage"
storage.mkdir()
Expand Down
16 changes: 15 additions & 1 deletion _worker/tests/test_worker_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import sys
from pathlib import Path
from uuid import uuid4

import pytest

Expand Down Expand Up @@ -299,6 +300,19 @@ def test_store_create_source_artifact_stores_sha256_in_metadata(tmp_path: Path):
assert result["sha256"] == expected


def test_store_create_source_artifact_preserves_original_filename_in_metadata():
store_root = Path(__file__).resolve().parents[1] / "pytest-cache-files-store" / uuid4().hex
store_root.mkdir(parents=True, exist_ok=False)
store = make_store(store_root)
Comment on lines +303 to +306
original_filename = "許良宇圖書館建築_2026.ifc"
req = make_intake_request(filename=original_filename)

result = store.create_source_artifact(req)

assert result["original_filename"] == original_filename
assert result["metadata"]["original_filename"] == original_filename


def test_store_create_source_artifact_content_base64_decodes_correctly(tmp_path: Path):
store = make_store(tmp_path)
raw = b"ISO-10303-21;\nEND-ISO-10303-21;\n"
Expand Down Expand Up @@ -413,4 +427,4 @@ def test_store_object_url_constructs_from_public_base(tmp_path: Path):
def test_store_object_url_strips_leading_slash(tmp_path: Path):
store = make_store(tmp_path)
url = store.object_url("/tenants/t1/model.usdc")
assert url == "http://testserver/objects/tenants/t1/model.usdc"
assert url == "http://testserver/objects/tenants/t1/model.usdc"
Loading