From a072c72666a4ea30b036107eb33c9d6de33cf619 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Tue, 7 Apr 2026 23:53:05 +0100 Subject: [PATCH] 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. --- src/bonsai/bonsai/bim/__init__.py | 1 + .../bonsai/bim/module/license/__init__.py | 45 +++ src/bonsai/bonsai/bim/module/license/data.py | 77 +++++ .../bonsai/bim/module/license/operator.py | 200 +++++++++++ src/bonsai/bonsai/bim/module/license/prop.py | 45 +++ src/bonsai/bonsai/bim/module/license/ui.py | 137 ++++++++ .../bonsai/bim/module/project/operator.py | 5 +- src/bonsai/bonsai/core/tool.py | 9 + src/bonsai/bonsai/tool/__init__.py | 1 + src/bonsai/bonsai/tool/license.py | 219 +++++++++++++ src/bonsai/test/tool/test_license.py | 310 ++++++++++++++++++ 11 files changed, 1048 insertions(+), 1 deletion(-) create mode 100644 src/bonsai/bonsai/bim/module/license/__init__.py create mode 100644 src/bonsai/bonsai/bim/module/license/data.py create mode 100644 src/bonsai/bonsai/bim/module/license/operator.py create mode 100644 src/bonsai/bonsai/bim/module/license/prop.py create mode 100644 src/bonsai/bonsai/bim/module/license/ui.py create mode 100644 src/bonsai/bonsai/tool/license.py create mode 100644 src/bonsai/test/tool/test_license.py diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 4302739aab..3ba0b23268 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -70,6 +70,7 @@ modules = { "pset": None, "qto": None, "classification": None, + "license": None, "library": None, "constraint": None, "document": None, diff --git a/src/bonsai/bonsai/bim/module/license/__init__.py b/src/bonsai/bonsai/bim/module/license/__init__.py new file mode 100644 index 0000000000..5638e47c81 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/license/__init__.py @@ -0,0 +1,45 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . + +# 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 diff --git a/src/bonsai/bonsai/bim/module/license/data.py b/src/bonsai/bonsai/bim/module/license/data.py new file mode 100644 index 0000000000..b62a5cb68e --- /dev/null +++ b/src/bonsai/bonsai/bim/module/license/data.py @@ -0,0 +1,77 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . + +# 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() diff --git a/src/bonsai/bonsai/bim/module/license/operator.py b/src/bonsai/bonsai/bim/module/license/operator.py new file mode 100644 index 0000000000..64acdbdf2f --- /dev/null +++ b/src/bonsai/bonsai/bim/module/license/operator.py @@ -0,0 +1,200 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . + +# 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"} diff --git a/src/bonsai/bonsai/bim/module/license/prop.py b/src/bonsai/bonsai/bim/module/license/prop.py new file mode 100644 index 0000000000..4e2f807336 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/license/prop.py @@ -0,0 +1,45 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . + +# 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 diff --git a/src/bonsai/bonsai/bim/module/license/ui.py b/src/bonsai/bonsai/bim/module/license/ui.py new file mode 100644 index 0000000000..00c3fe77d1 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/license/ui.py @@ -0,0 +1,137 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . + +# 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") diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index e24f6f41d8..c6f2107652 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -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: diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index e6a60c9deb..bd2b468641 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -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 diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 06e498e8be..eb2616b92a 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -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 diff --git a/src/bonsai/bonsai/tool/license.py b/src/bonsai/bonsai/tool/license.py new file mode 100644 index 0000000000..2afbd0861c --- /dev/null +++ b/src/bonsai/bonsai/tool/license.py @@ -0,0 +1,219 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . + +# 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 diff --git a/src/bonsai/test/tool/test_license.py b/src/bonsai/test/tool/test_license.py new file mode 100644 index 0000000000..74d7251e6a --- /dev/null +++ b/src/bonsai/test/tool/test_license.py @@ -0,0 +1,310 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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 . + +# 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"