Fix #6669. bSDD now supports test dictionaries, refactor domain to dictionary, support zero length keyword searches, multi-dict support, and basic tests.

Feature tests can now have more sophisticated per UIList item spies.
This commit is contained in:
Dion Moult
2025-05-17 21:27:47 +10:00
parent 8e34c1d7df
commit 22f10e5549
14 changed files with 420 additions and 183 deletions
@@ -21,14 +21,13 @@ from . import ui, prop, operator
classes = ( classes = (
operator.GetBSDDClassificationProperties, operator.GetBSDDClassificationProperties,
operator.LoadBSDDDomains, operator.LoadBSDDDictionaries,
operator.SearchBSDDClass, operator.SearchBSDDClass,
operator.SetActiveBSDDDictionary, prop.BSDDDictionary,
prop.BSDDDomain,
prop.BSDDClassification, prop.BSDDClassification,
prop.BSDDPset, prop.BSDDPset,
prop.BIMBSDDProperties, prop.BIMBSDDProperties,
ui.BIM_UL_bsdd_domains, ui.BIM_UL_bsdd_dictionaries,
ui.BIM_UL_bsdd_classifications, ui.BIM_UL_bsdd_classifications,
ui.BIM_PT_bsdd, ui.BIM_PT_bsdd,
) )
+45
View File
@@ -0,0 +1,45 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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
+5 -25
View File
@@ -22,25 +22,13 @@ import bonsai.tool as tool
from bonsai.core import bsdd as core from bonsai.core import bsdd as core
class LoadBSDDDomains(bpy.types.Operator): class LoadBSDDDictionaries(bpy.types.Operator):
bl_idname = "bim.load_bsdd_domains" bl_idname = "bim.load_bsdd_dictionaries"
bl_label = "Load bSDD Dictionaries" bl_label = "Load bSDD Dictionaries"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
def execute(self, context): def execute(self, context):
core.load_bsdd(bsdd.Client(), tool.Bsdd) core.load_bsdd(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)
return {"FINISHED"} return {"FINISHED"}
@@ -50,17 +38,9 @@ class SearchBSDDClass(bpy.types.Operator):
bl_description = "Search for bSDD classes by the provided keyword" bl_description = "Search for bSDD classes by the provided keyword"
bl_options = {"REGISTER", "UNDO"} 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): def execute(self, context):
keyword = context.scene.BIMBSDDProperties.keyword 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}'.") self.report({"INFO"}, f"{classes_found} bSDD classes found for '{keyword}'.")
return {"FINISHED"} return {"FINISHED"}
@@ -72,7 +52,7 @@ class GetBSDDClassificationProperties(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
def execute(self, context): 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) psets_found = len(pset_data)
props_found = sum(len(pset) for pset in pset_data.values()) 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.") self.report({"INFO"}, f"{psets_found} psets found ({props_found} props) for the active classification.")
+29 -8
View File
@@ -18,6 +18,7 @@
import bpy import bpy
from bpy.types import PropertyGroup from bpy.types import PropertyGroup
from bonsai.bim.module.bsdd.data import BSDDData
from bonsai.bim.prop import Attribute, StrProperty from bonsai.bim.prop import Attribute, StrProperty
from bpy.props import ( from bpy.props import (
PointerProperty, 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") name: StringProperty(name="Name")
uri: StringProperty(name="URI") uri: StringProperty(name="URI")
default_language_code: StringProperty(name="Language") default_language_code: StringProperty(name="Language")
organization_name_owner: StringProperty(name="Organization") organization_name_owner: StringProperty(name="Organization")
status: StringProperty(name="Status") status: StringProperty(name="Status")
version: StringProperty(name="Version") 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): class BSDDClassification(PropertyGroup):
name: StringProperty(name="Name") name: StringProperty(name="Name")
reference_code: StringProperty(name="Reference Code") reference_code: StringProperty(name="Reference Code")
uri: StringProperty(name="Namespace URI") uri: StringProperty(name="Namespace URI")
domain_name: StringProperty(name="Domain Name") dictionary_name: StringProperty(name="Dictionary Name")
domain_namespace_uri: StringProperty(name="Domain Namespace URI") dictionary_namespace_uri: StringProperty(name="Dictionary Namespace URI")
class BSDDPset(PropertyGroup): class BSDDPset(PropertyGroup):
@@ -54,10 +68,11 @@ class BSDDPset(PropertyGroup):
class BIMBSDDProperties(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") active_uri: StringProperty(name="Active URI")
domains: CollectionProperty(name="Domains", type=BSDDDomain) dictionaries: CollectionProperty(name="Dictionaries", type=BSDDDictionary)
active_domain_index: IntProperty(name="Active Domain Index") active_dictionary_index: IntProperty(name="Active Dictionary Index")
classifications: CollectionProperty(name="Classifications", type=BSDDClassification) classifications: CollectionProperty(name="Classifications", type=BSDDClassification)
active_classification_index: IntProperty(name="Active Classification Index") active_classification_index: IntProperty(name="Active Classification Index")
keyword: StringProperty(name="Keyword", description="Query for bsdd classes search, case and accent insensitive") 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", description="Whether to display and assign only properties from IFC dictionary",
default=True, default=True,
) )
load_preview_domains: BoolProperty( load_preview_dictionaries: BoolProperty(
name="Load Preview Domains", description="Whether it should load preview and inactive domains", default=False 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) classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset)
+22 -32
View File
@@ -17,6 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim.module.bsdd.data import BSDDData
from bpy.types import Panel, UIList from bpy.types import Panel, UIList
@@ -30,65 +31,54 @@ class BIM_PT_bsdd(Panel):
bl_parent_id = "BIM_PT_tab_project_setup" bl_parent_id = "BIM_PT_tab_project_setup"
def draw(self, context): def draw(self, context):
if not BSDDData.is_loaded:
BSDDData.load()
props = context.scene.BIMBSDDProperties props = context.scene.BIMBSDDProperties
layout = self.layout layout = self.layout
row = self.layout.row(align=True) if len(props.dictionaries):
row.prop(props, "load_preview_domains")
if len(props.domains):
row.operator("bim.load_bsdd_domains", text="", icon="FILE_REFRESH")
if props.active_domain:
row = self.layout.row() row = self.layout.row()
row.label(text="Active: " + props.active_domain, icon="URL") row.operator("bim.load_bsdd_dictionaries", icon="FILE_REFRESH")
else:
row = self.layout.row()
row.label(text="No Active bSDD Domain", icon="ERROR")
if len(props.domains): if len(props.dictionaries):
self.layout.template_list( self.layout.template_list(
"BIM_UL_bsdd_domains", "BIM_UL_bsdd_dictionaries",
"", "",
props, props,
"domains", "dictionaries",
props, props,
"active_domain_index", "active_dictionary_index",
) )
if 0 <= props.active_domain_index < len(props.domains): if 0 <= props.active_dictionary_index < len(props.dictionaries):
selected_domain = props.domains[props.active_domain_index] selected_dictionary = props.dictionaries[props.active_dictionary_index]
else: else:
selected_domain = None selected_dictionary = None
if selected_domain: if selected_dictionary:
layout.label(text="Selected domain:") layout.label(text="Selected dictionary:")
box = layout.box() box = layout.box()
row = box.row(align=True) row = box.row(align=True)
row.label(text="Language") 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 = box.row(align=True)
row.label(text="Version") row.label(text="Version")
row.label(text=selected_domain.version) row.label(text=selected_dictionary.version)
box.operator("bim.open_uri", text="Open bSDD In Browser", icon="URL").uri = selected_domain.uri box.operator("bim.open_uri", text="Open bSDD In Browser", icon="URL").uri = selected_dictionary.uri
else: else:
row = self.layout.row() 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): def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
if item: if item:
props = context.scene.BIMBSDDProperties props = context.scene.BIMBSDDProperties
row = layout.row(align=True) row = layout.row(align=True)
if item.status != "Active": 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: else:
row.label(text=f"{item.name} ({item.organization_name_owner})") row.label(text=f"{item.name} ({item.organization_name_owner}) v{item.version}")
if item.uri == props.active_uri: row.prop(item, "is_active", icon="CHECKBOX_HLT" if item.is_active else "CHECKBOX_DEHLT", text="", emboss=False)
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
class BIM_UL_bsdd_classifications(UIList): class BIM_UL_bsdd_classifications(UIList):
@@ -116,28 +116,26 @@ class AddClassificationFromBSDD(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context): def _execute(self, context):
props = context.scene.BIMBSDDProperties 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) if props.active_dictionary == "ALL":
dictionaries = [d.uri for d in props.dictionaries if d.is_active]
# Maybe user loaded preview domains, set it as active else:
# and then reloaded them without preview domains. dictionaries = [props.active_dictionary]
if not domain: for uri in dictionaries:
self.report( if not (dictionary := tool.Bsdd.get_dictionary(uri)):
{"INFO"}, continue
f"Couldn't find domain '{props.active_domain}' ({props.active_uri}). Try to reload bSDD dictionaries.", 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 classification.Source = dictionary["organizationNameOwner"]
classification.Edition = dictionary["version"]
for element in tool.Ifc.get().by_type("IfcClassification"): tool.Classification.set_location(classification, dictionary["uri"])
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)
class EnableAddingManualClassification(bpy.types.Operator): class EnableAddingManualClassification(bpy.types.Operator):
@@ -420,17 +418,17 @@ class AddClassificationReferenceFromBSDD(bpy.types.Operator, tool.Ifc.Operator):
classification = None classification = None
for element in tool.Ifc.get().by_type("IfcClassification"): for element in tool.Ifc.get().by_type("IfcClassification"):
if element.Name == bsdd_classification.domain_name or ( if element.Name == bsdd_classification.dictionary_name or (
tool.Classification.get_location(element) == bsdd_classification.domain_namespace_uri tool.Classification.get_location(element) == bsdd_classification.dictionary_namespace_uri
): ):
classification = element classification = element
break break
if not classification: if not classification:
classification = ifcopenshell.api.run( 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: for obj in objects:
ifc_definition_id = tool.Blender.get_obj_ifc_definition_id(obj, self.obj_type, context) ifc_definition_id = tool.Blender.get_obj_ifc_definition_id(obj, self.obj_type, context)
@@ -78,13 +78,8 @@ class BIM_PT_classifications(Panel):
def draw_add_bsdd_ui(self, context): def draw_add_bsdd_ui(self, context):
self.bprops = context.scene.BIMBSDDProperties 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 = 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 = self.layout.row()
row.operator("bim.add_classification_from_bsdd", icon="ADD") 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") row.operator("bim.enable_adding_manual_classification_reference", text="Add Reference", icon="ADD")
def draw_add_bsdd_ui(self, context): 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 = 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 = self.layout.row(align=True)
row.prop(self.bprops, "keyword", text="") row.prop(self.bprops, "keyword", text="")
+4
View File
@@ -435,6 +435,10 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
layout.prop(props, "should_disable_undo_on_save") layout.prop(props, "should_disable_undo_on_save")
layout.prop(props, "should_stream") 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 # Scene panel groups
+7 -18
View File
@@ -26,9 +26,9 @@ if TYPE_CHECKING:
import bonsai.tool as tool 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() bsdd.clear_class_psets()
data = bsdd.get_active_class_data(client) data = bsdd.get_active_class_data()
pset_dict = bsdd.get_property_dict(data) pset_dict = bsdd.get_property_dict(data)
if pset_dict is None: if pset_dict is None:
return {} return {}
@@ -36,23 +36,12 @@ def get_class_properties(client: bsdd.Client, bsdd: tool.Bsdd) -> dict[str, dict
return pset_dict return pset_dict
def load_bsdd(client: bsdd.Client, bsdd: tool.Bsdd) -> None: def load_bsdd(bsdd: tool.Bsdd) -> None:
bsdd.clear_domains() bsdd.clear_dictionaries()
if bsdd.should_load_preview_domains(): bsdd.create_dictionaries(bsdd.get_dictionaries())
dictionaries = bsdd.get_dictionaries(client)
else:
dictionaries = bsdd.get_dictionaries(client, "Active")
bsdd.create_dictionaries(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() bsdd.clear_classes()
related_entities = bsdd.get_related_ifc_entities() related_entities = bsdd.get_related_ifc_entities()
active_dictionary_uri = bsdd.get_active_dictionary_uri() return bsdd.search_class(keyword, related_entities)
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)
+63 -37
View File
@@ -3,10 +3,16 @@ import bonsai.tool as tool
import bpy import bpy
import json import json
import bsdd import bsdd
from typing import Any, Union, Optional from typing import Any, Union, Optional, TYPE_CHECKING
class Bsdd(bonsai.core.tool.Bsdd): class Bsdd(bonsai.core.tool.Bsdd):
client = bsdd.Client()
@classmethod
def get_bsdd_props(cls):
return bpy.context.scene.BIMBSDDProperties
@classmethod @classmethod
def clear_class_psets(cls) -> None: def clear_class_psets(cls) -> None:
bpy.context.scene.BIMBSDDProperties.classification_psets.clear() bpy.context.scene.BIMBSDDProperties.classification_psets.clear()
@@ -16,8 +22,8 @@ class Bsdd(bonsai.core.tool.Bsdd):
bpy.context.scene.BIMBSDDProperties.classifications.clear() bpy.context.scene.BIMBSDDProperties.classifications.clear()
@classmethod @classmethod
def clear_domains(cls) -> None: def clear_dictionaries(cls) -> None:
bpy.context.scene.BIMBSDDProperties.domains.clear() bpy.context.scene.BIMBSDDProperties.dictionaries.clear()
@classmethod @classmethod
def create_class_psets(cls, pset_dict: dict[str, dict[str, Any]]) -> None: 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.ifc_class = data["ifc_class"]
new2.metadata = data["dictionary"] 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 @classmethod
def create_dictionaries(cls, dictionaries: list[bsdd.DictionaryContractV1]) -> None: def create_dictionaries(cls, dictionaries: list[bsdd.DictionaryContractV1]) -> None:
props = bpy.context.scene.BIMBSDDProperties props = bpy.context.scene.BIMBSDDProperties
for dictionary in sorted(dictionaries, key=lambda d: d["name"]): for dictionary in sorted(dictionaries, key=lambda d: d["name"]):
new = props.domains.add() new = props.dictionaries.add()
new.name = dictionary["name"] new.name = dictionary["name"]
new.uri = dictionary["uri"] new.uri = dictionary["uri"]
new.default_language_code = dictionary["defaultLanguageCode"] new.default_language_code = dictionary["defaultLanguageCode"]
@@ -66,24 +61,35 @@ class Bsdd(bonsai.core.tool.Bsdd):
new.version = dictionary["version"] new.version = dictionary["version"]
@classmethod @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 prop = bpy.context.scene.BIMBSDDProperties
bsdd_classification = prop.classifications[prop.active_classification_index] bsdd_classification = prop.classifications[prop.active_classification_index]
if not bsdd_classification: if not bsdd_classification:
return {} return {}
return client.get_class(bsdd_classification.uri) return cls.client.get_class(bsdd_classification.uri)
@classmethod @classmethod
def get_active_dictionary_uri(cls) -> str: def get_active_dictionary_uri(cls) -> str:
return bpy.context.scene.BIMBSDDProperties.active_uri return bpy.context.scene.BIMBSDDProperties.active_uri
@classmethod @classmethod
def get_dictionaries(cls, client: bsdd.Client, status: Optional[str] = None) -> list[bsdd.DictionaryContractV1]: def get_dictionary(cls, uri: str) -> bsdd.DictionaryContractV1:
response = client.get_dictionary() 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 [] dicts = response.get("dictionaries") or []
if status is not None: statuses = ["Active"]
dicts = list(filter(lambda d: d["status"] == status, dicts)) if props.load_preview_dictionaries:
return dicts statuses.append("Preview")
if props.load_inactive_dictionaries:
statuses.append("Inactive")
return list(filter(lambda d: d["status"] in statuses, dicts))
@classmethod @classmethod
def get_property_dict(cls, class_data: Union[bsdd.ClassContractV1, dict]) -> Union[dict[str, dict[str, Any]], None]: 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 @classmethod
def search_class( def search_class(
cls, cls,
client: bsdd.Client,
keyword: str, keyword: str,
dictionary_uris: Union[list[str], None],
related_ifc_entities: Union[list[str], None], related_ifc_entities: Union[list[str], None],
offset: int = 0, offset: int = 0,
) -> list[bsdd.ClassSearchResponseClassContractV1]: limit: int = 100,
response = client.search_class( should_paginate: bool = True,
keyword, dictionary_uris=dictionary_uris, related_ifc_entities=related_ifc_entities, offset=offset ):
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", []) for dictionary_uri in dictionary_uris:
# If count is 100, it might be hitting the limit. response = cls.client.get_classes(
if response["count"] == 100: dictionary_uri=dictionary_uri,
classes += cls.search_class(client, keyword, dictionary_uris, related_ifc_entities, offset=offset + 100) use_nested_classes=False,
return classes 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 @classmethod
def set_active_bsdd(cls, name: str, uri: str) -> None: def set_active_bsdd(cls, name: str, uri: str) -> None:
props = bpy.context.scene.BIMBSDDProperties props = bpy.context.scene.BIMBSDDProperties
props.active_domain = name props.active_dictionary = name
props.active_uri = uri props.active_uri = uri
@classmethod @classmethod
def should_filter_ifc_class(cls) -> bool: def should_filter_ifc_class(cls) -> bool:
return bpy.context.scene.BIMBSDDProperties.should_filter_ifc_class 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
+1
View File
@@ -4,6 +4,7 @@ markers =
attribute attribute
boolean boolean
brick brick
bsdd
classification classification
context context
cost cost
+34
View File
@@ -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"
+162 -13
View File
@@ -50,6 +50,67 @@ variables = {
webbrowser.open = lambda x: True 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: class PanelSpy:
def __init__(self, panel: type[bpy.types.Panel]): def __init__(self, panel: type[bpy.types.Panel]):
self.is_spy_dirty = True self.is_spy_dirty = True
@@ -86,8 +147,9 @@ class PanelSpy:
"active_dataptr": active_dataptr, "active_dataptr": active_dataptr,
"active_propname": active_propname, "active_propname": active_propname,
} }
self.spied_lists.append(spied_data) template_list = TemplateListSpy(getattr(bpy.types, listtype_name), spied_data)
return TemplateListSpy(spied_data) self.spied_lists.append(template_list)
return template_list
elif self.spied_attr == "context_pointer_set": elif self.spied_attr == "context_pointer_set":
return lambda *args, **kwargs: None return lambda *args, **kwargs: None
elif self.spied_attr == "label": elif self.spied_attr == "label":
@@ -157,9 +219,39 @@ class OperatorSpy:
self.spied_data["kwargs"][name] = value self.spied_data["kwargs"][name] = value
class TemplateListSpy: class TemplateListSpy(PanelSpy):
def __init__(self, spied_data): def __init__(self, template_list: type[bpy.types.UIList], spied_data: dict):
self.spied_data = spied_data 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 = {} ui_name_cache = {}
@@ -181,6 +273,8 @@ def create_ui_name_cache():
if panel_type.bl_label == "Add" and bl_idname != "VIEW3D_MT_add": 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 continue # Non-unique, but "VIEW3D_MT_add" is the one we care about
ui_name_cache[panel_type.bl_label] = bl_idname 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: except:
pass 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}" 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}"')) @given(parsers.parse('I don\'t see "{text}"'))
@when(parsers.parse('I don\'t see "{text}"')) @when(parsers.parse('I don\'t see "{text}"'))
@then(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}" assert False, f"Failed to run operator bpy.ops.{operator} because of {e}"
@given(parsers.parse('I click "{button}"')) def i_click_button_on_panel(button, panel_spy):
@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()
for spied_operator in panel_spy.spied_operators: for spied_operator in panel_spy.spied_operators:
if spied_operator["text"] == button or spied_operator["icon"] == button: if spied_operator["text"] == button or spied_operator["icon"] == button:
spied_operator["operator"]("INVOKE_DEFAULT", **spied_operator["kwargs"]) 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}" 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}"')) @given(parsers.parse('I click the "{button}" after the text "{text}"'))
@when(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}"')) @then(parsers.parse('I click the "{button}" after the text "{text}"'))
+20 -9
View File
@@ -610,15 +610,15 @@ class Client:
print(f"function 'Client.Unit' is deprecated, use 'Client.get_units' instead") print(f"function 'Client.Unit' is deprecated, use 'Client.get_units' instead")
return self.get(f"api/Unit/{version}") 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 Get list of available Dictionaries
This API replaces Domain This API replaces Domain
""" """
endpoint = f"Dictionary/v{version}" endpoint = f"Dictionary/v{version}"
params = { params = {"Uri": dictionary_uri, "IncludeTestDictionaries": "true" if include_test_dictionaries else "false"}
"Uri": dictionary_uri,
}
return self.get(endpoint, params) return self.get(endpoint, params)
def get_classes( def get_classes(
@@ -626,6 +626,8 @@ class Client:
dictionary_uri: str, dictionary_uri: str,
use_nested_classes: bool = True, use_nested_classes: bool = True,
class_type: ClassTypes = "Class", class_type: ClassTypes = "Class",
search_text: str = "",
related_ifc_entity: str = "",
language_code: str = "", language_code: str = "",
version: int = 1, version: int = 1,
offset=0, offset=0,
@@ -636,14 +638,23 @@ class Client:
This API replaces Domain This API replaces Domain
""" """
endpoint = f"Dictionary/v{version}/Classes" endpoint = f"Dictionary/v{version}/Classes"
params = { params = {"Uri": dictionary_uri}
"Uri": dictionary_uri, for param, value in {
"UseNestedClasses": use_nested_classes, "UseNestedClasses": use_nested_classes,
"ClassType": class_type, "ClassType": class_type,
"languageCode": language_code, "languageCode": language_code,
"offset": offset, "offset": offset,
"limit": limit, "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) return self.get(endpoint, params)
def get_properties( def get_properties(
@@ -781,8 +792,8 @@ class Client:
this API replaces ClassificationSearch this API replaces ClassificationSearch
""" """
if len(search_text) < 3: if len(search_text) < 1:
raise ValueError("Search text must be at least 3 characters long.") raise ValueError("Search text must be at least 1 characters long.")
if related_ifc_entities is None: if related_ifc_entities is None:
related_ifc_entities = [] related_ifc_entities = []