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"