diff --git a/src/bonsai/bonsai/bim/module/bsdd/__init__.py b/src/bonsai/bonsai/bim/module/bsdd/__init__.py index ffa451915b..6b14130fe1 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/__init__.py +++ b/src/bonsai/bonsai/bim/module/bsdd/__init__.py @@ -21,14 +21,13 @@ from . import ui, prop, operator classes = ( operator.GetBSDDClassificationProperties, - operator.LoadBSDDDomains, + operator.LoadBSDDDictionaries, operator.SearchBSDDClass, - operator.SetActiveBSDDDictionary, - prop.BSDDDomain, + prop.BSDDDictionary, prop.BSDDClassification, prop.BSDDPset, prop.BIMBSDDProperties, - ui.BIM_UL_bsdd_domains, + ui.BIM_UL_bsdd_dictionaries, ui.BIM_UL_bsdd_classifications, ui.BIM_PT_bsdd, ) diff --git a/src/bonsai/bonsai/bim/module/bsdd/data.py b/src/bonsai/bonsai/bim/module/bsdd/data.py new file mode 100644 index 0000000000..513ca2de8b --- /dev/null +++ b/src/bonsai/bonsai/bim/module/bsdd/data.py @@ -0,0 +1,45 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2022 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 . + +import bpy +import ifcopenshell +import ifcopenshell.util.date +import ifcopenshell.util.classification +import bonsai.tool as tool +from bonsai.bim.ifc import IfcStore + + +def refresh(): + BSDDData.is_loaded = False + + +class BSDDData: + data = {} + is_loaded = False + + @classmethod + def load(cls): + cls.is_loaded = True + cls.data["active_dictionary"] = cls.active_dictionary() + + @classmethod + def active_dictionary(cls): + props = tool.Bsdd.get_bsdd_props() + results = [("ALL", "All Dictionaries", "All active dictionaries")] + results.extend([(d.uri, d.name, f"{d.status} - {d.version}") for d in props.dictionaries if d.is_active]) + return results diff --git a/src/bonsai/bonsai/bim/module/bsdd/operator.py b/src/bonsai/bonsai/bim/module/bsdd/operator.py index 3ae6e15493..d9df26349a 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/operator.py +++ b/src/bonsai/bonsai/bim/module/bsdd/operator.py @@ -22,25 +22,13 @@ import bonsai.tool as tool from bonsai.core import bsdd as core -class LoadBSDDDomains(bpy.types.Operator): - bl_idname = "bim.load_bsdd_domains" +class LoadBSDDDictionaries(bpy.types.Operator): + bl_idname = "bim.load_bsdd_dictionaries" bl_label = "Load bSDD Dictionaries" bl_options = {"REGISTER", "UNDO"} def execute(self, context): - core.load_bsdd(bsdd.Client(), tool.Bsdd) - return {"FINISHED"} - - -class SetActiveBSDDDictionary(bpy.types.Operator): - bl_idname = "bim.set_active_bsdd_domain" - bl_label = "Set bSDD Dictionary as active" - bl_options = {"REGISTER", "UNDO"} - name: bpy.props.StringProperty() - uri: bpy.props.StringProperty() - - def execute(self, context): - core.set_active_bsdd_dictionary(self.name, self.uri, tool.Bsdd) + core.load_bsdd(tool.Bsdd) return {"FINISHED"} @@ -50,17 +38,9 @@ class SearchBSDDClass(bpy.types.Operator): bl_description = "Search for bSDD classes by the provided keyword" bl_options = {"REGISTER", "UNDO"} - @classmethod - def poll(cls, context): - # Requirement by buildingSMART bSDD api. - if len(context.scene.BIMBSDDProperties.keyword) < 3: - cls.poll_message_set("Search query has to be at least 3 characters long.") - return False - return True - def execute(self, context): keyword = context.scene.BIMBSDDProperties.keyword - classes_found = core.search_class(keyword, bsdd.Client(), tool.Bsdd) + classes_found = core.search_class(keyword, tool.Bsdd) self.report({"INFO"}, f"{classes_found} bSDD classes found for '{keyword}'.") return {"FINISHED"} @@ -72,7 +52,7 @@ class GetBSDDClassificationProperties(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - pset_data = core.get_class_properties(bsdd.Client(), tool.Bsdd) + pset_data = core.get_class_properties(tool.Bsdd) psets_found = len(pset_data) props_found = sum(len(pset) for pset in pset_data.values()) self.report({"INFO"}, f"{psets_found} psets found ({props_found} props) for the active classification.") diff --git a/src/bonsai/bonsai/bim/module/bsdd/prop.py b/src/bonsai/bonsai/bim/module/bsdd/prop.py index 5a6090b12b..72fa337e00 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/prop.py +++ b/src/bonsai/bonsai/bim/module/bsdd/prop.py @@ -18,6 +18,7 @@ import bpy from bpy.types import PropertyGroup +from bonsai.bim.module.bsdd.data import BSDDData from bonsai.bim.prop import Attribute, StrProperty from bpy.props import ( PointerProperty, @@ -31,21 +32,34 @@ from bpy.props import ( ) -class BSDDDomain(PropertyGroup): +def get_active_dictionary(self, context): + if not BSDDData.is_loaded: + BSDDData.load() + return BSDDData.data["active_dictionary"] + + +def update_is_active(self: "BSDDDictionary", context: bpy.types.Context) -> None: + BSDDData.data["active_dictionary"] = BSDDData.active_dictionary() + + +class BSDDDictionary(PropertyGroup): name: StringProperty(name="Name") uri: StringProperty(name="URI") default_language_code: StringProperty(name="Language") organization_name_owner: StringProperty(name="Organization") status: StringProperty(name="Status") version: StringProperty(name="Version") + is_active: BoolProperty( + name="Is Active", description="Enable to search with this dictionary", default=False, update=update_is_active + ) class BSDDClassification(PropertyGroup): name: StringProperty(name="Name") reference_code: StringProperty(name="Reference Code") uri: StringProperty(name="Namespace URI") - domain_name: StringProperty(name="Domain Name") - domain_namespace_uri: StringProperty(name="Domain Namespace URI") + dictionary_name: StringProperty(name="Dictionary Name") + dictionary_namespace_uri: StringProperty(name="Dictionary Namespace URI") class BSDDPset(PropertyGroup): @@ -54,10 +68,11 @@ class BSDDPset(PropertyGroup): class BIMBSDDProperties(PropertyGroup): - active_domain: StringProperty(name="Active Domain") + active_dictionary: StringProperty(name="Active Dictionary") + active_dictionary: EnumProperty(items=get_active_dictionary, name="Active Dictionary") active_uri: StringProperty(name="Active URI") - domains: CollectionProperty(name="Domains", type=BSDDDomain) - active_domain_index: IntProperty(name="Active Domain Index") + dictionaries: CollectionProperty(name="Dictionaries", type=BSDDDictionary) + active_dictionary_index: IntProperty(name="Active Dictionary Index") classifications: CollectionProperty(name="Classifications", type=BSDDClassification) active_classification_index: IntProperty(name="Active Classification Index") keyword: StringProperty(name="Keyword", description="Query for bsdd classes search, case and accent insensitive") @@ -71,7 +86,13 @@ class BIMBSDDProperties(PropertyGroup): description="Whether to display and assign only properties from IFC dictionary", default=True, ) - load_preview_domains: BoolProperty( - name="Load Preview Domains", description="Whether it should load preview and inactive domains", default=False + load_preview_dictionaries: BoolProperty( + name="Load Preview Dictionaries", description="Load dictionaries marked as Preview status", default=False + ) + load_inactive_dictionaries: BoolProperty( + name="Load Inactive Dictionaries", description="Load dictionaries marked as Inactive status", default=False + ) + load_test_dictionaries: BoolProperty( + name="Load Test Dictionaries", description="Load dictionaries that are for testing only", default=False ) classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset) diff --git a/src/bonsai/bonsai/bim/module/bsdd/ui.py b/src/bonsai/bonsai/bim/module/bsdd/ui.py index 046bc46fec..b4a7e96901 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/ui.py +++ b/src/bonsai/bonsai/bim/module/bsdd/ui.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bonsai.tool as tool +from bonsai.bim.module.bsdd.data import BSDDData from bpy.types import Panel, UIList @@ -30,65 +31,54 @@ class BIM_PT_bsdd(Panel): bl_parent_id = "BIM_PT_tab_project_setup" def draw(self, context): + if not BSDDData.is_loaded: + BSDDData.load() props = context.scene.BIMBSDDProperties layout = self.layout - row = self.layout.row(align=True) - row.prop(props, "load_preview_domains") - if len(props.domains): - row.operator("bim.load_bsdd_domains", text="", icon="FILE_REFRESH") - - if props.active_domain: + if len(props.dictionaries): row = self.layout.row() - row.label(text="Active: " + props.active_domain, icon="URL") - else: - row = self.layout.row() - row.label(text="No Active bSDD Domain", icon="ERROR") + row.operator("bim.load_bsdd_dictionaries", icon="FILE_REFRESH") - if len(props.domains): + if len(props.dictionaries): self.layout.template_list( - "BIM_UL_bsdd_domains", + "BIM_UL_bsdd_dictionaries", "", props, - "domains", + "dictionaries", props, - "active_domain_index", + "active_dictionary_index", ) - if 0 <= props.active_domain_index < len(props.domains): - selected_domain = props.domains[props.active_domain_index] + if 0 <= props.active_dictionary_index < len(props.dictionaries): + selected_dictionary = props.dictionaries[props.active_dictionary_index] else: - selected_domain = None + selected_dictionary = None - if selected_domain: - layout.label(text="Selected domain:") + if selected_dictionary: + layout.label(text="Selected dictionary:") box = layout.box() row = box.row(align=True) row.label(text="Language") - row.label(text=selected_domain.default_language_code) + row.label(text=selected_dictionary.default_language_code) row = box.row(align=True) row.label(text="Version") - row.label(text=selected_domain.version) - box.operator("bim.open_uri", text="Open bSDD In Browser", icon="URL").uri = selected_domain.uri + row.label(text=selected_dictionary.version) + box.operator("bim.open_uri", text="Open bSDD In Browser", icon="URL").uri = selected_dictionary.uri else: row = self.layout.row() - row.operator("bim.load_bsdd_domains") + row.operator("bim.load_bsdd_dictionaries") -class BIM_UL_bsdd_domains(UIList): +class BIM_UL_bsdd_dictionaries(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if item: props = context.scene.BIMBSDDProperties row = layout.row(align=True) if item.status != "Active": - row.label(text=f"{item.name} ({item.organization_name_owner}) - {item.status}", icon="ERROR") + row.label(text=f"{item.name} ({item.organization_name_owner}) v{item.version} - {item.status}", icon="ERROR") else: - row.label(text=f"{item.name} ({item.organization_name_owner})") - if item.uri == props.active_uri: - row.label(text="", icon="URL") - else: - op = row.operator("bim.set_active_bsdd_domain", text="", icon="RESTRICT_SELECT_OFF") - op.name = item.name - op.uri = item.uri + row.label(text=f"{item.name} ({item.organization_name_owner}) v{item.version}") + row.prop(item, "is_active", icon="CHECKBOX_HLT" if item.is_active else "CHECKBOX_DEHLT", text="", emboss=False) class BIM_UL_bsdd_classifications(UIList): diff --git a/src/bonsai/bonsai/bim/module/classification/operator.py b/src/bonsai/bonsai/bim/module/classification/operator.py index ba9f9d1928..568904afd0 100644 --- a/src/bonsai/bonsai/bim/module/classification/operator.py +++ b/src/bonsai/bonsai/bim/module/classification/operator.py @@ -116,28 +116,26 @@ class AddClassificationFromBSDD(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = context.scene.BIMBSDDProperties - domain = next((d for d in props.domains if d.name == props.active_domain and d.uri == props.active_uri), None) - - # Maybe user loaded preview domains, set it as active - # and then reloaded them without preview domains. - if not domain: - self.report( - {"INFO"}, - f"Couldn't find domain '{props.active_domain}' ({props.active_uri}). Try to reload bSDD dictionaries.", + if props.active_dictionary == "ALL": + dictionaries = [d.uri for d in props.dictionaries if d.is_active] + else: + dictionaries = [props.active_dictionary] + for uri in dictionaries: + if not (dictionary := tool.Bsdd.get_dictionary(uri)): + continue + has_classification = False + for element in tool.Ifc.get().by_type("IfcClassification"): + if element.Name == dictionary["name"] or (tool.Classification.get_location(element) == dictionary["uri"]): + self.report({"INFO"}, f"Classification '{dictionary['name']}' is already added to the project.") + has_classification = True + if has_classification: + continue + classification = ifcopenshell.api.run( + "classification.add_classification", tool.Ifc.get(), classification=dictionary["name"] ) - return - - for element in tool.Ifc.get().by_type("IfcClassification"): - if element.Name == props.active_domain or (tool.Classification.get_location(element) == domain.uri): - self.report({"INFO"}, f"Classification '{props.active_domain}' is already added to the project.") - return - - classification = ifcopenshell.api.run( - "classification.add_classification", tool.Ifc.get(), classification=props.active_domain - ) - classification.Source = domain.organization_name_owner - classification.Edition = domain.version - tool.Classification.set_location(classification, domain.uri) + classification.Source = dictionary["organizationNameOwner"] + classification.Edition = dictionary["version"] + tool.Classification.set_location(classification, dictionary["uri"]) class EnableAddingManualClassification(bpy.types.Operator): @@ -420,17 +418,17 @@ class AddClassificationReferenceFromBSDD(bpy.types.Operator, tool.Ifc.Operator): classification = None for element in tool.Ifc.get().by_type("IfcClassification"): - if element.Name == bsdd_classification.domain_name or ( - tool.Classification.get_location(element) == bsdd_classification.domain_namespace_uri + if element.Name == bsdd_classification.dictionary_name or ( + tool.Classification.get_location(element) == bsdd_classification.dictionary_namespace_uri ): classification = element break if not classification: classification = ifcopenshell.api.run( - "classification.add_classification", tool.Ifc.get(), classification=bsdd_classification.domain_name + "classification.add_classification", tool.Ifc.get(), classification=bsdd_classification.dictionary_name ) - tool.Classification.set_location(classification, bsdd_classification.domain_namespace_uri) + tool.Classification.set_location(classification, bsdd_classification.dictionary_namespace_uri) for obj in objects: ifc_definition_id = tool.Blender.get_obj_ifc_definition_id(obj, self.obj_type, context) diff --git a/src/bonsai/bonsai/bim/module/classification/ui.py b/src/bonsai/bonsai/bim/module/classification/ui.py index 4b9081c3a8..7816828857 100644 --- a/src/bonsai/bonsai/bim/module/classification/ui.py +++ b/src/bonsai/bonsai/bim/module/classification/ui.py @@ -78,13 +78,8 @@ class BIM_PT_classifications(Panel): def draw_add_bsdd_ui(self, context): self.bprops = context.scene.BIMBSDDProperties - if not self.bprops.active_domain: - row = self.layout.row() - row.label(text="No Active bSDD Domain", icon="ERROR") - return - row = self.layout.row() - row.label(text="Active: " + self.bprops.active_domain, icon="URL") + row.prop(self.bprops, "active_dictionary", text="") row = self.layout.row() row.operator("bim.add_classification_from_bsdd", icon="ADD") @@ -163,13 +158,8 @@ class ReferenceUI: row.operator("bim.enable_adding_manual_classification_reference", text="Add Reference", icon="ADD") def draw_add_bsdd_ui(self, context): - if not self.bprops.active_domain: - row = self.layout.row() - row.label(text="No Active bSDD Domain", icon="ERROR") - return - row = self.layout.row() - row.label(text="Active: " + self.bprops.active_domain, icon="URL") + row.prop(self.bprops, "active_dictionary", text="") row = self.layout.row(align=True) row.prop(self.bprops, "keyword", text="") diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 5d22902d60..639216b6ee 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -435,6 +435,10 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): props = tool.Project.get_project_props() layout.prop(props, "should_disable_undo_on_save") layout.prop(props, "should_stream") + bprops = tool.Bsdd.get_bsdd_props() + layout.prop(bprops, "load_preview_dictionaries") + layout.prop(bprops, "load_inactive_dictionaries") + layout.prop(bprops, "load_test_dictionaries") # Scene panel groups diff --git a/src/bonsai/bonsai/core/bsdd.py b/src/bonsai/bonsai/core/bsdd.py index 5f8c287ae7..1523e08f81 100644 --- a/src/bonsai/bonsai/core/bsdd.py +++ b/src/bonsai/bonsai/core/bsdd.py @@ -26,9 +26,9 @@ if TYPE_CHECKING: import bonsai.tool as tool -def get_class_properties(client: bsdd.Client, bsdd: tool.Bsdd) -> dict[str, dict[str, Any]]: +def get_class_properties(bsdd: tool.Bsdd) -> dict[str, dict[str, Any]]: bsdd.clear_class_psets() - data = bsdd.get_active_class_data(client) + data = bsdd.get_active_class_data() pset_dict = bsdd.get_property_dict(data) if pset_dict is None: return {} @@ -36,23 +36,12 @@ def get_class_properties(client: bsdd.Client, bsdd: tool.Bsdd) -> dict[str, dict return pset_dict -def load_bsdd(client: bsdd.Client, bsdd: tool.Bsdd) -> None: - bsdd.clear_domains() - if bsdd.should_load_preview_domains(): - dictionaries = bsdd.get_dictionaries(client) - else: - dictionaries = bsdd.get_dictionaries(client, "Active") - bsdd.create_dictionaries(dictionaries) +def load_bsdd(bsdd: tool.Bsdd) -> None: + bsdd.clear_dictionaries() + bsdd.create_dictionaries(bsdd.get_dictionaries()) -def search_class(keyword: str, client: bsdd.Client, bsdd: tool.Bsdd) -> int: +def search_class(keyword: str, bsdd: tool.Bsdd) -> int: bsdd.clear_classes() related_entities = bsdd.get_related_ifc_entities() - active_dictionary_uri = bsdd.get_active_dictionary_uri() - classes = bsdd.search_class(client, keyword, [active_dictionary_uri], related_entities) - bsdd.create_classes(classes) - return len(classes) - - -def set_active_bsdd_dictionary(name: str, uri: str, bsdd: tool.Bsdd) -> None: - bsdd.set_active_bsdd(name, uri) + return bsdd.search_class(keyword, related_entities) diff --git a/src/bonsai/bonsai/tool/bsdd.py b/src/bonsai/bonsai/tool/bsdd.py index 8f1d1a1fd7..d455a6f21f 100644 --- a/src/bonsai/bonsai/tool/bsdd.py +++ b/src/bonsai/bonsai/tool/bsdd.py @@ -3,10 +3,16 @@ import bonsai.tool as tool import bpy import json import bsdd -from typing import Any, Union, Optional +from typing import Any, Union, Optional, TYPE_CHECKING class Bsdd(bonsai.core.tool.Bsdd): + client = bsdd.Client() + + @classmethod + def get_bsdd_props(cls): + return bpy.context.scene.BIMBSDDProperties + @classmethod def clear_class_psets(cls) -> None: bpy.context.scene.BIMBSDDProperties.classification_psets.clear() @@ -16,8 +22,8 @@ class Bsdd(bonsai.core.tool.Bsdd): bpy.context.scene.BIMBSDDProperties.classifications.clear() @classmethod - def clear_domains(cls) -> None: - bpy.context.scene.BIMBSDDProperties.domains.clear() + def clear_dictionaries(cls) -> None: + bpy.context.scene.BIMBSDDProperties.dictionaries.clear() @classmethod def create_class_psets(cls, pset_dict: dict[str, dict[str, Any]]) -> None: @@ -42,22 +48,11 @@ class Bsdd(bonsai.core.tool.Bsdd): new2.ifc_class = data["ifc_class"] new2.metadata = data["dictionary"] - @classmethod - def create_classes(cls, class_dict: list[bsdd.ClassSearchResponseClassContractV1]) -> None: - props = bpy.context.scene.BIMBSDDProperties - for _class in sorted(class_dict, key=lambda c: c["referenceCode"]): - prop = props.classifications.add() - prop.name = _class["name"] - prop.reference_code = _class["referenceCode"] - prop.uri = _class["uri"] - prop.domain_name = _class["dictionaryName"] - prop.domain_namespace_uri = _class["dictionaryUri"] - @classmethod def create_dictionaries(cls, dictionaries: list[bsdd.DictionaryContractV1]) -> None: props = bpy.context.scene.BIMBSDDProperties for dictionary in sorted(dictionaries, key=lambda d: d["name"]): - new = props.domains.add() + new = props.dictionaries.add() new.name = dictionary["name"] new.uri = dictionary["uri"] new.default_language_code = dictionary["defaultLanguageCode"] @@ -66,24 +61,35 @@ class Bsdd(bonsai.core.tool.Bsdd): new.version = dictionary["version"] @classmethod - def get_active_class_data(cls, client: bsdd.Client) -> Union[bsdd.ClassContractV1, dict]: + def get_active_class_data(cls) -> Union[bsdd.ClassContractV1, dict]: prop = bpy.context.scene.BIMBSDDProperties bsdd_classification = prop.classifications[prop.active_classification_index] if not bsdd_classification: return {} - return client.get_class(bsdd_classification.uri) + return cls.client.get_class(bsdd_classification.uri) @classmethod def get_active_dictionary_uri(cls) -> str: return bpy.context.scene.BIMBSDDProperties.active_uri @classmethod - def get_dictionaries(cls, client: bsdd.Client, status: Optional[str] = None) -> list[bsdd.DictionaryContractV1]: - response = client.get_dictionary() + def get_dictionary(cls, uri: str) -> bsdd.DictionaryContractV1: + props = bpy.context.scene.BIMBSDDProperties + response = cls.client.get_dictionary(dictionary_uri=uri, include_test_dictionaries=props.load_test_dictionaries) + if dicts := response.get("dictionaries"): + return dicts[0] + + @classmethod + def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]: + props = bpy.context.scene.BIMBSDDProperties + response = cls.client.get_dictionary(include_test_dictionaries=props.load_test_dictionaries) dicts = response.get("dictionaries") or [] - if status is not None: - dicts = list(filter(lambda d: d["status"] == status, dicts)) - return dicts + statuses = ["Active"] + if props.load_preview_dictionaries: + statuses.append("Preview") + if props.load_inactive_dictionaries: + statuses.append("Inactive") + return list(filter(lambda d: d["status"] in statuses, dicts)) @classmethod def get_property_dict(cls, class_data: Union[bsdd.ClassContractV1, dict]) -> Union[dict[str, dict[str, Any]], None]: @@ -133,31 +139,51 @@ class Bsdd(bonsai.core.tool.Bsdd): @classmethod def search_class( cls, - client: bsdd.Client, keyword: str, - dictionary_uris: Union[list[str], None], related_ifc_entities: Union[list[str], None], offset: int = 0, - ) -> list[bsdd.ClassSearchResponseClassContractV1]: - response = client.search_class( - keyword, dictionary_uris=dictionary_uris, related_ifc_entities=related_ifc_entities, offset=offset + limit: int = 100, + should_paginate: bool = True, + ): + props = cls.get_bsdd_props() + dictionary_uris = ( + [d.uri for d in props.dictionaries if d.is_active] + if props.active_dictionary == "ALL" + else [props.active_dictionary] ) - classes = response.get("classes", []) - # If count is 100, it might be hitting the limit. - if response["count"] == 100: - classes += cls.search_class(client, keyword, dictionary_uris, related_ifc_entities, offset=offset + 100) - return classes + for dictionary_uri in dictionary_uris: + response = cls.client.get_classes( + dictionary_uri=dictionary_uri, + use_nested_classes=False, + search_text=keyword, + related_ifc_entity=related_ifc_entities[0] if related_ifc_entities else None, + offset=offset, + limit=limit, + ) + dictionary_name = response.get("name", "") + dictionary_namespace_uri = response.get("uri", "") + for _class in sorted(response.get("classes", []), key=lambda c: c["referenceCode"]): + prop = props.classifications.add() + prop.name = _class["name"] + prop.reference_code = _class["referenceCode"] + prop.uri = _class["uri"] + prop.dictionary_name = dictionary_name + prop.dictionary_namespace_uri = dictionary_namespace_uri + + total_results = response.get("count", response.get("classesCount")) + # For now, hard limit at 1000 results because any more and Blender + # starts getting slow and they really should filter better + if offset < 1000 and should_paginate and total_results == limit: + cls.search_class(keyword, related_ifc_entities, offset=offset + limit, should_paginate=False) + + return offset + total_results @classmethod def set_active_bsdd(cls, name: str, uri: str) -> None: props = bpy.context.scene.BIMBSDDProperties - props.active_domain = name + props.active_dictionary = name props.active_uri = uri @classmethod def should_filter_ifc_class(cls) -> bool: return bpy.context.scene.BIMBSDDProperties.should_filter_ifc_class - - @classmethod - def should_load_preview_domains(cls) -> bool: - return bpy.context.scene.BIMBSDDProperties.load_preview_domains diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini index 03ca7a5dcd..94a75aaa5a 100644 --- a/src/bonsai/pytest.ini +++ b/src/bonsai/pytest.ini @@ -4,6 +4,7 @@ markers = attribute boolean brick + bsdd classification context cost diff --git a/src/bonsai/test/bim/feature/bsdd.feature b/src/bonsai/test/bim/feature/bsdd.feature new file mode 100644 index 0000000000..9deb49621b --- /dev/null +++ b/src/bonsai/test/bim/feature/bsdd.feature @@ -0,0 +1,34 @@ +@bsdd +Feature: bSDD + +Scenario: Load bSDD dictionaries + Given an empty Blender session + And I look at the "buildingSMART Data Dictionary" panel + When I click "Load bSDD Dictionaries" + Then I see "Selected dictionary" + And I see "LCA" in the "1st" list + +Scenario: Add dictionaries as classification systems + Given an empty IFC project + And I look at the "buildingSMART Data Dictionary" panel + And I click "Load bSDD Dictionaries" + When I click "is_active" in the row where I see "LCA" in the "1st" list + And I click "is_active" in the row where I see "BonsaiTestDict" in the "1st" list + And I look at the "Classifications" panel + And I set the "classification_source" property to "buildingSMART Data Dictionary" + And I set the "active_dictionary" property to "All Dictionaries" + And I click "Add Classification From bSDD" + Then I see "LCA" + And I see "BonsaiTestDict" + +Scenario: Add a single dictionary as a classification systems + Given an empty IFC project + And I look at the "buildingSMART Data Dictionary" panel + And I click "Load bSDD Dictionaries" + When I click "is_active" in the row where I see "BonsaiTestDict" in the "1st" list + And I look at the "Classifications" panel + And I set the "classification_source" property to "buildingSMART Data Dictionary" + And I set the "active_dictionary" property to "BonsaiTestDict" + And I click "Add Classification From bSDD" + Then I see "BonsaiTestDict" + And I don't see "LCA" diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 6409d33acf..ba3caa26d9 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -50,6 +50,67 @@ variables = { webbrowser.open = lambda x: True +class bSDDClientStub: + def get_dictionary(self, dictionary_uri=None, include_test_dictionaries=False): + dicts = { + "dictionaries": [ + { + "availableLanguages": [{"code": "EN", "name": "English"}], + "code": "LCA", + "uri": "https://identifier.buildingsmart.org/uri/LCA/LCA/3.0", + "name": "LCA indicators and modules", + "version": "3.0", + "organizationCodeOwner": "LCA", + "organizationNameOwner": "buildingSMART Sustainability Strategic Group", + "defaultLanguageCode": "EN", + "isLatestVersion": True, + "isVerified": False, + "isPrivate": False, + "license": "No license (rights reserved)", + "licenseUrl": "https://technical.buildingsmart.org/services/bsdd/license/", + "qualityAssuranceProcedure": "EN ISO 23386:2020", + "status": "Active", + "moreInfoUrl": "https://www.lignum.ch/leistungen/projekte/buildingsmart-data-dictionary-bsdd/", + "releaseDate": "2023-12-01T14:14:19Z", + "lastUpdatedUtc": "2023-12-01T14:17:53Z", + }, + { + "availableLanguages": [{"code": "EN", "name": "English"}], + "code": "BonsaiTestDict", + "uri": "https://identifier.buildingsmart.org/uri/BonsaiTestDict", + "name": "BonsaiTestDict", + "version": "3.0", + "organizationCodeOwner": "LCA", + "organizationNameOwner": "buildingSMART Sustainability Strategic Group", + "defaultLanguageCode": "EN", + "isLatestVersion": True, + "isVerified": False, + "isPrivate": False, + "license": "No license (rights reserved)", + "licenseUrl": "https://technical.buildingsmart.org/services/bsdd/license/", + "qualityAssuranceProcedure": "EN ISO 23386:2020", + "status": "Active", + "moreInfoUrl": "https://www.lignum.ch/leistungen/projekte/buildingsmart-data-dictionary-bsdd/", + "releaseDate": "2023-12-01T14:14:19Z", + "lastUpdatedUtc": "2023-12-01T14:17:53Z", + } + ], + "totalCount": 2, + "offset": 0, + "count": 2, + } + if not dictionary_uri: + return dicts + for dictionary in dicts["dictionaries"]: + if dictionary["uri"] == dictionary_uri: + dicts["dictionaries"] = [dictionary] + return dicts + assert False, f"Could not find dictionary uri {dictionary_uri}" + + +tool.Bsdd.client = bSDDClientStub() + + class PanelSpy: def __init__(self, panel: type[bpy.types.Panel]): self.is_spy_dirty = True @@ -86,8 +147,9 @@ class PanelSpy: "active_dataptr": active_dataptr, "active_propname": active_propname, } - self.spied_lists.append(spied_data) - return TemplateListSpy(spied_data) + template_list = TemplateListSpy(getattr(bpy.types, listtype_name), spied_data) + self.spied_lists.append(template_list) + return template_list elif self.spied_attr == "context_pointer_set": return lambda *args, **kwargs: None elif self.spied_attr == "label": @@ -157,9 +219,39 @@ class OperatorSpy: self.spied_data["kwargs"][name] = value -class TemplateListSpy: - def __init__(self, spied_data): +class TemplateListSpy(PanelSpy): + def __init__(self, template_list: type[bpy.types.UIList], spied_data: dict): self.spied_data = spied_data + self.items = getattr(self.spied_data["dataptr"], self.spied_data["propname"]) + self.active_index = getattr(self.spied_data["active_dataptr"], self.spied_data["active_propname"]) + try: + self.active_item = self.items[self.active_index] + except: + self.active_item = None + self.panel = template_list + + self.rows = [] + for item in self.items: + self.rows.append(TemplateListItemSpy(self, item)) + + +class TemplateListItemSpy(PanelSpy): + def __init__(self, parent: TemplateListSpy, item): + self.panel = parent.panel + self.spied_attr: Union[str, None] = None + self.spied_labels: list[str] = [] + self.spied_props: list[dict[str, Any]] = [] + self.spied_operators: list[dict[str, Any]] = [] + parent.panel.draw_item( + self, + bpy.context, + self, + parent.spied_data["dataptr"], + item, + "", + parent.spied_data["active_dataptr"], + parent.spied_data["active_propname"], + ) ui_name_cache = {} @@ -181,6 +273,8 @@ def create_ui_name_cache(): if panel_type.bl_label == "Add" and bl_idname != "VIEW3D_MT_add": continue # Non-unique, but "VIEW3D_MT_add" is the one we care about ui_name_cache[panel_type.bl_label] = bl_idname + elif panel_type.bl_rna.base.name == "UIList": + ui_name_cache[panel_type.bl_rna.name] = bl_idname except: pass @@ -293,6 +387,57 @@ def i_see_text(text): assert [l for l in panel_spy.spied_labels if text in l], f"Text {text} not found in {panel_spy.spied_labels}" +@given(parsers.parse('I see "{text}" in the "{nth}" list')) +@when(parsers.parse('I see "{text}" in the "{nth}" list')) +@then(parsers.parse('I see "{text}" in the "{nth}" list')) +def i_see_text_in_the_nth_list(text, nth): + assert panel_spy + panel_spy.refresh_spy() + nth = int("".join([c for c in nth if c.isnumeric()])) + if len(panel_spy.spied_lists) < nth: + assert False, f"{nth} list does not exist. Actual number of lists: {len(panel_spy.spied_lists)}" + debug = [] + for i, template_list in enumerate(panel_spy.spied_lists): + if i + 1 != nth: + continue + for row in template_list.rows: + for l in row.spied_labels: + debug.append(l) + if text in l: + return True + debug = "\n".join(debug) + assert False, f"Could not see '{text}' in any list. We saw:\n{debug}" + + +@given(parsers.parse('I click "{button}" in the row where I see "{text}" in the "{nth}" list')) +@when(parsers.parse('I click "{button}" in the row where I see "{text}" in the "{nth}" list')) +@then(parsers.parse('I click "{button}" in the row where I see "{text}" in the "{nth}" list')) +def i_click_button_in_the_row_where_i_see_text_in_the_nth_list(button, text, nth): + """ + :param button: The text or icon of the button to click. + """ + assert panel_spy + panel_spy.refresh_spy() + nth = int("".join([c for c in nth if c.isnumeric()])) + if len(panel_spy.spied_lists) < nth: + assert False, f"{nth} list does not exist. Actual number of lists: {len(panel_spy.spied_lists)}" + debug = [] + for i, template_list in enumerate(panel_spy.spied_lists): + if i + 1 != nth: + continue + for row in template_list.rows: + is_row = False + for l in row.spied_labels: + debug.append(l) + if text in l: + is_row = True + if is_row: + i_click_button_on_panel(button, row) + return True + debug = "\n".join(debug) + assert False, f"Could not see '{text}' in any list. We saw:\n{debug}" + + @given(parsers.parse('I don\'t see "{text}"')) @when(parsers.parse('I don\'t see "{text}"')) @then(parsers.parse('I don\'t see "{text}"')) @@ -564,15 +709,7 @@ def i_press_operator(operator): assert False, f"Failed to run operator bpy.ops.{operator} because of {e}" -@given(parsers.parse('I click "{button}"')) -@when(parsers.parse('I click "{button}"')) -@then(parsers.parse('I click "{button}"')) -def i_click_button(button): - """ - :param button: The text or icon of the button to click. - """ - assert panel_spy - panel_spy.refresh_spy() +def i_click_button_on_panel(button, panel_spy): for spied_operator in panel_spy.spied_operators: if spied_operator["text"] == button or spied_operator["icon"] == button: spied_operator["operator"]("INVOKE_DEFAULT", **spied_operator["kwargs"]) @@ -592,6 +729,18 @@ def i_click_button(button): assert False, f"Could not find {button}:\n{debug}" +@given(parsers.parse('I click "{button}"')) +@when(parsers.parse('I click "{button}"')) +@then(parsers.parse('I click "{button}"')) +def i_click_button(button): + """ + :param button: The text or icon of the button to click. + """ + assert panel_spy + panel_spy.refresh_spy() + i_click_button_on_panel(button, panel_spy) + + @given(parsers.parse('I click the "{button}" after the text "{text}"')) @when(parsers.parse('I click the "{button}" after the text "{text}"')) @then(parsers.parse('I click the "{button}" after the text "{text}"')) diff --git a/src/bsdd/bsdd.py b/src/bsdd/bsdd.py index d770b3c38b..f634a422d1 100644 --- a/src/bsdd/bsdd.py +++ b/src/bsdd/bsdd.py @@ -610,15 +610,15 @@ class Client: print(f"function 'Client.Unit' is deprecated, use 'Client.get_units' instead") return self.get(f"api/Unit/{version}") - def get_dictionary(self, dictionary_uri: str = "", version: int = 1) -> DictionaryResponseContractV1: + def get_dictionary( + self, dictionary_uri: str = "", include_test_dictionaries: bool = "False", version: int = 1 + ) -> DictionaryResponseContractV1: """ Get list of available Dictionaries This API replaces Domain """ endpoint = f"Dictionary/v{version}" - params = { - "Uri": dictionary_uri, - } + params = {"Uri": dictionary_uri, "IncludeTestDictionaries": "true" if include_test_dictionaries else "false"} return self.get(endpoint, params) def get_classes( @@ -626,6 +626,8 @@ class Client: dictionary_uri: str, use_nested_classes: bool = True, class_type: ClassTypes = "Class", + search_text: str = "", + related_ifc_entity: str = "", language_code: str = "", version: int = 1, offset=0, @@ -636,14 +638,23 @@ class Client: This API replaces Domain """ endpoint = f"Dictionary/v{version}/Classes" - params = { - "Uri": dictionary_uri, + params = {"Uri": dictionary_uri} + for param, value in { "UseNestedClasses": use_nested_classes, "ClassType": class_type, "languageCode": language_code, "offset": offset, "limit": limit, - } + }.items(): + if value: + params[param] = value + if not use_nested_classes: + for param, value in { + "SearchText": search_text, + "RelatedIfcEntity": related_ifc_entity, + }.items(): + if value: + params[param] = value return self.get(endpoint, params) def get_properties( @@ -781,8 +792,8 @@ class Client: this API replaces ClassificationSearch """ - if len(search_text) < 3: - raise ValueError("Search text must be at least 3 characters long.") + if len(search_text) < 1: + raise ValueError("Search text must be at least 1 characters long.") if related_ifc_entities is None: related_ifc_entities = []