From 260a387069f9fabcc4f35db88b8027b2bfaf6679 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 18 Jun 2026 17:13:03 +0200 Subject: [PATCH] Fix loading project library without IfcProject Per IFC4+, IfcContext is the abstract supertype of IfcProject and IfcProjectLibrary; library-only files legitimately contain only IfcProjectLibrary as their root context. Bonsai assumed an IfcProject was always present at three crash sites: the parent-library enum (reported in #8183), RefreshLibrary's tree view, and AddProjectLibrary. Introduce tool.Project.get_root_context() that prefers IfcProject and falls back to IfcProjectLibrary, and route the three sites through it. get_parent_library() now returns None for a root IfcProjectLibrary; get_project_hierarchy() and the EditProjectLibrary parent-swap branch handle that. AddProjectLibrary creates the nested sub-library via IfcRelNests when the root is an IfcProjectLibrary, matching the existing convention for library-under-library nesting. For the separate "Open IFC Project" path, abort with a friendly error pointing users to Project Setup -> Project Library -> Select Library File instead of letting set_units() crash deep in the importer. Closes #8183. Partly generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/project/data.py | 4 +- .../bonsai/bim/module/project/operator.py | 24 +++- src/bonsai/bonsai/bim/module/project/prop.py | 3 +- src/bonsai/bonsai/tool/project.py | 30 ++++- .../project/test_project_library_data.py | 121 ++++++++++++++++++ 5 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 src/bonsai/test/bim/module/project/test_project_library_data.py diff --git a/src/bonsai/bonsai/bim/module/project/data.py b/src/bonsai/bonsai/bim/module/project/data.py index 8ecf30eef5..db64041a89 100644 --- a/src/bonsai/bonsai/bim/module/project/data.py +++ b/src/bonsai/bonsai/bim/module/project/data.py @@ -162,8 +162,8 @@ class ProjectLibraryData: library_file = IfcStore.library_file if library_file is None or library_file.schema == "IFC2X3": return results - project = library_file.by_type("IfcProject")[0] - results.append((str(project.id()), f"IfcProject {project.Name or 'Unnamed'}", project.Description or "")) + root = tool.Project.get_root_context(library_file) + 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(): results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or "")) return results diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 31b1248785..dbe799b857 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -281,9 +281,9 @@ class RefreshLibrary(bpy.types.Operator): elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)} self.props.add_library_project_library("Unassigned", len(elements), 0, False) - ifc_project = library_file.by_type("IfcProject")[0] + root_context = tool.Project.get_root_context(library_file) hierarchy = tool.Project.get_project_hierarchy(library_file) - tool.Project.load_project_libraries_to_ui(ifc_project, hierarchy) + tool.Project.load_project_libraries_to_ui(root_context, hierarchy) return {"FINISHED"} @@ -763,7 +763,10 @@ class EditProjectLibrary(bpy.types.Operator): previous_parent_library = tool.Project.get_parent_library(project_library) new_parent_library = library_file.by_id(int(props.parent_library)) if previous_parent_library != new_parent_library: - if previous_parent_library.is_a("IfcProject"): + if previous_parent_library is None: + # Edited library was a root in a library-only file; nest it under the new parent. + ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library) + elif previous_parent_library.is_a("IfcProject"): # Then new one is IfcProjectLibrary. ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library) else: # Previous is IfcProjectLibrary. @@ -804,9 +807,12 @@ class AddProjectLibrary(bpy.types.Operator): props = tool.Project.get_project_props() library_file = IfcStore.library_file assert library_file - project = library_file.by_type("IfcProject")[0] + root_context = tool.Project.get_root_context(library_file) project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary") - ifcopenshell.api.project.assign_declaration(library_file, [project_library], project) + if root_context.is_a("IfcProject"): + ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context) + else: + ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context) ProjectLibraryData.load() # Update enum. props.selected_project_library = str(project_library.id()) props.is_editing_project_library = True @@ -1113,6 +1119,14 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): f"Error loading IFC file from filepath '{filepath}'. See logs above in the system console for the details.", ) return {"CANCELLED"} + if not tool.Ifc.get().by_type("IfcProject"): + self.report( + {"ERROR"}, + "This file contains no IfcProject. It is likely an IFC project library — " + "load it via Project Setup → Project Library → Select Library File instead.", + ) + IfcStore.purge() + return {"CANCELLED"} props = tool.Project.get_project_props() props.is_loading = True props.total_elements = len(tool.Ifc.get().by_type("IfcElement")) diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 53b239ee6c..d7a11a5e05 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -98,7 +98,8 @@ def is_editing_project_library_update(self: "BIMProjectProperties", context: bpy project_library = library_file.by_id(int(self.selected_project_library)) self.project_library_attributes.clear() bonsai.bim.helper.import_attributes(project_library, self.project_library_attributes) - self.parent_library = str(tool.Project.get_parent_library(project_library).id()) + if parent_library := tool.Project.get_parent_library(project_library): + self.parent_library = str(parent_library.id()) ProjectLibraryData.load() # Show edit icon in enum. return diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 1bfb239726..9edfd5821d 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -32,6 +32,7 @@ from typing import ( NotRequired, Optional, TypedDict, + Union, ) import bpy @@ -376,12 +377,31 @@ class Project(bonsai.core.tool.Project): ) @classmethod - def get_parent_library(cls, project_library: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + def get_parent_library( + cls, project_library: ifcopenshell.entity_instance + ) -> Union[ifcopenshell.entity_instance, None]: + """Return the IfcContext that declares or nests ``project_library``. + + Returns ``None`` when ``project_library`` is itself the root of a + library-only file (no IfcRelNests, no IfcRelDeclares). + """ if nests := project_library.Nests: - # IfcProjectLibrary. return nests[0].RelatingObject - # IfcProject. - return project_library.HasContext[0].RelatingContext + if has_context := project_library.HasContext: + return has_context[0].RelatingContext + return None + + @classmethod + def get_root_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance: + """Return the file's root IfcContext. + + Prefers IfcProject if present, otherwise falls back to IfcProjectLibrary — + library-only files are valid per IFC4+ and contain no IfcProject. Caller is + responsible for the IFC2X3 guard; IfcContext does not exist in that schema. + """ + if projects := ifc_file.by_type("IfcProject"): + return projects[0] + return ifc_file.by_type("IfcProjectLibrary")[0] @classmethod def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict: @@ -401,6 +421,8 @@ class Project(bonsai.core.tool.Project): return hierarchy for project_library in ifc_file.by_type("IfcProjectLibrary"): parent_library = cls.get_parent_library(project_library) + if parent_library is None: + continue hierarchy[parent_library][project_library] = hierarchy[project_library] return hierarchy diff --git a/src/bonsai/test/bim/module/project/test_project_library_data.py b/src/bonsai/test/bim/module/project/test_project_library_data.py new file mode 100644 index 0000000000..08f5573b00 --- /dev/null +++ b/src/bonsai/test/bim/module/project/test_project_library_data.py @@ -0,0 +1,121 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell +import ifcopenshell.api.nest +import ifcopenshell.api.project +import ifcopenshell.api.root +import pytest + +import bonsai.tool as tool +from bonsai.bim.ifc import IfcStore +from bonsai.bim.module.project.data import ProjectLibraryData +from test.bim.bootstrap import NewIfc + +pytestmark = pytest.mark.project + + +def _make_library_only_file(*, with_child: bool = False) -> ifcopenshell.file: + """Build a minimal IFC4 file containing only an IfcProjectLibrary (no IfcProject). + + Per IFC4+, a file must contain at least one IfcContext; IfcProjectLibrary is a + valid root on its own. ``with_child=True`` nests a sub-library under the root via + IfcRelNests, mirroring real authored library files. + """ + library_file = ifcopenshell.api.project.create_file(version="IFC4") + root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib") + 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 + + +class TestLibraryOnlyFile(NewIfc): + def test_get_root_context_returns_project_library_when_no_project(self): + library_file = _make_library_only_file() + assert not library_file.by_type("IfcProject") + + root = tool.Project.get_root_context(library_file) + + assert root.is_a("IfcProjectLibrary") + assert root.Name == "RootLib" + + def test_get_parent_library_returns_none_for_root_library(self): + library_file = _make_library_only_file() + root = library_file.by_type("IfcProjectLibrary")[0] + + assert tool.Project.get_parent_library(root) is None + + def test_get_project_hierarchy_skips_root_library(self): + library_file = _make_library_only_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") + + hierarchy = tool.Project.get_project_hierarchy(library_file) + + assert root in hierarchy + assert child in hierarchy[root] + + def test_project_library_data_loads_without_crash(self): + IfcStore.library_file = _make_library_only_file() + try: + ProjectLibraryData.is_loaded = False + ProjectLibraryData.load() + assert ProjectLibraryData.is_loaded + enum = ProjectLibraryData.data["parent_libraries_enum"] + assert len(enum) == 1 + assert enum[0][1].startswith("IfcProjectLibrary ") + finally: + IfcStore.library_file = None + ProjectLibraryData.is_loaded = False + + def test_refresh_library_succeeds_on_library_only_file(self): + import bpy + + IfcStore.library_file = _make_library_only_file(with_child=True) + try: + result = bpy.ops.bim.refresh_library() + assert result == {"FINISHED"} + finally: + IfcStore.library_file = None + ProjectLibraryData.is_loaded = False + + def test_add_project_library_nests_under_root_when_no_project(self): + import bpy + + IfcStore.library_file = _make_library_only_file() + library_file = IfcStore.library_file + try: + root = library_file.by_type("IfcProjectLibrary")[0] + before = set(library_file.by_type("IfcProjectLibrary")) + + result = bpy.ops.bim.add_project_library() + + assert result == {"FINISHED"} + after = set(library_file.by_type("IfcProjectLibrary")) + new_libraries = after - before + assert len(new_libraries) == 1 + new_library = next(iter(new_libraries)) + assert new_library.Nests + assert new_library.Nests[0].RelatingObject == root + assert not new_library.HasContext + finally: + IfcStore.library_file = None + ProjectLibraryData.is_loaded = False