mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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
|
||||
Reference in New Issue
Block a user