mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +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
|
library_file = IfcStore.library_file
|
||||||
if library_file is None or library_file.schema == "IFC2X3":
|
if library_file is None or library_file.schema == "IFC2X3":
|
||||||
return results
|
return results
|
||||||
project = library_file.by_type("IfcProject")[0]
|
root = tool.Project.get_root_context(library_file)
|
||||||
results.append((str(project.id()), f"IfcProject {project.Name or 'Unnamed'}", project.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():
|
||||||
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
|
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
|
||||||
return results
|
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)}
|
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)
|
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)
|
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"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
@@ -763,7 +763,10 @@ class EditProjectLibrary(bpy.types.Operator):
|
|||||||
previous_parent_library = tool.Project.get_parent_library(project_library)
|
previous_parent_library = tool.Project.get_parent_library(project_library)
|
||||||
new_parent_library = library_file.by_id(int(props.parent_library))
|
new_parent_library = library_file.by_id(int(props.parent_library))
|
||||||
if previous_parent_library != new_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.
|
# Then new one is IfcProjectLibrary.
|
||||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||||
else: # Previous is IfcProjectLibrary.
|
else: # Previous is IfcProjectLibrary.
|
||||||
@@ -804,9 +807,12 @@ class AddProjectLibrary(bpy.types.Operator):
|
|||||||
props = tool.Project.get_project_props()
|
props = tool.Project.get_project_props()
|
||||||
library_file = IfcStore.library_file
|
library_file = IfcStore.library_file
|
||||||
assert 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")
|
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.
|
ProjectLibraryData.load() # Update enum.
|
||||||
props.selected_project_library = str(project_library.id())
|
props.selected_project_library = str(project_library.id())
|
||||||
props.is_editing_project_library = True
|
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.",
|
f"Error loading IFC file from filepath '{filepath}'. See logs above in the system console for the details.",
|
||||||
)
|
)
|
||||||
return {"CANCELLED"}
|
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 = tool.Project.get_project_props()
|
||||||
props.is_loading = True
|
props.is_loading = True
|
||||||
props.total_elements = len(tool.Ifc.get().by_type("IfcElement"))
|
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))
|
project_library = library_file.by_id(int(self.selected_project_library))
|
||||||
self.project_library_attributes.clear()
|
self.project_library_attributes.clear()
|
||||||
bonsai.bim.helper.import_attributes(project_library, self.project_library_attributes)
|
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.
|
ProjectLibraryData.load() # Show edit icon in enum.
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from typing import (
|
|||||||
NotRequired,
|
NotRequired,
|
||||||
Optional,
|
Optional,
|
||||||
TypedDict,
|
TypedDict,
|
||||||
|
Union,
|
||||||
)
|
)
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
@@ -376,12 +377,31 @@ class Project(bonsai.core.tool.Project):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@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:
|
if nests := project_library.Nests:
|
||||||
# IfcProjectLibrary.
|
|
||||||
return nests[0].RelatingObject
|
return nests[0].RelatingObject
|
||||||
# IfcProject.
|
if has_context := project_library.HasContext:
|
||||||
return project_library.HasContext[0].RelatingContext
|
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
|
@classmethod
|
||||||
def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict:
|
def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict:
|
||||||
@@ -401,6 +421,8 @@ class Project(bonsai.core.tool.Project):
|
|||||||
return hierarchy
|
return hierarchy
|
||||||
for project_library in ifc_file.by_type("IfcProjectLibrary"):
|
for project_library in ifc_file.by_type("IfcProjectLibrary"):
|
||||||
parent_library = cls.get_parent_library(project_library)
|
parent_library = cls.get_parent_library(project_library)
|
||||||
|
if parent_library is None:
|
||||||
|
continue
|
||||||
hierarchy[parent_library][project_library] = hierarchy[project_library]
|
hierarchy[parent_library][project_library] = hierarchy[project_library]
|
||||||
return hierarchy
|
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