Compare commits

...

1 Commits

Author SHA1 Message Date
Bruno Postle a072c72666 Bonsai, add license tagging for IFC elements
Adds a new 'license' module that lets users attach SPDX licence
information to any IFC element via a BBIM_LicenseInformation pset.
Licences are inherited from the spatial/aggregation hierarchy so a
project-level tag covers all contained elements without explicit
per-element tags. When an asset is appended from a library the library's
licence (resolved from the element, its declaring IfcProjectLibrary, or
the library IfcProject) is automatically stamped onto the imported
element if it carries no tag of its own.

Generated with the assistance of an AI coding tool.
2026-04-07 23:53:05 +01:00
11 changed files with 1048 additions and 1 deletions
+1
View File
@@ -70,6 +70,7 @@ modules = {
"pset": None,
"qto": None,
"classification": None,
"license": None,
"library": None,
"constraint": None,
"document": None,
@@ -0,0 +1,45 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
# AI-assisted development tool was used in writing this file.
import bpy
from . import operator, prop, ui
classes = (
operator.DisableEditingObjectLicense,
operator.DisableEditingProjectLicense,
operator.EditObjectLicense,
operator.EditProjectLicense,
operator.EnableEditingObjectLicense,
operator.EnableEditingProjectLicense,
operator.RemoveObjectLicense,
operator.RemoveProjectLicense,
prop.BIMLicenseProperties,
ui.BIM_PT_object_license,
ui.BIM_PT_project_license,
)
def register():
bpy.types.Scene.BIMLicenseProperties = bpy.props.PointerProperty(type=prop.BIMLicenseProperties)
def unregister():
del bpy.types.Scene.BIMLicenseProperties
@@ -0,0 +1,77 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
# AI-assisted development tool was used in writing this file.
from __future__ import annotations
from typing import Any, Optional
import bpy
import bonsai.tool as tool
def refresh():
ProjectLicenseData.is_loaded = False
ObjectLicenseData.is_loaded = False
class ProjectLicenseData:
data: dict[str, Any] = {}
is_loaded = False
@classmethod
def load(cls):
cls.is_loaded = True
cls.data["license"] = cls.license()
@classmethod
def license(cls) -> Optional[dict]:
ifc = tool.Ifc.get()
if not ifc:
return None
projects = ifc.by_type("IfcProject")
if not projects:
return None
return tool.License.get_pset(projects[0])
class ObjectLicenseData:
data: dict[str, Any] = {}
is_loaded = False
@classmethod
def load(cls):
cls.is_loaded = True
cls.data["license"] = None
cls.data["inherited_from"] = None
cls.data["inherited_from_type"] = None
obj = bpy.context.active_object
if not obj:
return
element = tool.Ifc.get_entity(obj)
if not element:
return
pset, source = tool.License.get_effective_pset(element)
cls.data["license"] = pset
if source is not None and source.id() != element.id():
cls.data["inherited_from"] = getattr(source, "Name", None) or source.is_a()
cls.data["inherited_from_type"] = source.is_a()
@@ -0,0 +1,200 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
# AI-assisted development tool was used in writing this file.
from __future__ import annotations
from typing import TYPE_CHECKING
import bpy
from bpy.types import Operator
import bonsai.bim.handler
import bonsai.tool as tool
from bonsai.bim.module.license.data import ObjectLicenseData, ProjectLicenseData
if TYPE_CHECKING:
from bonsai.bim.module.license.prop import BIMLicenseProperties
def _get_props() -> BIMLicenseProperties:
assert (scene := bpy.context.scene)
return scene.BIMLicenseProperties # type: ignore[attr-defined]
def _populate_props_from_pset(props: BIMLicenseProperties, pset: dict) -> None:
props.spdx_license_identifier = pset.get("SpdxLicenseIdentifier", "") or ""
props.copyright_notice = pset.get("CopyrightNotice", "") or ""
props.attribution_text = pset.get("AttributionText", "") or ""
props.source_url = pset.get("SourceUrl", "") or ""
# ---------------------------------------------------------------------------
# Project-level license
# ---------------------------------------------------------------------------
class EnableEditingProjectLicense(Operator):
bl_idname = "bim.enable_editing_project_license"
bl_label = "Edit Project License"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = _get_props()
existing = ProjectLicenseData.data.get("license")
if existing:
_populate_props_from_pset(props, existing)
props.is_editing = True
return {"FINISHED"}
class DisableEditingProjectLicense(Operator):
bl_idname = "bim.disable_editing_project_license"
bl_label = "Cancel"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
_get_props().is_editing = False
return {"FINISHED"}
class EditProjectLicense(Operator):
bl_idname = "bim.edit_project_license"
bl_label = "Save Project License"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
file = tool.Ifc.get()
projects = file.by_type("IfcProject")
if not projects:
return {"CANCELLED"}
props = _get_props()
tool.License.set_license(
file,
projects[0],
spdx_id=props.spdx_license_identifier,
copyright_notice=props.copyright_notice,
attribution_text=props.attribution_text,
source_url=props.source_url,
)
props.is_editing = False
ProjectLicenseData.is_loaded = False
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
class RemoveProjectLicense(Operator):
bl_idname = "bim.remove_project_license"
bl_label = "Remove Project License"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
file = tool.Ifc.get()
projects = file.by_type("IfcProject")
if not projects:
return {"CANCELLED"}
tool.License.remove_license(file, projects[0])
ProjectLicenseData.is_loaded = False
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
# ---------------------------------------------------------------------------
# Object-level license
# ---------------------------------------------------------------------------
class EnableEditingObjectLicense(Operator):
bl_idname = "bim.enable_editing_object_license"
bl_label = "Edit Object License"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = _get_props()
obj = context.active_object
if not obj:
return {"CANCELLED"}
element = tool.Ifc.get_entity(obj)
if not element:
return {"CANCELLED"}
existing = tool.License.get_pset(element)
if existing:
_populate_props_from_pset(props, existing)
else:
# Pre-populate from effective (inherited) license as a convenience
eff, _ = tool.License.get_effective_pset(element)
if eff:
_populate_props_from_pset(props, eff)
props.is_editing = True
return {"FINISHED"}
class DisableEditingObjectLicense(Operator):
bl_idname = "bim.disable_editing_object_license"
bl_label = "Cancel"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
_get_props().is_editing = False
return {"FINISHED"}
class EditObjectLicense(Operator):
bl_idname = "bim.edit_object_license"
bl_label = "Save Object License"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
obj = context.active_object
if not obj:
return {"CANCELLED"}
element = tool.Ifc.get_entity(obj)
if not element:
return {"CANCELLED"}
props = _get_props()
tool.License.set_license(
tool.Ifc.get(),
element,
spdx_id=props.spdx_license_identifier,
copyright_notice=props.copyright_notice,
attribution_text=props.attribution_text,
source_url=props.source_url,
)
props.is_editing = False
ObjectLicenseData.is_loaded = False
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
class RemoveObjectLicense(Operator):
bl_idname = "bim.remove_object_license"
bl_label = "Remove Object License"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
obj = context.active_object
if not obj:
return {"CANCELLED"}
element = tool.Ifc.get_entity(obj)
if not element:
return {"CANCELLED"}
tool.License.remove_license(tool.Ifc.get(), element)
ObjectLicenseData.is_loaded = False
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
@@ -0,0 +1,45 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
# AI-assisted development tool was used in writing this file.
from typing import TYPE_CHECKING
from bpy.props import BoolProperty, EnumProperty, StringProperty
from bpy.types import PropertyGroup
from bonsai.tool.license import SPDX_ENUM_ITEMS
class BIMLicenseProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
spdx_license_identifier: EnumProperty(
name="License",
items=SPDX_ENUM_ITEMS,
description="SPDX license identifier",
)
copyright_notice: StringProperty(name="Copyright Notice", description='e.g. "© 2026 Acme Architecture Ltd"')
attribution_text: StringProperty(name="Attribution Text", description="Text to use when crediting this work")
source_url: StringProperty(name="Source URL", description="URL to the original source or license text")
if TYPE_CHECKING:
is_editing: bool
spdx_license_identifier: str
copyright_notice: str
attribution_text: str
source_url: str
+137
View File
@@ -0,0 +1,137 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
# AI-assisted development tool was used in writing this file.
from __future__ import annotations
import bpy
from bpy.types import Panel
import bonsai.tool as tool
from bonsai.bim.module.license.data import ObjectLicenseData, ProjectLicenseData
def _draw_license_fields(layout: bpy.types.UILayout, props) -> None:
layout.prop(props, "spdx_license_identifier")
layout.prop(props, "copyright_notice")
layout.prop(props, "attribution_text")
layout.prop(props, "source_url")
def _draw_license_display(layout: bpy.types.UILayout, pset: dict) -> None:
spdx = pset.get("SpdxLicenseIdentifier") or ""
notice = pset.get("CopyrightNotice") or ""
attribution = pset.get("AttributionText") or ""
source = pset.get("SourceUrl") or ""
if spdx:
row = layout.row()
row.label(text=spdx, icon="COPYDOWN")
if notice:
row = layout.row()
row.label(text=notice, icon="USER")
if attribution:
row = layout.row()
row.label(text=attribution, icon="INFO")
if source:
row = layout.row(align=True)
row.label(text="Source", icon="URL")
row.operator("bim.open_uri", text=source, icon="LINKED").uri = source
class BIM_PT_project_license(Panel):
bl_label = "License"
bl_idname = "BIM_PT_project_license"
bl_options = {"DEFAULT_CLOSED"}
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_project_setup"
@classmethod
def poll(cls, context):
return tool.Ifc.get()
def draw(self, context):
if not ProjectLicenseData.is_loaded:
ProjectLicenseData.load()
layout = self.layout
props = context.scene.BIMLicenseProperties # type: ignore[attr-defined]
pset = ProjectLicenseData.data["license"]
if props.is_editing:
_draw_license_fields(layout, props)
row = layout.row(align=True)
row.operator("bim.edit_project_license", text="Save", icon="CHECKMARK")
row.operator("bim.disable_editing_project_license", text="", icon="CANCEL")
elif pset:
_draw_license_display(layout, pset)
row = layout.row(align=True)
row.operator("bim.enable_editing_project_license", text="Edit", icon="GREASEPENCIL")
row.operator("bim.remove_project_license", text="", icon="X")
else:
row = layout.row()
row.label(text="No license set", icon="QUESTION")
row.operator("bim.enable_editing_project_license", text="Set License", icon="ADD")
class BIM_PT_object_license(Panel):
bl_label = "License"
bl_idname = "BIM_PT_object_license"
bl_options = {"DEFAULT_CLOSED"}
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
bl_parent_id = "BIM_PT_tab_object_metadata"
@classmethod
def poll(cls, context):
return (obj := tool.Blender.get_active_object()) and tool.Ifc.get_entity(obj)
def draw(self, context):
if not ObjectLicenseData.is_loaded:
ObjectLicenseData.load()
layout = self.layout
props = context.scene.BIMLicenseProperties # type: ignore[attr-defined]
pset = ObjectLicenseData.data["license"]
inherited_from = ObjectLicenseData.data["inherited_from"]
obj = context.active_object
element = tool.Ifc.get_entity(obj) if obj else None
has_own_pset = bool(element and tool.License.get_pset(element))
if props.is_editing:
_draw_license_fields(layout, props)
row = layout.row(align=True)
row.operator("bim.edit_object_license", text="Save", icon="CHECKMARK")
row.operator("bim.disable_editing_object_license", text="", icon="CANCEL")
elif pset:
if inherited_from:
row = layout.row()
row.label(text=f"Inherited from: {inherited_from}", icon="LINKED")
_draw_license_display(layout, pset)
row = layout.row(align=True)
row.operator("bim.enable_editing_object_license", text="Override", icon="GREASEPENCIL")
if has_own_pset:
row.operator("bim.remove_object_license", text="", icon="X")
else:
row = layout.row()
row.label(text="No license set", icon="QUESTION")
row.operator("bim.enable_editing_object_license", text="Set License", icon="ADD")
@@ -629,14 +629,16 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
self.file = tool.Ifc.get()
library_file = IfcStore.library_file
assert library_file
library_element = library_file.by_id(self.definition)
element = ifcopenshell.api.project.append_asset(
self.file,
library=library_file,
element=library_file.by_id(self.definition),
element=library_element,
assume_asset_uniqueness_by_name=self.assume_unique_by_name,
)
if not element:
return {"FINISHED"}
tool.License.inherit_library_license(self.file, element, library_file, library_element)
if element.is_a("IfcTypeProduct"):
self.import_type_from_ifc(element, context)
elif element.is_a("IfcProduct"):
@@ -2539,6 +2541,7 @@ class AppendInspectedLinkedElement(AppendLibraryElement):
library=linked_ifc_file,
element=element_to_append,
)
tool.License.inherit_library_license(tool.Ifc.get(), element, linked_ifc_file, element_to_append)
self.import_product_from_ifc(element, context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type and tool.Ifc.get_object(element_type) is None:
+9
View File
@@ -190,6 +190,15 @@ class Classification:
def set_location(cls, classification): pass
@interface
class License:
def get_pset(cls, element): pass
def get_effective_pset(cls, element): pass
def set_license(cls, file, element, spdx_id, copyright_notice, attribution_text, source_url): pass
def remove_license(cls, file, element): pass
def inherit_library_license(cls, file, element, library, library_element): pass
@interface
class Collector:
def assign(cls, obj, should_clean_users_collection=False): pass
+1
View File
@@ -44,6 +44,7 @@ from bonsai.tool.group import Group
from bonsai.tool.ifc import Ifc
from bonsai.tool.ifcgit import IfcGit, IfcGitRepo
from bonsai.tool.layer import Layer
from bonsai.tool.license import License
from bonsai.tool.library import Library
from bonsai.tool.loader import Loader
from bonsai.tool.material import Material
+219
View File
@@ -0,0 +1,219 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
# AI-assisted development tool was used in writing this file.
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.element
import bonsai.core.tool
if TYPE_CHECKING:
pass
PSET_NAME = "BBIM_LicenseInformation"
# Curated SPDX identifiers appropriate for construction documentation.
# Format: (identifier, label, description)
SPDX_LICENSES: list[tuple[str, str, str]] = [
# Open content
("CC0-1.0", "CC0 1.0", "Public domain dedication — no rights reserved"),
("CC-BY-4.0", "CC BY 4.0", "Attribution required"),
("CC-BY-SA-4.0", "CC BY-SA 4.0", "Attribution + share-alike (copyleft)"),
("CC-BY-ND-4.0", "CC BY-ND 4.0", "Attribution, no derivatives"),
("CC-BY-NC-4.0", "CC BY-NC 4.0", "Attribution, non-commercial only"),
("CC-BY-NC-SA-4.0", "CC BY-NC-SA 4.0", "Attribution, non-commercial, share-alike"),
# Data
("ODbL-1.0", "ODbL 1.0", "Open Database License — for data-heavy BIM content"),
# Software / parametric content
("MIT", "MIT", "Permissive — suitable for parametric/procedural content"),
("Apache-2.0", "Apache 2.0", "Permissive with patent grant"),
("LGPL-2.1-or-later", "LGPL 2.1+", "Weak copyleft (IfcOpenShell's own license)"),
# Proprietary
("LicenseRef-Proprietary", "Proprietary", "All rights reserved — no reuse without permission"),
("LicenseRef-AllRightsReserved", "All Rights Reserved", "Explicit all-rights-reserved declaration"),
]
SPDX_ENUM_ITEMS: list[tuple[str, str, str]] = [(id_, label, desc) for id_, label, desc in SPDX_LICENSES]
def _get_pset_from_element(element: ifcopenshell.entity_instance) -> Optional[dict]:
"""Return raw pset dict from this element only, no inheritance."""
return ifcopenshell.util.element.get_pset(element, PSET_NAME, should_inherit=False)
def _walk_up(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
"""Return ancestor chain from element up to IfcProject (inclusive)."""
ancestors: list[ifcopenshell.entity_instance] = []
current = element
seen: set[int] = set()
while current is not None and current.id() not in seen:
seen.add(current.id())
ancestors.append(current)
if current.is_a("IfcProject"):
break
# Spatial containment
container = ifcopenshell.util.element.get_container(current, should_get_direct=True)
if container is not None:
current = container
continue
# Aggregation (e.g. element inside assembly, storey inside building)
aggregate = ifcopenshell.util.element.get_aggregate(current)
if aggregate is not None:
current = aggregate
continue
# Type objects may declare themselves to a project/library context
if hasattr(current, "HasContext"):
for rel in current.HasContext:
current = rel.RelatingContext
break
else:
break
continue
break
return ancestors
class License(bonsai.core.tool.License):
@classmethod
def get_pset(cls, element: ifcopenshell.entity_instance) -> Optional[dict]:
"""Return BBIM_LicenseInformation pset dict if directly on this element, else None."""
return _get_pset_from_element(element)
@classmethod
def get_effective_pset(
cls, element: ifcopenshell.entity_instance
) -> tuple[Optional[dict], Optional[ifcopenshell.entity_instance]]:
"""Walk up the spatial/aggregation hierarchy returning (pset_dict, source_entity).
The source entity is the element where the pset was actually found, so the
UI can indicate whether the license was inherited. Returns (None, None) if
no license is found anywhere in the chain.
"""
for ancestor in _walk_up(element):
pset = _get_pset_from_element(ancestor)
if pset:
return pset, ancestor
return None, None
@classmethod
def set_license(
cls,
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
spdx_id: str,
copyright_notice: str,
attribution_text: str = "",
source_url: str = "",
) -> None:
"""Create or update BBIM_LicenseInformation on element."""
props: dict = {"SpdxLicenseIdentifier": spdx_id, "CopyrightNotice": copyright_notice}
if attribution_text:
props["AttributionText"] = attribution_text
if source_url:
props["SourceUrl"] = source_url
existing = cls._get_pset_entity(file, element)
if existing:
ifcopenshell.api.pset.edit_pset(file, pset=existing, properties=props)
else:
pset = ifcopenshell.api.pset.add_pset(file, product=element, name=PSET_NAME)
ifcopenshell.api.pset.edit_pset(file, pset=pset, properties=props)
@classmethod
def remove_license(cls, file: ifcopenshell.file, element: ifcopenshell.entity_instance) -> None:
"""Remove BBIM_LicenseInformation pset from element."""
existing = cls._get_pset_entity(file, element)
if existing:
ifcopenshell.api.pset.remove_pset(file, product=element, pset=existing)
@classmethod
def _get_pset_entity(
cls, file: ifcopenshell.file, element: ifcopenshell.entity_instance
) -> Optional[ifcopenshell.entity_instance]:
"""Return the actual IfcPropertySet entity, or None."""
pset_data = _get_pset_from_element(element)
if not pset_data:
return None
return file.by_id(pset_data["id"])
@classmethod
def inherit_library_license(
cls,
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
library: ifcopenshell.file,
library_element: ifcopenshell.entity_instance,
) -> None:
"""After append_asset, stamp an inherited library license onto the element if it has none.
Checks, in order: the library element itself, its declaring IfcProjectLibrary,
then the library's IfcProject. The first match found is copied to the
imported element in the destination file.
"""
# If the element already has an explicit tag it traveled with the asset.
if _get_pset_from_element(element):
return
pset_data = cls._resolve_library_license(library, library_element)
if not pset_data:
return
spdx_id = pset_data.get("SpdxLicenseIdentifier", "")
copyright_notice = pset_data.get("CopyrightNotice", "")
if not spdx_id and not copyright_notice:
return
cls.set_license(
file,
element,
spdx_id=spdx_id,
copyright_notice=copyright_notice,
attribution_text=pset_data.get("AttributionText", "") or "",
source_url=pset_data.get("SourceUrl", "") or "",
)
@classmethod
def _resolve_library_license(
cls, library: ifcopenshell.file, library_element: ifcopenshell.entity_instance
) -> Optional[dict]:
"""Walk up inside the library file to find an effective license."""
# 1. Directly on the element (should already have traveled, but check anyway)
pset = _get_pset_from_element(library_element)
if pset:
return pset
# 2. Declaring IfcProjectLibrary context
if hasattr(library_element, "HasContext"):
for rel in library_element.HasContext:
pset = _get_pset_from_element(rel.RelatingContext)
if pset:
return pset
# 3. IfcProject
for project in library.by_type("IfcProject"):
pset = _get_pset_from_element(project)
if pset:
return pset
return None
+310
View File
@@ -0,0 +1,310 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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/>.
# AI-assisted development tool was used in writing this file.
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.element
import pytest
import bonsai.core.tool
from bonsai.tool.license import PSET_NAME
from bonsai.tool.license import License as subject
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture
def ifc():
return ifcopenshell.file(schema="IFC4")
def make_spatial_hierarchy(ifc):
"""Return (project, storey, wall) connected through full spatial chain."""
project = ifc.create_entity("IfcProject", Name="TestProject")
site = ifc.create_entity("IfcSite", Name="Site")
building = ifc.create_entity("IfcBuilding", Name="Building")
storey = ifc.create_entity("IfcBuildingStorey", Name="Ground Floor")
wall = ifc.create_entity("IfcWall", Name="W1")
ifc.create_entity(
"IfcRelAggregates", GlobalId=ifcopenshell.guid.new(), RelatingObject=project, RelatedObjects=[site]
)
ifc.create_entity(
"IfcRelAggregates", GlobalId=ifcopenshell.guid.new(), RelatingObject=site, RelatedObjects=[building]
)
ifc.create_entity(
"IfcRelAggregates", GlobalId=ifcopenshell.guid.new(), RelatingObject=building, RelatedObjects=[storey]
)
ifc.create_entity(
"IfcRelContainedInSpatialStructure",
GlobalId=ifcopenshell.guid.new(),
RelatingStructure=storey,
RelatedElements=[wall],
)
return project, storey, wall
# ---------------------------------------------------------------------------
# Interface
# ---------------------------------------------------------------------------
class TestImplementsTool:
def test_run(self):
assert isinstance(subject(), bonsai.core.tool.License)
# ---------------------------------------------------------------------------
# get_pset
# ---------------------------------------------------------------------------
class TestGetPset:
def test_returns_none_when_no_pset(self, ifc):
wall = ifc.create_entity("IfcWall")
assert subject.get_pset(wall) is None
def test_returns_pset_when_present(self, ifc):
wall = ifc.create_entity("IfcWall")
subject.set_license(ifc, wall, "CC-BY-4.0", "(c) 2026 Test")
result = subject.get_pset(wall)
assert result is not None
assert result["SpdxLicenseIdentifier"] == "CC-BY-4.0"
def test_does_not_inherit_from_type(self, ifc):
"""get_pset is direct-only; a pset on the type must not appear on the occurrence."""
wall_type = ifc.create_entity("IfcWallType", Name="WT")
wall = ifc.create_entity("IfcWall")
ifc.create_entity(
"IfcRelDefinesByType",
GlobalId=ifcopenshell.guid.new(),
RelatingType=wall_type,
RelatedObjects=[wall],
)
subject.set_license(ifc, wall_type, "CC0-1.0", "Public Domain")
assert subject.get_pset(wall) is None
# ---------------------------------------------------------------------------
# get_effective_pset
# ---------------------------------------------------------------------------
class TestGetEffectivePset:
def test_returns_none_when_no_license_anywhere(self, ifc):
_, _, wall = make_spatial_hierarchy(ifc)
pset, source = subject.get_effective_pset(wall)
assert pset is None
assert source is None
def test_returns_own_pset_with_self_as_source(self, ifc):
_, _, wall = make_spatial_hierarchy(ifc)
subject.set_license(ifc, wall, "MIT", "(c) 2026 Someone")
pset, source = subject.get_effective_pset(wall)
assert pset is not None
assert pset["SpdxLicenseIdentifier"] == "MIT"
assert source.id() == wall.id()
def test_inherits_from_project(self, ifc):
project, _, wall = make_spatial_hierarchy(ifc)
subject.set_license(ifc, project, "CC-BY-4.0", "(c) 2026 Corp")
pset, source = subject.get_effective_pset(wall)
assert pset["SpdxLicenseIdentifier"] == "CC-BY-4.0"
assert source.id() == project.id()
def test_own_pset_overrides_project(self, ifc):
project, _, wall = make_spatial_hierarchy(ifc)
subject.set_license(ifc, project, "CC-BY-4.0", "(c) 2026 Corp")
subject.set_license(ifc, wall, "CC-BY-SA-4.0", "(c) 2026 Other")
pset, source = subject.get_effective_pset(wall)
assert pset["SpdxLicenseIdentifier"] == "CC-BY-SA-4.0"
assert source.id() == wall.id()
def test_inherits_from_storey(self, ifc):
_, storey, wall = make_spatial_hierarchy(ifc)
subject.set_license(ifc, storey, "ODbL-1.0", "(c) 2026 Storey Owner")
pset, source = subject.get_effective_pset(wall)
assert pset["SpdxLicenseIdentifier"] == "ODbL-1.0"
assert source.id() == storey.id()
def test_stops_at_project(self, ifc):
project, _, _ = make_spatial_hierarchy(ifc)
subject.set_license(ifc, project, "CC0-1.0", "Public Domain")
pset, source = subject.get_effective_pset(project)
assert source.id() == project.id()
def test_type_without_own_pset(self, ifc):
wall_type = ifc.create_entity("IfcWallType", Name="WT")
pset, source = subject.get_effective_pset(wall_type)
assert pset is None
assert source is None
def test_type_with_own_pset(self, ifc):
wall_type = ifc.create_entity("IfcWallType", Name="WT")
subject.set_license(ifc, wall_type, "Apache-2.0", "(c) 2026 Firm")
pset, source = subject.get_effective_pset(wall_type)
assert pset["SpdxLicenseIdentifier"] == "Apache-2.0"
assert source.id() == wall_type.id()
# ---------------------------------------------------------------------------
# set_license
# ---------------------------------------------------------------------------
class TestSetLicense:
def test_creates_pset_on_occurrence(self, ifc):
wall = ifc.create_entity("IfcWall")
subject.set_license(ifc, wall, "CC-BY-4.0", "(c) 2026 Corp")
result = ifcopenshell.util.element.get_pset(wall, PSET_NAME, should_inherit=False)
assert result["SpdxLicenseIdentifier"] == "CC-BY-4.0"
assert result["CopyrightNotice"] == "(c) 2026 Corp"
def test_creates_pset_on_type(self, ifc):
wall_type = ifc.create_entity("IfcWallType", Name="WT")
subject.set_license(ifc, wall_type, "MIT", "(c) 2026 Lib")
result = ifcopenshell.util.element.get_pset(wall_type, PSET_NAME, should_inherit=False)
assert result["SpdxLicenseIdentifier"] == "MIT"
def test_creates_pset_on_project(self, ifc):
project = ifc.create_entity("IfcProject", Name="P")
subject.set_license(ifc, project, "CC0-1.0", "Public Domain")
result = ifcopenshell.util.element.get_pset(project, PSET_NAME, should_inherit=False)
assert result["SpdxLicenseIdentifier"] == "CC0-1.0"
def test_updates_existing_pset_in_place(self, ifc):
wall = ifc.create_entity("IfcWall")
subject.set_license(ifc, wall, "CC-BY-4.0", "First")
subject.set_license(ifc, wall, "MIT", "Second")
psets = [e for e in ifc.by_type("IfcPropertySet") if e.Name == PSET_NAME]
assert len(psets) == 1, "Should update in place, not duplicate"
result = ifcopenshell.util.element.get_pset(wall, PSET_NAME, should_inherit=False)
assert result["SpdxLicenseIdentifier"] == "MIT"
assert result["CopyrightNotice"] == "Second"
def test_optional_fields_stored_when_provided(self, ifc):
wall = ifc.create_entity("IfcWall")
subject.set_license(
ifc,
wall,
"CC-BY-4.0",
"(c) 2026 Corp",
attribution_text="Designed by Corp",
source_url="https://example.com",
)
result = ifcopenshell.util.element.get_pset(wall, PSET_NAME, should_inherit=False)
assert result["AttributionText"] == "Designed by Corp"
assert result["SourceUrl"] == "https://example.com"
def test_optional_fields_omitted_when_empty(self, ifc):
wall = ifc.create_entity("IfcWall")
subject.set_license(ifc, wall, "CC-BY-4.0", "(c) 2026 Corp")
result = ifcopenshell.util.element.get_pset(wall, PSET_NAME, should_inherit=False)
assert not result.get("AttributionText")
assert not result.get("SourceUrl")
# ---------------------------------------------------------------------------
# remove_license
# ---------------------------------------------------------------------------
class TestRemoveLicense:
def test_removes_pset(self, ifc):
wall = ifc.create_entity("IfcWall")
subject.set_license(ifc, wall, "CC-BY-4.0", "(c) 2026 Corp")
assert subject.get_pset(wall) is not None
subject.remove_license(ifc, wall)
assert subject.get_pset(wall) is None
def test_no_error_when_no_pset(self, ifc):
wall = ifc.create_entity("IfcWall")
subject.remove_license(ifc, wall) # Must not raise
# ---------------------------------------------------------------------------
# inherit_library_license
# ---------------------------------------------------------------------------
class TestInheritLibraryLicense:
def make_library(self, spdx_id, notice, on="project"):
"""Library with license on IfcProject (default) or on the type."""
lib = ifcopenshell.file(schema="IFC4")
lib_project = lib.create_entity("IfcProject", Name="Library")
wall_type = lib.create_entity("IfcWallType", Name="WAL01")
if on == "project":
subject.set_license(lib, lib_project, spdx_id, notice)
elif on == "type":
subject.set_license(lib, wall_type, spdx_id, notice)
return lib, wall_type
def test_stamps_project_license_onto_untagged_type(self, ifc):
lib, lib_type = self.make_library("CC0-1.0", "Public Domain", on="project")
dest_type = ifc.create_entity("IfcWallType", Name="WAL01")
subject.inherit_library_license(ifc, dest_type, lib, lib_type)
result = subject.get_pset(dest_type)
assert result is not None
assert result["SpdxLicenseIdentifier"] == "CC0-1.0"
assert result["CopyrightNotice"] == "Public Domain"
def test_does_not_overwrite_existing_tag(self, ifc):
"""An element already tagged (pset traveled via append_asset) must not be overwritten."""
lib, lib_type = self.make_library("CC0-1.0", "Public Domain", on="project")
dest_type = ifc.create_entity("IfcWallType", Name="WAL01")
subject.set_license(ifc, dest_type, "CC-BY-SA-4.0", "(c) 2026 Studio")
subject.inherit_library_license(ifc, dest_type, lib, lib_type)
assert subject.get_pset(dest_type)["SpdxLicenseIdentifier"] == "CC-BY-SA-4.0"
def test_no_license_in_library_does_nothing(self, ifc):
lib = ifcopenshell.file(schema="IFC4")
lib.create_entity("IfcProject", Name="Library")
lib_type = lib.create_entity("IfcWallType", Name="WAL01")
dest_type = ifc.create_entity("IfcWallType", Name="WAL01")
subject.inherit_library_license(ifc, dest_type, lib, lib_type)
assert subject.get_pset(dest_type) is None
def test_type_level_library_license_is_resolved(self, ifc):
"""License on the library type itself is also found."""
lib, lib_type = self.make_library("MIT", "(c) 2026 Lib", on="type")
dest_type = ifc.create_entity("IfcWallType", Name="WAL01")
subject.inherit_library_license(ifc, dest_type, lib, lib_type)
result = subject.get_pset(dest_type)
assert result is not None
assert result["SpdxLicenseIdentifier"] == "MIT"
def test_all_optional_fields_are_propagated(self, ifc):
lib = ifcopenshell.file(schema="IFC4")
lib_project = lib.create_entity("IfcProject", Name="Library")
lib_type = lib.create_entity("IfcWallType", Name="WAL01")
subject.set_license(
lib,
lib_project,
"CC-BY-4.0",
"(c) 2026 Corp",
attribution_text="Credit: Corp",
source_url="https://example.com/lib",
)
dest_type = ifc.create_entity("IfcWallType", Name="WAL01")
subject.inherit_library_license(ifc, dest_type, lib, lib_type)
result = subject.get_pset(dest_type)
assert result["AttributionText"] == "Credit: Corp"
assert result["SourceUrl"] == "https://example.com/lib"