Bonsai: repair library files missing the required IfcProject, not just the symptom

Per the IFC Project Context concept template, every project data set (library
files included) shall contain exactly one IfcProject, and IfcProjectLibrary
instances are assigned to it via IfcRelDeclares. There is no such thing as a
spec-valid file rooted on IfcProjectLibrary alone.

get_root_context() (added in 260a387069, #8184) treated a missing IfcProject
as license to use the top-level IfcProjectLibrary as the file's root context
instead. That invalid premise is why project_libraries() (which walks every
IfcProjectLibrary, root included) then re-added that same entity, producing
the duplicate, colliding enum key this PR originally papered over with a
dedup guard.

Add tool.Project.ensure_project_context(), which repairs a file missing
IfcProject by creating one and declaring the file's root-level
IfcProjectLibrary instances to it, and tool.Project.open_library_file(),
which opens a library file through that repair. Route all three
IfcStore.library_file load sites in SelectLibraryFile through it. Downstream
code (get_root_context, ProjectLibraryData, RefreshLibrary,
AddProjectLibrary) now always operates on a spec-valid model, so the
duplicate enum entry cannot occur; the previous one-line dedup guard in
parent_libraries_enum() is kept only as cheap defense in depth for callers
that bypass the load-time repair, not as the fix.

Rework test_project_library_data.py: the previous _make_library_only_file()
fixture built an invalid library-only model and asserted that as correct
behaviour. Replace it with a spec-valid fixture (IfcProject + IfcProjectLibrary
declared to it) for the downstream tests, and a malformed fixture used only to
exercise the new repair path.

Verified live in headless Blender (isolated profile): reproduced the original
duplicate-enum-key failure mode, then confirmed ensure_project_context/
open_library_file repair a malformed file and ProjectLibraryData,
refresh_library and add_project_library all operate correctly on the result,
with no duplicate keys and no regression on already-valid files or IFC2X3.

This change was made with the assistance of an AI tool.
This commit is contained in:
Petru Conduraru
2026-07-24 08:27:03 +03:00
parent 6c9e863546
commit 23c74891e0
4 changed files with 158 additions and 41 deletions
+1 -4
View File
@@ -165,10 +165,7 @@ class ProjectLibraryData:
root = tool.Project.get_root_context(library_file) root = tool.Project.get_root_context(library_file)
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or "")) results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
for library_id, data in cls.data["project_libraries"].items(): for library_id, data in cls.data["project_libraries"].items():
# Library-only files have an IfcProjectLibrary as their root context, and # Defensive guard only; see tool.Project.ensure_project_context for the fix.
# project_libraries() collects every IfcProjectLibrary in the file (root
# included). Skip it here since it was already added above, otherwise the
# root library is listed twice with colliding enum keys.
if library_id == root.id(): if library_id == root.id():
continue continue
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or "")) results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
@@ -215,7 +215,7 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
filepath = self.get_filepath() filepath = self.get_filepath()
ifc_file = tool.Ifc.get() ifc_file = tool.Ifc.get()
library_file: ifcopenshell.file library_file: ifcopenshell.file
library_file = ifcopenshell.open(filepath) library_file = tool.Project.open_library_file(filepath)
if library_file.schema_identifier != ifc_file.schema_identifier: if library_file.schema_identifier != ifc_file.schema_identifier:
self.report( self.report(
{"ERROR"}, {"ERROR"},
@@ -237,14 +237,14 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
def rollback(self, data): def rollback(self, data):
if data["old_filepath"]: if data["old_filepath"]:
IfcStore.library_path = data["old_filepath"] IfcStore.library_path = data["old_filepath"]
IfcStore.library_file = ifcopenshell.open(data["old_filepath"]) IfcStore.library_file = tool.Project.open_library_file(data["old_filepath"])
else: else:
IfcStore.library_path = "" IfcStore.library_path = ""
IfcStore.library_file = None IfcStore.library_file = None
def commit(self, data): def commit(self, data):
IfcStore.library_path = data["filepath"] IfcStore.library_path = data["filepath"]
IfcStore.library_file = ifcopenshell.open(data["filepath"]) IfcStore.library_file = tool.Project.open_library_file(data["filepath"])
def draw(self, context): def draw(self, context):
self.layout.prop(self, "append_all", text="Append Entire Library") self.layout.prop(self, "append_all", text="Append Entire Library")
+47 -6
View File
@@ -38,6 +38,8 @@ from typing import (
import bpy import bpy
import ifcopenshell import ifcopenshell
import ifcopenshell.api.document import ifcopenshell.api.document
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.util.element import ifcopenshell.util.element
import ifcopenshell.util.representation import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder import ifcopenshell.util.shape_builder
@@ -390,8 +392,10 @@ class Project(bonsai.core.tool.Project):
) -> Union[ifcopenshell.entity_instance, None]: ) -> Union[ifcopenshell.entity_instance, None]:
"""Return the IfcContext that declares or nests ``project_library``. """Return the IfcContext that declares or nests ``project_library``.
Returns ``None`` when ``project_library`` is itself the root of a Every IfcProjectLibrary should be either nested under another library or
library-only file (no IfcRelNests, no IfcRelDeclares). declared to the file's IfcProject (see ensure_project_context()). Returns
``None`` only as a defensive fallback for malformed data with neither
relationship, which should not occur on a normalized file.
""" """
if nests := project_library.Nests: if nests := project_library.Nests:
return nests[0].RelatingObject return nests[0].RelatingObject
@@ -401,16 +405,53 @@ class Project(bonsai.core.tool.Project):
@classmethod @classmethod
def get_root_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance: def get_root_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
"""Return the file's root IfcContext. """Return the file's IfcProject.
Prefers IfcProject if present, otherwise falls back to IfcProjectLibrary — Per the IFC Project Context concept template, every project data set (this
library-only files are valid per IFC4+ and contain no IfcProject. Caller is includes library files) shall contain exactly one IfcProject; there is no
responsible for the IFC2X3 guard; IfcContext does not exist in that schema. such thing as a spec-valid file rooted on IfcProjectLibrary alone. Files
opened through open_library_file() are normalized by ensure_project_context()
so an IfcProject is always present here. The IfcProjectLibrary fallback below
only guards callers that bypass that normalization (e.g. a file opened
directly with ifcopenshell.open); it is not a legitimate IFC structure and
should not be relied upon. Caller is responsible for the IFC2X3 guard;
IfcContext does not exist in that schema.
""" """
if projects := ifc_file.by_type("IfcProject"): if projects := ifc_file.by_type("IfcProject"):
return projects[0] return projects[0]
return ifc_file.by_type("IfcProjectLibrary")[0] return ifc_file.by_type("IfcProjectLibrary")[0]
@classmethod
def ensure_project_context(cls, ifc_file: ifcopenshell.file) -> None:
"""Repair a file that is missing the IfcProject the IFC spec requires.
Some externally authored library files only contain IfcProjectLibrary, with
no IfcProject (see #8183). Per the Project Context concept template, all
project data sets shall contain a single IfcProject, and IfcProjectLibrary
instances are assigned to it via IfcRelDeclares. Rather than treating such a
file as though a bare IfcProjectLibrary were a legitimate root context, create
the missing IfcProject and declare the file's root-level IfcProjectLibrary
instances to it, so the in-memory model becomes spec-valid.
"""
if ifc_file.schema == "IFC2X3" or ifc_file.by_type("IfcProject"):
return
root_libraries = [lib for lib in ifc_file.by_type("IfcProjectLibrary") if not lib.Nests and not lib.HasContext]
if not root_libraries:
return
project = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject", name="Unnamed")
ifcopenshell.api.project.assign_declaration(ifc_file, definitions=root_libraries, relating_context=project)
@classmethod
def open_library_file(cls, filepath: str) -> ifcopenshell.file:
"""Open a library file, repairing a missing IfcProject if needed.
See ensure_project_context() for why this repair is necessary rather than
treating a library-only file as spec-valid.
"""
library_file = ifcopenshell.open(filepath)
cls.ensure_project_context(library_file)
return library_file
@classmethod @classmethod
def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict: def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict:
"""Get project hierarchy in the following form: """Get project hierarchy in the following form:
@@ -32,12 +32,32 @@ from test.bim.bootstrap import NewIfc
pytestmark = pytest.mark.project pytestmark = pytest.mark.project
def _make_library_only_file(*, with_child: bool = False) -> ifcopenshell.file: def _make_valid_library_file(*, with_child: bool = False) -> ifcopenshell.file:
"""Build a minimal IFC4 file containing only an IfcProjectLibrary (no IfcProject). """Build a spec-valid IFC4 library file: IfcProject + IfcProjectLibrary declared to it.
Per IFC4+, a file must contain at least one IfcContext; IfcProjectLibrary is a Per the IFC Project Context concept template, every project data set (library
valid root on its own. ``with_child=True`` nests a sub-library under the root via files included) shall contain exactly one IfcProject, and IfcProjectLibrary
IfcRelNests, mirroring real authored library files. instances are assigned to it via IfcRelDeclares. ``with_child=True`` also nests a
sub-library under the root via IfcRelNests, mirroring real authored library files.
"""
library_file = ifcopenshell.api.project.create_file(version="IFC4")
project = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProject", name="Demo Project")
root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
ifcopenshell.api.project.assign_declaration(library_file, definitions=[root], relating_context=project)
if with_child:
child = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="ChildLib")
ifcopenshell.api.nest.assign_object(library_file, [child], root)
return library_file
def _make_malformed_library_file(*, with_child: bool = False) -> ifcopenshell.file:
"""Build an IFC4 file containing only an IfcProjectLibrary, no IfcProject.
This is NOT spec-valid IFC (IfcProject is mandatory per the Project Context
concept template) but mirrors real externally authored files that omit it, such
as the one reported in #8183. Used to exercise the repair path
(tool.Project.ensure_project_context / open_library_file), not as an example of
a legitimate model.
""" """
library_file = ifcopenshell.api.project.create_file(version="IFC4") library_file = ifcopenshell.api.project.create_file(version="IFC4")
root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib") root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
@@ -47,49 +67,108 @@ def _make_library_only_file(*, with_child: bool = False) -> ifcopenshell.file:
return library_file return library_file
class TestLibraryOnlyFile(NewIfc): class TestEnsureProjectContext(NewIfc):
def test_get_root_context_returns_project_library_when_no_project(self): """tool.Project.ensure_project_context() repairs files missing the required IfcProject."""
library_file = _make_library_only_file()
def test_repairs_malformed_file_by_declaring_root_library_to_a_new_project(self):
library_file = _make_malformed_library_file()
root = library_file.by_type("IfcProjectLibrary")[0]
tool.Project.ensure_project_context(library_file)
projects = library_file.by_type("IfcProject")
assert len(projects) == 1
assert root.HasContext
assert root.HasContext[0].RelatingContext == projects[0]
def test_only_declares_root_level_libraries_not_nested_children(self):
library_file = _make_malformed_library_file(with_child=True)
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
tool.Project.ensure_project_context(library_file)
assert root.HasContext
assert not child.HasContext
assert child.Nests and child.Nests[0].RelatingObject == root
def test_is_a_noop_when_project_already_present(self):
library_file = _make_valid_library_file()
before = set(library_file.by_type("IfcProject"))
tool.Project.ensure_project_context(library_file)
assert set(library_file.by_type("IfcProject")) == before
def test_is_a_noop_when_no_project_library_either(self):
library_file = ifcopenshell.api.project.create_file(version="IFC4")
tool.Project.ensure_project_context(library_file)
assert not library_file.by_type("IfcProject") assert not library_file.by_type("IfcProject")
def test_open_library_file_repairs_a_malformed_file_from_disk(self, tmp_path):
library_file = _make_malformed_library_file()
filepath = tmp_path / "malformed_library.ifc"
library_file.write(str(filepath))
opened = tool.Project.open_library_file(str(filepath))
assert len(opened.by_type("IfcProject")) == 1
assert opened.by_type("IfcProjectLibrary")[0].HasContext
class TestValidLibraryFile(NewIfc):
"""Downstream project-library UI code operating on a spec-valid model (IfcProject root)."""
def test_get_root_context_returns_the_project(self):
library_file = _make_valid_library_file()
project = library_file.by_type("IfcProject")[0]
root = tool.Project.get_root_context(library_file) root = tool.Project.get_root_context(library_file)
assert root.is_a("IfcProjectLibrary") assert root == project
assert root.Name == "RootLib"
def test_get_parent_library_returns_none_for_root_library(self): def test_get_parent_library_returns_project_for_declared_root_library(self):
library_file = _make_library_only_file() library_file = _make_valid_library_file()
project = library_file.by_type("IfcProject")[0]
root = library_file.by_type("IfcProjectLibrary")[0] root = library_file.by_type("IfcProjectLibrary")[0]
assert tool.Project.get_parent_library(root) is None assert tool.Project.get_parent_library(root) == project
def test_get_project_hierarchy_skips_root_library(self): def test_get_project_hierarchy_roots_libraries_under_the_project(self):
library_file = _make_library_only_file(with_child=True) library_file = _make_valid_library_file(with_child=True)
project = library_file.by_type("IfcProject")[0]
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib") root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib") child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
hierarchy = tool.Project.get_project_hierarchy(library_file) hierarchy = tool.Project.get_project_hierarchy(library_file)
assert root in hierarchy assert root in hierarchy[project]
assert child in hierarchy[root] assert child in hierarchy[root]
def test_project_library_data_loads_without_crash(self): def test_project_library_data_loads_with_a_single_unique_root_entry(self):
IfcStore.library_file = _make_library_only_file() # Regression test for the ci-bonsai-daily failure: parent_libraries_enum()
# must never emit two entries with the same STEP id (Blender's EnumProperty
# requires unique keys). Reproduced here on a repaired, spec-valid file rather
# than an invalid library-only one.
IfcStore.library_file = _make_valid_library_file()
try: try:
ProjectLibraryData.is_loaded = False ProjectLibraryData.is_loaded = False
ProjectLibraryData.load() ProjectLibraryData.load()
assert ProjectLibraryData.is_loaded assert ProjectLibraryData.is_loaded
enum = ProjectLibraryData.data["parent_libraries_enum"] enum = ProjectLibraryData.data["parent_libraries_enum"]
assert len(enum) == 1 keys = [entry[0] for entry in enum]
assert enum[0][1].startswith("IfcProjectLibrary ") assert len(keys) == len(set(keys))
assert enum[0][1].startswith("IfcProject ")
finally: finally:
IfcStore.library_file = None IfcStore.library_file = None
ProjectLibraryData.is_loaded = False ProjectLibraryData.is_loaded = False
def test_refresh_library_succeeds_on_library_only_file(self): def test_refresh_library_succeeds_on_valid_library_file(self):
import bpy import bpy
IfcStore.library_file = _make_library_only_file(with_child=True) IfcStore.library_file = _make_valid_library_file(with_child=True)
try: try:
result = bpy.ops.bim.refresh_library() result = bpy.ops.bim.refresh_library()
assert result == {"FINISHED"} assert result == {"FINISHED"}
@@ -97,13 +176,13 @@ class TestLibraryOnlyFile(NewIfc):
IfcStore.library_file = None IfcStore.library_file = None
ProjectLibraryData.is_loaded = False ProjectLibraryData.is_loaded = False
def test_add_project_library_nests_under_root_when_no_project(self): def test_add_project_library_declares_new_library_under_the_project_root(self):
import bpy import bpy
IfcStore.library_file = _make_library_only_file() IfcStore.library_file = _make_valid_library_file()
library_file = IfcStore.library_file library_file = IfcStore.library_file
try: try:
root = library_file.by_type("IfcProjectLibrary")[0] project = library_file.by_type("IfcProject")[0]
before = set(library_file.by_type("IfcProjectLibrary")) before = set(library_file.by_type("IfcProjectLibrary"))
result = bpy.ops.bim.add_project_library() result = bpy.ops.bim.add_project_library()
@@ -113,9 +192,9 @@ class TestLibraryOnlyFile(NewIfc):
new_libraries = after - before new_libraries = after - before
assert len(new_libraries) == 1 assert len(new_libraries) == 1
new_library = next(iter(new_libraries)) new_library = next(iter(new_libraries))
assert new_library.Nests assert new_library.HasContext
assert new_library.Nests[0].RelatingObject == root assert new_library.HasContext[0].RelatingContext == project
assert not new_library.HasContext assert not new_library.Nests
finally: finally:
IfcStore.library_file = None IfcStore.library_file = None
ProjectLibraryData.is_loaded = False ProjectLibraryData.is_loaded = False