diff --git a/bim-streaming-server/source/extensions/ezplus.bim_review_stream.messaging/ezplus/bim_review_stream/messaging/ifc2usdc_powershell_adapter.py b/bim-streaming-server/source/extensions/ezplus.bim_review_stream.messaging/ezplus/bim_review_stream/messaging/ifc2usdc_powershell_adapter.py index 5a1c6fdd0..d7bb13a2d 100644 --- a/bim-streaming-server/source/extensions/ezplus.bim_review_stream.messaging/ezplus/bim_review_stream/messaging/ifc2usdc_powershell_adapter.py +++ b/bim-streaming-server/source/extensions/ezplus.bim_review_stream.messaging/ezplus/bim_review_stream/messaging/ifc2usdc_powershell_adapter.py @@ -450,8 +450,9 @@ def _run_ifcopenshell_openusd_fallback( except Exception: pass - mapping_items: list[dict[str, str]] = [] + mapping_items: list[dict[str, Any]] = [] entity_items: list[dict[str, Any]] = [] + ifc_class_xforms: set[str] = set() shape_count = 0 skipped_shape_count = 0 while True: @@ -478,33 +479,63 @@ def _run_ifcopenshell_openusd_fallback( skipped_shape_count += 1 else: shape_count += 1 - prim_path = f"/World/IfcShape_{shape_count:06d}" + ifc_guid = str(getattr(shape, "guid", "") or "") + ifc_name = str(getattr(shape, "name", "") or "") + ifc_type = str(getattr(shape, "type", "") or "") + + # streaming-server-fallback-semantic-mapping:IFC-class grouped + # prim path. Per-class Xform 只 Define 一次;GUID/class 任一含 + # 非 USD-legal 字元時走 _safe_usd_prim_name 做 sanitize。 + # collision suffix 用 while-loop counter:不同原始 GUID + # (例如 `abc$` / `abc!` / `abc-`)可能 sanitize 成同一 token, + # 必須以「下一個未使用的 __N 後綴」確保 prim path 在 stage 內 + # 唯一,避免 UsdGeom.Mesh.Define 對既有 prim reapply 而 silently + # overwrite 前一個 shape 的 mesh attr。 + class_token = self._resolve_ifc_class_token(ifc_type) + guid_token = self._resolve_guid_token(ifc_guid, shape_count) + if class_token not in ifc_class_xforms: + UsdGeom.Xform.Define(stage, f"/World/{class_token}") + ifc_class_xforms.add(class_token) + candidate = f"/World/{class_token}/{guid_token}" + suffix = 1 + while stage.GetPrimAtPath(candidate).IsValid(): + candidate = f"/World/{class_token}/{guid_token}__{suffix}" + suffix += 1 + prim_path = candidate + mesh = UsdGeom.Mesh.Define(stage, prim_path) mesh.CreatePointsAttr(points) mesh.CreateFaceVertexCountsAttr(face_counts) mesh.CreateFaceVertexIndicesAttr(face_indices) mesh.CreateExtentAttr(self._mesh_extent(points, vec3_type=Gf.Vec3f)) - ifc_guid = str(getattr(shape, "guid", "") or "") - ifc_name = str(getattr(shape, "name", "") or "") - ifc_type = str(getattr(shape, "type", "") or "") prim = mesh.GetPrim() if ifc_guid: prim.SetCustomDataByKey("ifcGlobalId", ifc_guid) prim.SetCustomDataByKey("ifc_guid", ifc_guid) - mapping_items.append( - {"ifc_guid": ifc_guid, "usd_prim_path": prim_path} - ) if ifc_name: prim.SetCustomDataByKey("ifcName", ifc_name) if ifc_type: prim.SetCustomDataByKey("ifcType", ifc_type) + + entity_id = f"entity_{shape_count:06d}" + if ifc_guid: + mapping_items.append( + { + "ifc_guid": ifc_guid, + "usd_prim_path": prim_path, + "ifc_type": ifc_type or None, + "ifc_name": ifc_name or None, + "entity_id": entity_id, + } + ) entity_items.append( { "ifc_guid": ifc_guid or None, "ifc_type": ifc_type or None, "name": ifc_name or None, "usd_prim_path": prim_path, + "entity_id": entity_id, } ) @@ -574,6 +605,12 @@ def _run_ifcopenshell_openusd_fallback( "skipped_shape_count": skipped_shape_count, "primary_converter_error": primary_error.message, } + # streaming-server-fallback-semantic-mapping:declare semantic fidelity + # so coordinator /ui 與 viewer 可不必 re-parse mapping_items 即判定 + # Semantic ready。flags 取 mapping_items 為主而非 entity_items,因為 + # mapping items 才是 viewer 對外消費的 IFC GUID → USD prim 對照表。 + mapping_has_ifc_type = any(item.get("ifc_type") for item in mapping_items) + mapping_has_ifc_name = any(item.get("ifc_name") for item in mapping_items) quality_metrics = { "source_ifc_entity_count": source_count, "mapped_count": mapped_count, @@ -583,6 +620,9 @@ def _run_ifcopenshell_openusd_fallback( "materialization_strategy": "ifcopenshell_openusd_fallback", "sidecar_carrier_count": shape_count, "minimum_coverage_baseline_locked": False, + "semantic_mapping_fidelity": "ifc_class_grouped_with_name", + "mapping_has_ifc_type": mapping_has_ifc_type, + "mapping_has_ifc_name": mapping_has_ifc_name, "hard_quality_gates": { "usdc_openable": True, "has_renderable_prims": True, @@ -603,6 +643,34 @@ def _run_ifcopenshell_openusd_fallback( json.dumps(quality_metrics, ensure_ascii=False), encoding="utf-8" ) + @staticmethod + def _safe_usd_prim_name(text: str) -> str | None: + """Sanitize an arbitrary string into a USD-legal prim name segment. + + USD prim names must match `[A-Za-z_][A-Za-z0-9_]*`. Returns ``None`` + for empty input (callers fall back to a deterministic placeholder). + """ + if not text: + return None + sanitized = "".join( + ch if ch.isalnum() or ch == "_" else "_" for ch in text + ) + if not sanitized: + return None + if not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = "_" + sanitized + return sanitized + + @classmethod + def _resolve_ifc_class_token(cls, ifc_type: str) -> str: + """Return USD-legal IFC class token, defaulting to ``Unclassified``.""" + return cls._safe_usd_prim_name(ifc_type) or "Unclassified" + + @classmethod + def _resolve_guid_token(cls, ifc_guid: str, shape_index: int) -> str: + """Return USD-legal GUID token, defaulting to ``Shape_NNNNNN``.""" + return cls._safe_usd_prim_name(ifc_guid) or f"Shape_{shape_index:06d}" + def _usd_mesh_data_from_ifcopenshell( self, *, diff --git a/bim-streaming-server/tests/test_host_native_conversion_service.py b/bim-streaming-server/tests/test_host_native_conversion_service.py index c6a21f938..2d4f1c381 100644 --- a/bim-streaming-server/tests/test_host_native_conversion_service.py +++ b/bim-streaming-server/tests/test_host_native_conversion_service.py @@ -939,3 +939,322 @@ def next(self) -> bool: assert raised is not None assert raised.code == "fallback_no_renderable_geometry" + + +# --- streaming-server-fallback-semantic-mapping:fallback semantic mapping fidelity --- + + +def _run_fallback_with_single_shape( + tmp_path: Path, + monkeypatch, + *, + ifc_guid: str, + ifc_name: str, + ifc_type: str, +) -> tuple[dict, dict, dict]: + """Run `_run_ifcopenshell_openusd_fallback` against a single mocked shape and + return parsed (mapping_doc, entity_index_doc, quality_metrics_doc).""" + _clear_pxr_test_stubs(monkeypatch) + repo_root = tmp_path / "repo" + (repo_root / "scripts").mkdir(parents=True) + (repo_root / "scripts" / "convert-ifc-to-usdc.ps1").write_text("# fake", encoding="utf-8") + ifc_file = repo_root / "fixtures" / "source.ifc" + ifc_file.parent.mkdir(parents=True) + ifc_file.write_text("ISO-10303-21;", encoding="utf-8") + adapter = Ifc2UsdcPowershellConverterAdapter(repo_root=repo_root, work_dir=repo_root) + + fake_ifcopenshell = types.ModuleType("ifcopenshell") + fake_geom = types.ModuleType("ifcopenshell.geom") + + class FakeModel: + schema = "IFC4" + + def by_type(self, name: str): + return [object()] if name == "IfcProduct" else [] + + class FakeSettings: + USE_WORLD_COORDS = "USE_WORLD_COORDS" + + def set(self, *_args): + return None + + class FakeGeometry: + verts = (0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0) + faces = (0, 1, 2) + + class FakeShape: + guid = ifc_guid + name = ifc_name + type = ifc_type + geometry = FakeGeometry() + + class FakeIterator: + def __init__(self): + self.done = False + + def initialize(self) -> bool: + return True + + def get(self): + return FakeShape() + + def next(self) -> bool: + if self.done: + return False + self.done = True + return False + + fake_ifcopenshell.open = lambda _path: FakeModel() + fake_geom.settings = FakeSettings + fake_geom.iterator = lambda *_args: FakeIterator() + fake_ifcopenshell.geom = fake_geom + monkeypatch.setitem(sys.modules, "ifcopenshell", fake_ifcopenshell) + monkeypatch.setitem(sys.modules, "ifcopenshell.geom", fake_geom) + + output_dir = tmp_path / "out" + adapter._run_ifcopenshell_openusd_fallback( + ifc_path=ifc_file, + output_dir=output_dir, + primary_error=ConversionAuthorityError( + "converter_failed", "A3D_LOAD_CANNOT_LOAD_MODEL" + ), + ) + + mapping_doc = json.loads((output_dir / "element_mapping.json").read_text(encoding="utf-8")) + entity_index_doc = json.loads((output_dir / "entity_index.json").read_text(encoding="utf-8")) + metrics_doc = json.loads((output_dir / "quality_metrics.json").read_text(encoding="utf-8")) + return mapping_doc, entity_index_doc, metrics_doc + + +def test_fallback_mapping_carries_ifc_type_and_name(tmp_path: Path, monkeypatch): + mapping_doc, _entity_doc, _metrics = _run_fallback_with_single_shape( + tmp_path, + monkeypatch, + ifc_guid="GUID_A", + ifc_name="樓梯1", + ifc_type="IfcStair", + ) + assert mapping_doc["items"], "fallback mapping must have at least one item" + item = mapping_doc["items"][0] + assert item["ifc_type"] == "IfcStair" + assert item["ifc_name"] == "樓梯1" + assert isinstance(item["entity_id"], str) and item["entity_id"] + + +def test_fallback_prim_paths_are_ifc_class_grouped(tmp_path: Path, monkeypatch): + mapping_doc, _entity_doc, _metrics = _run_fallback_with_single_shape( + tmp_path, + monkeypatch, + ifc_guid="GUID_B", + ifc_name="梁1", + ifc_type="IfcBeam", + ) + item = mapping_doc["items"][0] + assert item["usd_prim_path"].startswith("/World/IfcBeam/"), item["usd_prim_path"] + + +def test_fallback_unclassified_grouping(tmp_path: Path, monkeypatch): + mapping_doc, _entity_doc, _metrics = _run_fallback_with_single_shape( + tmp_path, + monkeypatch, + ifc_guid="GUID_C", + ifc_name="", + ifc_type="", + ) + item = mapping_doc["items"][0] + assert item["usd_prim_path"].startswith("/World/Unclassified/"), item["usd_prim_path"] + # ifc_type / ifc_name keys MUST still be present (may be null) + assert "ifc_type" in item and item["ifc_type"] in (None, "") + assert "ifc_name" in item and item["ifc_name"] in (None, "") + + +def test_fallback_prim_path_sanitization(tmp_path: Path, monkeypatch): + mapping_doc, _entity_doc, _metrics = _run_fallback_with_single_shape( + tmp_path, + monkeypatch, + ifc_guid="abc$def-XYZ!", + ifc_name="Demo", + ifc_type="IfcBuildingElementProxy", + ) + item = mapping_doc["items"][0] + path = item["usd_prim_path"] + assert path.startswith("/World/IfcBuildingElementProxy/"), path + assert "$" not in path + assert "!" not in path + assert "-" not in path + # path segments must be USD-legal: each /-separated segment is [A-Za-z_][A-Za-z0-9_]* + import re + + for segment in path.split("/")[1:]: + assert re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", segment), f"illegal USD segment: {segment!r}" + + +def test_fallback_quality_metrics_semantic_fields(tmp_path: Path, monkeypatch): + _mapping_doc, _entity_doc, metrics = _run_fallback_with_single_shape( + tmp_path, + monkeypatch, + ifc_guid="GUID_D", + ifc_name="梁2", + ifc_type="IfcBeam", + ) + assert metrics["semantic_mapping_fidelity"] == "ifc_class_grouped_with_name" + assert metrics["mapping_has_ifc_type"] is True + assert metrics["mapping_has_ifc_name"] is True + # legacy quality fields must still be present + assert metrics["materialization_strategy"] == "ifcopenshell_openusd_fallback" + assert metrics["hard_quality_gates"]["usdc_openable"] is True + + +def test_fallback_entity_id_alignment(tmp_path: Path, monkeypatch): + mapping_doc, entity_doc, _metrics = _run_fallback_with_single_shape( + tmp_path, + monkeypatch, + ifc_guid="GUID_E", + ifc_name="柱1", + ifc_type="IfcColumn", + ) + items = mapping_doc["items"] + entities = entity_doc["entities"] + assert len(items) == len(entities) == 1 + mapping_ids = {item["entity_id"] for item in items} + entity_ids = {ent["entity_id"] for ent in entities} + assert mapping_ids == entity_ids + # cross-reference: matching entity record carries same ifc_guid + usd_prim_path + item = items[0] + matching = next(ent for ent in entities if ent["entity_id"] == item["entity_id"]) + assert matching["ifc_guid"] == item["ifc_guid"] + assert matching["usd_prim_path"] == item["usd_prim_path"] + + +def test_fallback_mapping_backward_compat_keys(tmp_path: Path, monkeypatch): + mapping_doc, _entity_doc, _metrics = _run_fallback_with_single_shape( + tmp_path, + monkeypatch, + ifc_guid="GUID_F", + ifc_name="牆1", + ifc_type="IfcWall", + ) + item = mapping_doc["items"][0] + # legacy schema keys retained for backward-compatible consumers + assert "ifc_guid" in item + assert "usd_prim_path" in item + assert item["ifc_guid"] == "GUID_F" + + +def _run_fallback_with_multiple_shapes( + tmp_path: Path, + monkeypatch, + *, + shapes: list[dict[str, str]], +) -> tuple[dict, dict, dict]: + """Multi-shape variant of `_run_fallback_with_single_shape`,用於驗證 + multi-shape invariant(prim path uniqueness、entity_id 對齊、跨 class 衝突)。""" + _clear_pxr_test_stubs(monkeypatch) + repo_root = tmp_path / "repo" + (repo_root / "scripts").mkdir(parents=True) + (repo_root / "scripts" / "convert-ifc-to-usdc.ps1").write_text("# fake", encoding="utf-8") + ifc_file = repo_root / "fixtures" / "source.ifc" + ifc_file.parent.mkdir(parents=True) + ifc_file.write_text("ISO-10303-21;", encoding="utf-8") + adapter = Ifc2UsdcPowershellConverterAdapter(repo_root=repo_root, work_dir=repo_root) + + fake_ifcopenshell = types.ModuleType("ifcopenshell") + fake_geom = types.ModuleType("ifcopenshell.geom") + + class FakeModel: + schema = "IFC4" + + class FakeSettings: + USE_WORLD_COORDS = "USE_WORLD_COORDS" + + def set(self, *_args): + return None + + class FakeGeometry: + verts = (0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0) + faces = (0, 1, 2) + + class FakeShape: + def __init__(self, *, guid: str, name: str, ifc_type: str): + self.guid = guid + self.name = name + self.type = ifc_type + self.geometry = FakeGeometry() + + class FakeIterator: + def __init__(self, shape_specs: list[dict[str, str]]): + self._shapes = [ + FakeShape(guid=s["guid"], name=s["name"], ifc_type=s["ifc_type"]) + for s in shape_specs + ] + self._index = 0 + + def initialize(self) -> bool: + return len(self._shapes) > 0 + + def get(self): + return self._shapes[self._index] + + def next(self) -> bool: + self._index += 1 + return self._index < len(self._shapes) + + fake_ifcopenshell.open = lambda _path: FakeModel() + fake_geom.settings = FakeSettings + fake_geom.iterator = lambda *_args: FakeIterator(shapes) + fake_ifcopenshell.geom = fake_geom + monkeypatch.setitem(sys.modules, "ifcopenshell", fake_ifcopenshell) + monkeypatch.setitem(sys.modules, "ifcopenshell.geom", fake_geom) + + output_dir = tmp_path / "out" + adapter._run_ifcopenshell_openusd_fallback( + ifc_path=ifc_file, + output_dir=output_dir, + primary_error=ConversionAuthorityError("converter_failed", "A3D_LOAD_CANNOT_LOAD_MODEL"), + ) + + mapping_doc = json.loads((output_dir / "element_mapping.json").read_text(encoding="utf-8")) + entity_index_doc = json.loads((output_dir / "entity_index.json").read_text(encoding="utf-8")) + metrics_doc = json.loads((output_dir / "quality_metrics.json").read_text(encoding="utf-8")) + return mapping_doc, entity_index_doc, metrics_doc + + +def test_fallback_sanitized_clash_does_not_overwrite_prim(tmp_path: Path, monkeypatch): + """不同原始 GUID 但 sanitize 成同 token,prim path 必須 unique。""" + mapping_doc, _entity_doc, _metrics = _run_fallback_with_multiple_shapes( + tmp_path, + monkeypatch, + shapes=[ + {"guid": "abc$", "name": "牆 A", "ifc_type": "IfcWall"}, + {"guid": "abc!", "name": "牆 B", "ifc_type": "IfcWall"}, + {"guid": "abc-", "name": "牆 C", "ifc_type": "IfcWall"}, + ], + ) + items = mapping_doc["items"] + paths = [item["usd_prim_path"] for item in items] + assert len(items) == 3, "三個 shape 都應該有對應 mapping item" + assert len(set(paths)) == 3, f"prim path 必須 unique:{paths}" + assert all(p.startswith("/World/IfcWall/abc_") for p in paths) + + +def test_fallback_entity_id_one_to_one_with_entity_index_multi_shape(tmp_path: Path, monkeypatch): + """multi-shape 場景下 mapping items 與 entity_index entry 1:1 對齊。""" + mapping_doc, entity_doc, _metrics = _run_fallback_with_multiple_shapes( + tmp_path, + monkeypatch, + shapes=[ + {"guid": "GUID_AA", "name": "梁 1", "ifc_type": "IfcBeam"}, + {"guid": "GUID_BB", "name": "柱 1", "ifc_type": "IfcColumn"}, + {"guid": "GUID_CC", "name": "牆 1", "ifc_type": "IfcWall"}, + ], + ) + items = mapping_doc["items"] + entities = entity_doc["entities"] + assert len(items) == len(entities) == 3 + mapping_ids = {item["entity_id"] for item in items} + entity_ids = {ent["entity_id"] for ent in entities} + assert mapping_ids == entity_ids + for item in items: + matching = next(ent for ent in entities if ent["entity_id"] == item["entity_id"]) + assert matching["ifc_guid"] == item["ifc_guid"] + assert matching["usd_prim_path"] == item["usd_prim_path"] diff --git a/openspec/changes/streaming-server-fallback-semantic-mapping/design.md b/openspec/changes/streaming-server-fallback-semantic-mapping/design.md new file mode 100644 index 000000000..5676d7839 --- /dev/null +++ b/openspec/changes/streaming-server-fallback-semantic-mapping/design.md @@ -0,0 +1,146 @@ +## Context + +`_run_ifcopenshell_openusd_fallback`(`bim-streaming-server/source/extensions/ezplus.bim_review_stream.messaging/ezplus/bim_review_stream/messaging/ifc2usdc_powershell_adapter.py`) +目前產出: + +```python +prim_path = f"/World/IfcShape_{shape_count:06d}" +mapping_items.append({"ifc_guid": ifc_guid, "usd_prim_path": prim_path}) +entity_items.append({ + "ifc_guid": ifc_guid or None, + "ifc_type": ifc_type or None, + "name": ifc_name or None, + "usd_prim_path": prim_path, +}) +``` + +`ifc_type` / `ifc_name` 已在 `entity_index.json` 保留,但 `element_mapping.json` 的 +mapping item 沒帶這些欄位,且 mapping_items / entity_items 兩 list 沒有顯式 row id +對齊。viewer / `/ui` 因而只能拿到 IFC GUID + shape-level USD path,無法直接呈現 +BIM 語意。 + +## Approach + +### D1. mapping item schema 加 IFC 語意欄位(additive) + +```python +mapping_items.append({ + "ifc_guid": ifc_guid, + "usd_prim_path": prim_path, + "ifc_type": ifc_type or None, + "ifc_name": ifc_name or None, + "entity_id": entity_id, # 與 entity_index 對齊 +}) +``` + +`ifc_type` / `ifc_name` 可為 null(IfcOpenShell 對某些 shape 取不到 type/name),但 +欄位 schema 必須存在。新欄位 additive,既有 viewer / coordinator ingest 仍可用舊 +schema 解析。 + +### D2. USD prim path 改為 IFC class grouped + +```python +ifc_class_token = _safe_usd_prim_name(ifc_type) or "Unclassified" +guid_token = _safe_usd_prim_name(ifc_guid) or f"Shape_{shape_count:06d}" +prim_path = f"/World/{ifc_class_token}/{guid_token}" +``` + +`_safe_usd_prim_name` 把 IFC GUID 與 IFC class 字串轉成 USD-legal identifier: + +- 只保留 `[A-Za-z0-9_]` +- 開頭非字母 / `_` 則 prepend `_` +- 空字串回傳 None + +IFC GUID 22 字元 base64-like 中含 `$` 等特殊字元,必須 sanitize。USD prim 中 +parent xform `/World/` 用 `UsdGeom.Xform.Define` 建立(per IfcClass +僅建一次)。 + +### D3. entity_id 對齊 + +```python +entity_id = f"entity_{shape_count:06d}" +mapping_items.append({..., "entity_id": entity_id}) +entity_items.append({"entity_id": entity_id, ...}) +``` + +mapping_items 與 entity_items 用同一 entity_id 對齊,viewer 可拿 mapping item 的 +entity_id 查 entity_index.json 取得完整 IFC entity info。 + +### D4. quality_metrics 新欄位 + +```python +quality_metrics = { + # 既有 + "source_ifc_entity_count": source_count, + "mapped_count": mapped_count, + "unmapped_count": ..., + "coverage_ratio": coverage_ratio, + "coverage_status": "pass" if mapped_count == source_count else "warn", + "materialization_strategy": "ifcopenshell_openusd_fallback", + "sidecar_carrier_count": shape_count, + "minimum_coverage_baseline_locked": False, + "hard_quality_gates": {...}, + # 新增 + "semantic_mapping_fidelity": "ifc_class_grouped_with_name", + "mapping_has_ifc_type": True, + "mapping_has_ifc_name": True, +} +``` + +`semantic_mapping_fidelity` 是 enum-like string: +- `"ifc_class_grouped_with_name"`:本 change 落地後 fallback 的標準值 +- 未來若引入 IfcRelAggregates 還原可加新 enum value(不在本 change scope) + +`mapping_has_ifc_type` / `mapping_has_ifc_name` 是 boolean,coordinator / viewer +判定 Semantic ready 時拿這兩個欄位作為快速判斷依據(不需要逐筆掃 mapping_items)。 + +### D5. Backward compatibility + +- `mapping_items[i]["usd_prim_path"]` 仍存在,舊 viewer / coordinator ingest 仍能用 +- `mapping_items[i]["ifc_guid"]` 仍存在 +- 新增欄位 viewer / coordinator 不認識也不會報錯 +- `quality_metrics` 新欄位以 additive 方式加入;舊 consumer 不會 break + +### D6. USD prim 衝突處理 + +同 IFC class + 同 GUID 重複時(不該發生,但保險起見): + +```python +if stage.GetPrimAtPath(prim_path).IsValid(): + prim_path = f"/World/{ifc_class_token}/{guid_token}_{shape_count:06d}" +``` + +雖然 IFC GUID 在同一 IFC file 內應該唯一,但若 IfcOpenShell geometry iterator +對同一 entity 產出多個 shape(不常見),衝突 path 加 shape_count 後綴。 + +### D7. Test strategy + +- 新增 fixture:mock IfcOpenShell shape with (guid="GUID_A", name="樓梯1", type="IfcStair") + → 驗證 mapping item 含 `ifc_type="IfcStair"` / `ifc_name="樓梯1"` / `entity_id` / + prim path `/World/IfcStair/` +- 新增 fixture:shape with empty type → 驗證 fallback 使用 `/World/Unclassified/...` +- 新增 fixture:shape with special-char GUID → 驗證 sanitization +- 驗證 quality_metrics 含三個新欄位 +- 驗證 entity_index.json 與 mapping_items entity_id 對齊 +- 既有 fallback test(USDC openable / has_renderable_prims / placeholder_output=false) + 保持通過 + +### D8. Archive evidence + +- 用既有 IFC fixture 重跑 fallback path: + - `element_mapping.json` 每筆帶 `ifc_type` + `ifc_name` field(值可為 null) + - `model.usdc` 內有 `/World/IfcCableCarrierSegment/...` 等 IFC class grouped prim + - `quality_metrics.json` 含 `semantic_mapping_fidelity=ifc_class_grouped_with_name` + - `mapping_items[i].entity_id` 與 `entity_index.entities[i].entity_id` 對齊 +- 不需要 GPU / Kit live evidence +- C2 / C3 archive 時才需要 Chrome E2E viewer evidence + +## Risks + +- mapping_items 的 ifc_type / ifc_name 可能為 null(IfcOpenShell 對部分 entity 取不 + 到 type/name)。Spec scenario 必須允許 null,但 schema field 存在 +- USD prim sanitization 規則改動可能影響 element_mapping 與 USD stage 的 round-trip + 讀取;test 必須驗證 `Usd.Stage.Open` 之後 prim path 可被 GetPrimAtPath 找到 +- IFC class grouped 後 `/World` 下會多很多 IfcClass xform 節點;viewer USD stage + tree 顯示時要承受這個層次。本 change 不改 viewer USD stage tree 行為(viewer + 目前用 mapping item 的 usd_prim_path 操作,不依賴 stage tree 排序) diff --git a/openspec/changes/streaming-server-fallback-semantic-mapping/proposal.md b/openspec/changes/streaming-server-fallback-semantic-mapping/proposal.md new file mode 100644 index 000000000..9fb22779e --- /dev/null +++ b/openspec/changes/streaming-server-fallback-semantic-mapping/proposal.md @@ -0,0 +1,73 @@ +## Why + +2026-05-22 archived change `fix-ifc-usdc-hoops-load-failure` 將 HOOPS A3D primary +converter 失敗的 IFC 接到 `IfcOpenShell + OpenUSD` fallback path,產出可開啟的 +`model.usdc` 與 4,889 筆 mapping。但 fallback `element_mapping.json` 仍為 shape-level +path(`/World/IfcShape_000001`),mapping item 只帶 `ifc_guid` 與 `usd_prim_path`, +viewer 無法把高亮 / 聚焦行為對到原 IFC entity 的語意(`IfcCableCarrierSegment`、 +`IfcBuildingElementProxy` 等)。 + +2026-05-25 觀察筆記(`docs/plans/TEMP-fast-mvp-session-artifact-binding-discussion-2026-05-25.md`) +明確指出:viewer / `/ui` 顯示 stage matched 並不證明 IFC 語意正確;目前 fast MVP +demo 的 Semantic ready 一律 incomplete。HOOPS primary 屬 vendor-side 不可控;要讓 +viewer / `/ui` 有 Semantic ready 真實資料來源,務實作法是提升 fallback 的 mapping +fidelity,把 `ifc_type` / `ifc_name` / `entity_id` 帶進 mapping item,並把 USD prim +path 改為 IFC class grouped(`/World//`)。 + +## What Changes + +- 修改 `bim-streaming-server` 的 `_run_ifcopenshell_openusd_fallback`: + - `element_mapping.json` 的 mapping item 必須帶 `ifc_type`、`ifc_name`、`entity_id` + 欄位(值可為 null,但 schema field 必須存在),維持既有 `ifc_guid` 與 + `usd_prim_path` 欄位(backward compatible)。 + - fallback 產出的 mesh prim path 改為 `/World//` 結構, + 其中 `` 是 IFC entity type(例如 `IfcCableCarrierSegment`), + `` 為 USD-safe identifier。無法取得 IFC class 的 shape 使用 + `/World/Unclassified/` 或 `/World/Unclassified/Shape_NNNNNN`。 + - `entity_index.json` 維持產出,且每筆 entity 與 mapping_items 用同一 `entity_id` + 對齊(1:1,row index 等價即可)。 + - `quality_metrics.json` 新增三個欄位: + - `semantic_mapping_fidelity`(fallback 必須填 `ifc_class_grouped_with_name`) + - `mapping_has_ifc_type`(fallback 填 `true`) + - `mapping_has_ifc_name`(fallback 填 `true`) + - 維持既有 `coverage_ratio` / `coverage_status` / `materialization_strategy` + / `sidecar_carrier_count` / `hard_quality_gates` 等欄位。 +- 不改 `convert` 對外簽名、不改 `host_native_conversion_service.py` 對外 API、 + 不改 coordinator 的 ingest path、不改 callback outbox。 +- 不新增 production dependency。 +- 不修 HOOPS primary path(vendor-side,不在本 change scope)。 + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `streaming-ifc-usdc-conversion-authority`: + - ADD requirement「Fallback converter emits IFC-semantic mapping」covering + IFC-class grouped prim path、mapping item ifc_type/ifc_name/entity_id、 + quality_metrics 新欄位 + - MODIFY 既有 requirement「Streaming conversion preserves quality metrics and + mapping semantics」加新 scenario:fallback quality_metrics 必須包含 + `semantic_mapping_fidelity` 等新欄位 + +## Impact + +- Owner repo / folder:`bim-streaming-server/source/extensions/ezplus.bim_review_stream.messaging/ezplus/bim_review_stream/messaging/ifc2usdc_powershell_adapter.py`、`bim-streaming-server/tests/`、`openspec/changes/streaming-server-fallback-semantic-mapping/`。 +- Runtime boundary:conversion 仍由 `bim-streaming-server` 執行;coordinator / viewer + 不新增轉檔責任。 +- API:`POST /api/conversions/ifc-to-usdc` 與 `GET /api/conversions/{id}/result` + 路徑不變;result `artifacts.element_mapping` / `artifacts.entity_index` / + `quality_metrics` 等欄位形狀為 additive 變更(新增欄位不破壞既有 consumer)。 +- Data:fallback 寫入同一 conversion artifact directory 的 `model.usdc` 結構 + 改為 IFC-class grouped;`element_mapping.json` / `entity_index.json` 加新欄位; + `quality_metrics.json` 加新欄位。 +- Dependencies:不新增;`ifcopenshell`、`pxr` 已在 fallback path lazy import。 +- Non-goals: + - 不修復 HOOPS A3D primary converter(vendor-side) + - 不還原 `IfcRelAggregates` / `IfcRelContainedInSpatialStructure` 等 BIM hierarchy + - 不引入 production queue dependency + - 不改變既有 mapping `mock=false` 規範 + - 不改變 `unmapped` / `sidecar-only` 政策 diff --git a/openspec/changes/streaming-server-fallback-semantic-mapping/specs/streaming-ifc-usdc-conversion-authority/spec.md b/openspec/changes/streaming-server-fallback-semantic-mapping/specs/streaming-ifc-usdc-conversion-authority/spec.md new file mode 100644 index 000000000..cea497626 --- /dev/null +++ b/openspec/changes/streaming-server-fallback-semantic-mapping/specs/streaming-ifc-usdc-conversion-authority/spec.md @@ -0,0 +1,138 @@ +# streaming-ifc-usdc-conversion-authority — Spec Delta (streaming-server-fallback-semantic-mapping) + +> Delta against `openspec/specs/streaming-ifc-usdc-conversion-authority/spec.md`。 +> 本 change 提升 `_run_ifcopenshell_openusd_fallback` 產出的 mapping fidelity,讓 +> viewer / `/ui` 的 Semantic ready 有真實 IFC 語意資料來源。 + +## ADDED Requirements + +### Requirement: Fallback converter emits IFC-semantic mapping + +`bim-streaming-server` 的 `IfcOpenShell + OpenUSD` fallback converter SHALL produce +`element_mapping.json` items that carry the originating IFC entity type and name +(when available from IfcOpenShell), align each mapping item with one entity in +`entity_index.json` via a shared `entity_id`, and structure the USD prim hierarchy +so each mesh prim is grouped under an `Xform` named after its IFC class. The +fallback SHALL also publish three quality-metric fields that downstream consumers +(coordinator `/ui`, viewer) can read to determine semantic readiness without +having to re-parse the mapping items themselves. + +The new fields and structure SHALL be additive to the existing schema so existing +consumers that only know the legacy `ifc_guid` + `usd_prim_path` shape continue +to work without modification. + +#### Scenario: Fallback mapping carries IFC class and name + +- **WHEN** `_run_ifcopenshell_openusd_fallback` writes `element_mapping.json` for a + successful IFC parse +- **THEN** every entry in `items[]` SHALL include the keys `ifc_guid`, + `usd_prim_path`, `ifc_type`, `ifc_name`, and `entity_id` +- **AND** `ifc_type` / `ifc_name` MAY be `null` when IfcOpenShell does not return + a type or name for that shape, but the keys MUST be present +- **AND** the legacy keys `ifc_guid` and `usd_prim_path` SHALL retain their + existing meaning (IFC GUID and absolute USD prim path) + +#### Scenario: Fallback prim paths are IFC-class grouped + +- **WHEN** `_run_ifcopenshell_openusd_fallback` writes the fallback `model.usdc` +- **THEN** every mesh prim SHALL live under `/World//` where + `` is a USD-safe identifier derived from the IFC entity type (e.g. + `IfcCableCarrierSegment`, `IfcBuildingElementProxy`) and `` is a + USD-safe identifier derived from the IFC GUID +- **AND** shapes with no resolvable IFC class SHALL be grouped under + `/World/Unclassified/` +- **AND** any `` segment SHALL be defined as a `UsdGeom.Xform` (only + once per class) before any mesh under it is added +- **AND** the resulting `model.usdc` SHALL remain openable via `Usd.Stage.Open` + with at least one `UsdGeom.Mesh` prim + +#### Scenario: Mapping items align with entity index by entity_id + +- **WHEN** `_run_ifcopenshell_openusd_fallback` writes `element_mapping.json` and + `entity_index.json` +- **THEN** every `items[i].entity_id` value in `element_mapping.json` SHALL + appear exactly once in `entity_index.json` `entities[].entity_id` +- **AND** the matching entity record SHALL contain the same `ifc_guid` and + `usd_prim_path` as the mapping item +- **AND** consumers MAY join `element_mapping.items` with `entity_index.entities` + on `entity_id` to retrieve full IFC entity information + +#### Scenario: Quality metrics declare semantic mapping fidelity + +- **WHEN** `_run_ifcopenshell_openusd_fallback` writes `quality_metrics.json` +- **THEN** the JSON object SHALL include the keys `semantic_mapping_fidelity`, + `mapping_has_ifc_type`, and `mapping_has_ifc_name` +- **AND** when at least one mapping item has a non-null `ifc_type`, + `mapping_has_ifc_type` SHALL be `true` +- **AND** when at least one mapping item has a non-null `ifc_name`, + `mapping_has_ifc_name` SHALL be `true` +- **AND** when the fallback uses IFC-class grouped prim paths and emits the + enriched mapping schema described above, `semantic_mapping_fidelity` SHALL be + `"ifc_class_grouped_with_name"` + +#### Scenario: USD-safe identifier sanitization for IFC GUID and class + +- **WHEN** the fallback constructs a USD prim path segment from an IFC GUID or + IFC class string that contains characters outside `[A-Za-z0-9_]` +- **THEN** the identifier SHALL be sanitized so each illegal character is replaced + by `_` +- **AND** if the resulting identifier does not begin with a letter or `_`, the + identifier SHALL be prefixed with `_` +- **AND** if sanitization yields an empty string, the fallback SHALL use a + deterministic placeholder such as `Shape_NNNNNN` (zero-padded shape index) or + the literal `Unclassified` (for the IFC class segment) +- **AND** the sanitized prim path SHALL remain unique within `model.usdc` + +#### Scenario: Backward compatible mapping schema + +- **WHEN** a consumer that only understands the legacy mapping shape reads + `element_mapping.json` +- **THEN** the consumer SHALL still be able to parse `items[].ifc_guid` and + `items[].usd_prim_path` without error +- **AND** the new keys `ifc_type`, `ifc_name`, `entity_id` MAY be ignored without + breaking the consumer +- **AND** the fallback MUST NOT remove or rename any pre-existing key in the + mapping or entity-index documents + +## MODIFIED Requirements + +### Requirement: Streaming conversion preserves quality metrics and mapping semantics + +`bim-streaming-server` SHALL preserve existing conversion quality semantics when +it becomes conversion authority. When the fallback converter is the +materialization strategy, the quality metrics document SHALL additionally +declare semantic mapping fidelity so downstream consumers can distinguish a +shape-level fallback from an IFC-semantic fallback without re-parsing the mapping +artifact body. + +#### Scenario: Quality metrics are returned + +- **WHEN** conversion completes +- **THEN** result includes `source_ifc_entity_count`, `mapped_count`, + `unmapped_count`, `coverage_ratio`, `coverage_status`, + `materialization_strategy`, `sidecar_carrier_count`, and + `minimum_coverage_baseline_locked` + +#### Scenario: Mapping is not fabricated + +- **WHEN** IFC element cannot be mapped to a USD prim +- **THEN** the element is listed as unmapped or sidecar-only according to policy +- **AND** fake GUID/prim mapping MUST NOT be generated unless + `allow_fake_mapping=true` and the result is clearly marked `fake_for_smoke_test` + +#### Scenario: Entity index sidecar is preserved + +- **WHEN** sidecar carrier strategy is used +- **THEN** the result includes an `entity_index` artifact identity/URL +- **AND** lineage indicates the sidecar relation between the IFC source, USDC + artifact, mapping artifact, and entity index artifact + +#### Scenario: Fallback quality metrics declare semantic mapping fidelity + +- **WHEN** conversion completes via the `IfcOpenShell + OpenUSD` fallback + (`materialization_strategy == "ifcopenshell_openusd_fallback"`) +- **THEN** the result `quality_metrics` SHALL additionally include + `semantic_mapping_fidelity` (string), `mapping_has_ifc_type` (boolean), and + `mapping_has_ifc_name` (boolean) +- **AND** these three fields SHALL NOT be required for the primary HOOPS path + in this change (HOOPS path remains out of scope) diff --git a/openspec/changes/streaming-server-fallback-semantic-mapping/tasks.md b/openspec/changes/streaming-server-fallback-semantic-mapping/tasks.md new file mode 100644 index 000000000..8153bdac5 --- /dev/null +++ b/openspec/changes/streaming-server-fallback-semantic-mapping/tasks.md @@ -0,0 +1,93 @@ +# Tasks — streaming-server-fallback-semantic-mapping + +## 0. Setup + +- [x] 0.1 Create branch `codex/openspec/streaming-server-fallback-semantic-mapping` + from latest `main`. +- [x] 0.2 Run GitNexus impact analysis on `Ifc2UsdcPowershellConverterAdapter` + (only callers are `host_native_conversion_service.py` + tests; LOW risk). +- [x] 0.3 Create OpenSpec scaffold:proposal / design / tasks / spec delta. + +## 1. Failing tests first (TDD) + +- [ ] 1.1 Add unit test `test_fallback_mapping_carries_ifc_type_and_name`:given + mock IfcOpenShell shape with `guid="GUID_A"`, `name="樓梯1"`, `type="IfcStair"`, + assert `element_mapping.json` item has `ifc_type="IfcStair"` / + `ifc_name="樓梯1"` / non-null `entity_id`. +- [ ] 1.2 Add unit test `test_fallback_prim_paths_are_ifc_class_grouped`:assert + generated prim path starts with `/World/IfcStair/` for an IfcStair shape. +- [ ] 1.3 Add unit test `test_fallback_unclassified_grouping`:given shape with + empty `type`, assert prim path under `/World/Unclassified/`. +- [ ] 1.4 Add unit test `test_fallback_prim_path_sanitization`:given shape with + special-char GUID containing `$`, assert sanitized USD-legal identifier. +- [ ] 1.5 Add unit test `test_fallback_quality_metrics_semantic_fields`:assert + `quality_metrics.json` contains `semantic_mapping_fidelity == "ifc_class_grouped_with_name"`, + `mapping_has_ifc_type == True`, `mapping_has_ifc_name == True`. +- [ ] 1.6 Add unit test `test_fallback_entity_id_alignment`:assert every + `items[i].entity_id` in `element_mapping.json` appears exactly once in + `entity_index.json` `entities[].entity_id` with same `ifc_guid` / + `usd_prim_path`. +- [ ] 1.7 Add unit test `test_fallback_mapping_backward_compat_keys`:assert + legacy `ifc_guid` + `usd_prim_path` keys still present on every item. +- [ ] 1.8 Run `python -m pytest bim-streaming-server/tests/test_host_native_conversion_service.py -k fallback -v` + and verify all new tests FAIL with expected reasons (missing fields / + wrong prim paths). + +## 2. Implementation + +- [ ] 2.1 Add private helper `_safe_usd_prim_name(value: str) -> str | None`: + sanitize to `[A-Za-z0-9_]`, prepend `_` if not starting with letter / `_`, + return `None` for empty. +- [ ] 2.2 Add private helper `_resolve_ifc_class_token(ifc_type: str) -> str`: + returns sanitized token or `"Unclassified"` fallback. +- [ ] 2.3 Add private helper `_resolve_guid_token(ifc_guid: str, shape_index: int) + -> str`:returns sanitized GUID or `"Shape_{shape_index:06d}"` fallback. +- [ ] 2.4 Modify `_run_ifcopenshell_openusd_fallback`:track `ifc_class_xforms` + dict (per-IFC-class `UsdGeom.Xform.Define` once), build prim path as + `/World/{ifc_class_token}/{guid_token}`. +- [ ] 2.5 Modify mapping_items append:add `ifc_type`, `ifc_name`, `entity_id` + fields (keep `ifc_guid`, `usd_prim_path` first for backward-compat ordering). +- [ ] 2.6 Modify entity_items append:add `entity_id` field (same value as + mapping_items entry at the same shape index). +- [ ] 2.7 Modify quality_metrics dict:add `semantic_mapping_fidelity`, + `mapping_has_ifc_type`, `mapping_has_ifc_name`. +- [ ] 2.8 Handle prim path collision:if `stage.GetPrimAtPath(prim_path).IsValid()`, + suffix with `_{shape_index:06d}`. + +## 3. Verify + +- [ ] 3.1 Run `python -m pytest bim-streaming-server/tests/test_host_native_conversion_service.py -v` + and verify ALL tests pass (new + existing). +- [ ] 3.2 Run `python -m pytest bim-streaming-server/tests -q` + and verify no regression in adjacent suites. +- [ ] 3.3 Run `python -m pytest tests -p no:cacheprovider` for repo-root contracts + and fakes;no regression. +- [ ] 3.4 Run `openspec validate streaming-server-fallback-semantic-mapping --strict` + (use the `openspec` CLI under `~/AppData/Roaming/npm/openspec`). +- [ ] 3.5 Run `openspec validate --specs --strict`. +- [ ] 3.6 GitNexus `detect_changes` to confirm changes only touch + `ifc2usdc_powershell_adapter.py` + `test_host_native_conversion_service.py` + + `openspec/changes/streaming-server-fallback-semantic-mapping/`. + +## 4. Commit / PR + +- [ ] 4.1 `git diff --cached --check` to catch trailing whitespace. +- [ ] 4.2 Commit:`feat(streaming): fallback 加 IFC 語意 mapping (streaming-server-fallback-semantic-mapping)`. +- [ ] 4.3 Push branch and open PR with Traditional Chinese title / body. +- [ ] 4.4 Wait for GitHub Actions verify + human review. +- [ ] 4.5 Address review comments inside this branch (do not amend; new commits). + +## 5. Archive (post-merge, blocked by user merge action) + +- [ ] 5.1 After PR merged to `origin/main`, sync local main: + `git fetch origin --prune` then align `main` with `origin/main`. +- [ ] 5.2 Run `openspec archive streaming-server-fallback-semantic-mapping` + (move to `openspec/changes/archive/-streaming-server-fallback-semantic-mapping/`). +- [ ] 5.3 Sync delta into `openspec/specs/streaming-ifc-usdc-conversion-authority/spec.md`. +- [ ] 5.4 Update `docs/plans/AI-BIM-governance-saas-roadmap-2026-05.md`(roadmap + Markdown source-of-truth). +- [ ] 5.5 Regenerate `docs/plans/AI-BIM-governance-saas-roadmap-2026-05.html` + (HTML view derived from Markdown). +- [ ] 5.6 Run closeout event flow per AGENTS.md `Archive 後的 agent closeout + event flow`:check local / remote branches, delete merged branches with + reporting.