Merge branch 'v0.8.0' into ifcmax/initial-refresh

This commit is contained in:
Josef Wienerroither
2026-01-16 07:51:51 +01:00
68 changed files with 3193 additions and 1622 deletions
+3 -14
View File
@@ -130,12 +130,7 @@ classes = [
prop.StrProperty,
operator.BIM_OT_enum_property_search, # /!\ Register AFTER prop.StrProperty
operator.BIM_OT_attribute_search_values,
operator.BIM_UL_tab_panels,
operator.BIM_OT_toggle_panel_visibility,
operator.BIM_OT_bookmark_panel,
operator.BIM_OT_manage_tab_panels,
operator.BIM_OT_manage_tab_visibility,
operator.BIM_OT_toggle_tab_visibility,
operator.BIM_OT_reset_ui_layout,
prop.ObjProperty,
prop.MultipleFileSelect,
@@ -144,7 +139,7 @@ classes = [
prop.BIMAreaProperties,
prop.BIMTabProperties,
prop.BIMTabVisibility, # Must be registered before BIMProperties
prop.BIMPanelProperties, # Must be registered before BIMProperties
prop.BIMPanelVisibility, # Must be registered before BIMProperties
prop.BIMProperties,
prop.IfcParameter,
prop.PsetQto,
@@ -157,6 +152,8 @@ classes = [
prop.BIMSnapGroups,
ui.BIM_UL_clipping_plane,
ui.BIM_UL_generic,
ui.BIM_UL_tab_visibilities,
ui.BIM_UL_panel_visibilities,
ui.DocPreferences,
ui.GizmoPreferencesDoor, # Register before GizmoPreferences
ui.GizmoPreferencesWindow, # Register before GizmoPreferences
@@ -318,10 +315,6 @@ def register():
# RestrictedContext doesn't allow accessing scene attribute, postpone it for a bit.
bpy.app.timers.register(tool.Blender.setup_user_data_dir, first_interval=0.1)
bpy.types.Scene.active_tab_name = bpy.props.StringProperty()
bpy.types.Scene.tab_panels = bpy.props.CollectionProperty(type=bpy.types.PropertyGroup)
bpy.types.Scene.active_tab_panel_index = bpy.props.IntProperty()
def unregister():
global icons
@@ -363,7 +356,3 @@ def unregister():
tool.Blender.remove_scene_panel_override(panel)
bpy.app.translations.unregister("bonsai")
del bpy.types.Scene.active_tab_name
del bpy.types.Scene.tab_panels
del bpy.types.Scene.active_tab_panel_index
+5 -147
View File
@@ -428,7 +428,11 @@ def get_enum_items(
else:
annotations_data = data
prop = annotations_data.__annotations__[prop_name]
try:
annotations = annotations_data.__annotations__
except AttributeError:
annotations = type(annotations_data).__annotations__
prop = annotations[prop_name]
items = prop.keywords.get("items")
if items is None:
return
@@ -788,149 +792,3 @@ def draw_filter(
op.group_index = i
op.index = j
op.module = module
# ============================================================================
# UI Panel Visibility Helpers
# ============================================================================
def get_tab_names():
from bonsai.bim.prop import get_tab
enum_items = get_tab(None, None)
# Exclude None separators and the BLENDER tab (not part of BIM tab system)
return [item[0] for item in enum_items if item is not None and item[0] != "BLENDER"]
def get_panel_tab_name(panel_class):
if hasattr(panel_class, "bim_tab_name"):
return panel_class.bim_tab_name
return "PROJECT" # Default fallback
def should_show_panel(panel_id, panel_tab_name, context):
if tool.Blender.is_tab(context, "BOOKMARK"):
return is_panel_bookmarked(panel_id) and get_panel_visibility(panel_id, "BOOKMARK")
if tool.Blender.is_tab(context, panel_tab_name):
return get_tab_visibility(panel_tab_name) and get_panel_visibility(panel_id, panel_tab_name)
return False
def get_tab_visibility(tab_name):
bim_props = tool.Blender.get_bim_props()
tab_vis = bim_props.tab_visibilities.get(tab_name)
return tab_vis.is_visible if tab_vis else True
def set_tab_visibility(tab_name, visible):
bim_props = tool.Blender.get_bim_props()
tab_vis = bim_props.tab_visibilities.get(tab_name)
if tab_vis:
tab_vis.is_visible = visible
else:
new_tab = bim_props.tab_visibilities.add()
new_tab.name = tab_name
new_tab.is_visible = visible
def get_panel_visibility(panel_id, current_tab=None):
panel_config = get_panel_config(panel_id)
if panel_config:
if current_tab == "BOOKMARK":
return panel_config.is_visible_in_bookmarks
else:
return panel_config.is_visible_in_tab
return True
def is_panel_bookmarked(panel_id):
panel_config = get_panel_config(panel_id)
if panel_config:
return panel_config.is_bookmarked
return False
def get_panel_config(panel_id, create_if_missing=False):
try:
bim_props = tool.Blender.get_bim_props()
except (AttributeError, AssertionError):
return None
for prop in bim_props.panel_properties:
if prop.name == panel_id:
return prop
if create_if_missing:
try:
prop = bim_props.panel_properties.add()
prop.name = panel_id
prop.is_visible_in_tab = True
prop.is_visible_in_bookmarks = True
prop.is_bookmarked = False
return prop
except AttributeError:
pass
return None
def get_all_tab_panels(force_refresh=False):
panels = {tab_name: [] for tab_name in get_tab_names() if tab_name != "BOOKMARK"}
panels["BOOKMARK"] = []
bim_props = tool.Blender.get_bim_props()
for prop in bim_props.panel_properties:
panel_class = getattr(bpy.types, prop.name, None)
if panel_class:
tab_name = get_panel_tab_name(panel_class)
if tab_name and tab_name != "BOOKMARK":
bl_label = getattr(panel_class, "bl_label", prop.name)
panels[tab_name].append({"bl_idname": prop.name, "bl_label": bl_label})
if prop.is_bookmarked:
panel_class = getattr(bpy.types, prop.name, None)
if panel_class:
bl_label = getattr(panel_class, "bl_label", prop.name)
panels["BOOKMARK"].append({"bl_idname": prop.name, "bl_label": bl_label})
if not panels["BOOKMARK"]:
panels["BOOKMARK"] = [{}]
return panels
def initialize_tab_visibilities():
bim_props = tool.Blender.get_bim_props()
if len(bim_props.tab_visibilities) > 0:
return
for tab_name in get_tab_names():
tab_vis = bim_props.tab_visibilities.add()
tab_vis.name = tab_name
tab_vis.is_visible = True
def initialize_panel_properties():
bim_props = tool.Blender.get_bim_props()
if len(bim_props.panel_properties) > 0:
return
for attr_name in dir(bpy.types):
if attr_name.startswith("BIM_PT_tab_"):
panel_class = getattr(bpy.types, attr_name)
if not hasattr(panel_class, "bl_idname"):
continue
panel_id = panel_class.bl_idname
prop = bim_props.panel_properties.add()
prop.name = panel_id
prop.is_visible_in_tab = True
prop.is_visible_in_bookmarks = True
prop.is_bookmarked = False
+1
View File
@@ -1295,6 +1295,7 @@ class IfcImporter:
if element.is_a("IfcSpace"):
obj.hide_set(True)
class IfcImportSettings:
"""
Initialize only using `IfcImportSettings.factory()`.
@@ -24,26 +24,33 @@ classes = (
operator.AddInformation,
operator.AssignDocument,
operator.DisableDocumentEditingUI,
operator.DisableObjectDocumentEditingUI,
operator.DisableEditingDocument,
operator.EditDocument,
operator.EnableEditingDocument,
operator.LoadDocument,
operator.LoadParentDocument,
operator.LoadObjectDocuments,
operator.LoadProjectDocuments,
operator.RemoveDocument,
operator.SelectDocumentObjects,
operator.ToggleDocument,
operator.UnassignDocument,
operator.OpenIFCDocument,
prop.Document,
prop.DocumentObject,
prop.BIMDocumentProperties,
ui.BIM_PT_documents,
ui.BIM_PT_object_documents,
ui.BIM_UL_documents,
ui.BIM_UL_document_objects,
ui.BIM_MT_object_documents_context_menu,
)
def register():
bpy.types.Scene.BIMDocumentProperties = bpy.props.PointerProperty(type=prop.BIMDocumentProperties)
bpy.types.VIEW3D_MT_object_context_menu.append(ui.add_object_documents_context_menu)
def unregister():
del bpy.types.Scene.BIMDocumentProperties
bpy.types.VIEW3D_MT_object_context_menu.remove(ui.add_object_documents_context_menu)
+90 -40
View File
@@ -21,6 +21,7 @@ import bpy
import ifcopenshell
import ifcopenshell.util.schema
import bonsai.tool as tool
from natsort import natsorted
def refresh():
@@ -35,30 +36,50 @@ class DocumentData:
@classmethod
def load(cls):
cls.data = {
"total_information": cls.total_information(),
"parent_document": cls.parent_document(),
"total_documents": cls.total_documents(),
"document_objects": cls.document_objects(),
}
cls.is_loaded = True
@classmethod
def total_information(cls):
return len(
[
rel
for rel in tool.Ifc.get().by_type("IfcProject")[0].HasAssociations or []
if rel.is_a("IfcRelAssociatesDocument") and rel.RelatingDocument.is_a("IfcDocumentInformation")
]
)
def total_documents(cls):
file = tool.Ifc.get()
return len(file.by_type("IfcDocumentInformation")) + len(file.by_type("IfcDocumentReference"))
@classmethod
def parent_document(cls):
def document_objects(cls):
document_objects = {}
file = tool.Ifc.get()
for rel in file.by_type("IfcRelAssociatesDocument"):
document_id = rel.RelatingDocument.id()
if document_id not in document_objects:
document_objects[document_id] = []
for related_object in rel.RelatedObjects:
element = related_object
obj = tool.Ifc.get_object(element)
if obj:
document_objects[document_id].append({"id": element.id(), "name": obj.name, "obj": obj})
return document_objects
@classmethod
def load_document_objects_into_props(cls, document_id):
if not cls.is_loaded:
cls.load()
props = tool.Document.get_document_props()
if len(props.breadcrumbs):
parent = tool.Ifc.get().by_id(int(props.breadcrumbs[-1].name))
if tool.Ifc.get_schema() == "IFC2X3":
return str(parent.DocumentId)
return str(parent.Identification)
return ""
props.document_objects.clear()
if document_id not in cls.data["document_objects"]:
return
sorted_objects = natsorted(cls.data["document_objects"][document_id], key=lambda x: x["name"].lower())
for obj_data in sorted_objects:
item = props.document_objects.add()
item.name = obj_data["name"]
class ObjectDocumentData:
@@ -72,6 +93,18 @@ class ObjectDocumentData:
}
cls.is_loaded = True
@staticmethod
def convert_to_file_uri(location: str) -> str:
if not location:
return ""
uri = location
if "://" not in uri:
if not os.path.isabs(uri):
uri = os.path.abspath(os.path.join(os.path.dirname(tool.Ifc.get_path()), uri))
uri = "file://" + uri
return uri
@classmethod
def documents(cls):
results = []
@@ -80,44 +113,61 @@ class ObjectDocumentData:
return results
for rel in getattr(element, "HasAssociations", []):
if rel.is_a("IfcRelAssociatesDocument"):
if not rel.RelatingDocument.is_a("IfcDocumentReference"):
relating_document = rel.RelatingDocument
is_information = relating_document.is_a("IfcDocumentInformation")
is_reference = relating_document.is_a("IfcDocumentReference")
if not (is_information or is_reference):
continue
name = rel.RelatingDocument.Name
name = relating_document.Name
if tool.Ifc.get_schema() == "IFC2X3":
if not name and rel.RelatingDocument.ReferenceToDocument:
name = rel.RelatingDocument.ReferenceToDocument[0].Name
location = None
identification = None
identification = rel.RelatingDocument.ItemReference
if not identification and rel.RelatingDocument.ReferenceToDocument:
identification = rel.RelatingDocument.ReferenceToDocument[0].DocumentId
if is_information:
if tool.Ifc.get_schema() == "IFC2X3":
identification = relating_document.DocumentId
else:
identification = relating_document.Identification
location = rel.RelatingDocument.Location
location = getattr(relating_document, "Location", None)
description = getattr(relating_document, "Description", "No description")
else:
if not name and rel.RelatingDocument.ReferencedDocument:
name = rel.RelatingDocument.ReferencedDocument.Name
description = relating_document.Description
if tool.Ifc.get_schema() == "IFC2X3":
reference_to_document = relating_document.ReferenceToDocument
if not name and reference_to_document:
name = reference_to_document[0].Name
identification = rel.RelatingDocument.Identification
if not identification and rel.RelatingDocument.ReferencedDocument:
identification = rel.RelatingDocument.ReferencedDocument.Identification
identification = relating_document.ItemReference
if not identification and reference_to_document:
identification = reference_to_document[0].DocumentId
location = relating_document.Location
else:
referenced_document = relating_document.ReferencedDocument
if not name and referenced_document:
name = referenced_document.Name
location = rel.RelatingDocument.Location
if location is None and rel.RelatingDocument.ReferencedDocument:
location = rel.RelatingDocument.ReferencedDocument.Location
identification = relating_document.Identification
if not identification and referenced_document:
identification = referenced_document.Identification
if location:
if not "://" in location:
if not os.path.isabs(location):
location = os.path.abspath(os.path.join(os.path.dirname(tool.Ifc.get_path()), location))
location = "file://" + location
location = relating_document.Location
if location is None and referenced_document:
location = referenced_document.Location
location = cls.convert_to_file_uri(location) if location else None
results.append(
{
"id": rel.RelatingDocument.id(),
"id": relating_document.id(),
"identification": identification,
"name": name,
"location": location,
"is_information": is_information,
"description": description,
}
)
return results
+171 -37
View File
@@ -18,12 +18,10 @@
import bpy
import json
import ifcopenshell.api
import ifcopenshell.util.attribute
import ifcopenshell.util.element
import bonsai.bim.handler
import bonsai.tool as tool
import bonsai.core.document as core
from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData
class LoadProjectDocuments(bpy.types.Operator):
@@ -33,30 +31,6 @@ class LoadProjectDocuments(bpy.types.Operator):
def execute(self, context):
core.load_project_documents(tool.Document)
bonsai.bim.handler.refresh_ui_data() # Update breadcrumbs data.
return {"FINISHED"}
class LoadDocument(bpy.types.Operator):
bl_idname = "bim.load_document"
bl_label = "Load Document"
bl_options = {"REGISTER", "UNDO"}
document: bpy.props.IntProperty()
def execute(self, context):
core.load_document(tool.Document, document=tool.Ifc.get().by_id(self.document))
bonsai.bim.handler.refresh_ui_data() # Update breadcrumbs data.
return {"FINISHED"}
class LoadParentDocument(bpy.types.Operator):
bl_idname = "bim.load_parent_document"
bl_label = "Load Parent Document"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
core.load_parent_document(tool.Document)
bonsai.bim.handler.refresh_ui_data() # Update breadcrumbs data.
return {"FINISHED"}
@@ -70,6 +44,16 @@ class DisableDocumentEditingUI(bpy.types.Operator):
return {"FINISHED"}
class DisableObjectDocumentEditingUI(bpy.types.Operator):
bl_idname = "bim.disable_object_document_editing_ui"
bl_label = "Disable Object Document Editing UI"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
core.disable_object_document_editing_ui(tool.Document)
return {"FINISHED"}
class EnableEditingDocument(bpy.types.Operator):
bl_idname = "bim.enable_editing_document"
bl_label = "Enable Editing Document"
@@ -77,7 +61,7 @@ class EnableEditingDocument(bpy.types.Operator):
document: bpy.props.IntProperty()
def execute(self, context):
core.enable_editing_document(tool.Document, document=tool.Ifc.get().by_id(self.document))
core.enable_editing_document(tool.Document, ifc_document=tool.Ifc.get().by_id(self.document))
return {"FINISHED"}
@@ -97,7 +81,35 @@ class AddInformation(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.add_information(tool.Ifc, tool.Document)
props = tool.Document.get_document_props()
parent = None
if props.active_document:
selected_document = props.active_document
if selected_document.document_type == "PROJECT":
parent = tool.Ifc.get().by_type("IfcProject")[0]
elif selected_document.document_type == "INFORMATION":
parent = tool.Ifc.get().by_id(selected_document.ifc_definition_id)
elif selected_document.document_type == "REFERENCE":
self.report({"ERROR"}, "Cannot add an information element as a child of a reference element")
return {"CANCELLED"}
else:
parent = tool.Ifc.get().by_type("IfcProject")[0]
core.add_information(tool.Ifc, tool.Document, parent)
expanded_docs = []
try:
expanded_docs = json.loads(props.json_string)
except (AttributeError, json.JSONDecodeError):
pass
if parent and parent.is_a("IfcDocumentInformation"):
if parent.id() not in expanded_docs:
expanded_docs.append(parent.id())
props.json_string = json.dumps(expanded_docs)
bpy.ops.bim.load_project_documents()
class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator):
@@ -106,7 +118,33 @@ class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
props = tool.Document.get_document_props()
if not props.active_document:
self.report({"ERROR"}, "No document selected")
return {"CANCELLED"}
selected_document = props.active_document
if selected_document.document_type != "INFORMATION":
self.report({"ERROR"}, "Cannot add a reference to a document that is not an information element")
return {"CANCELLED"}
parent = tool.Ifc.get().by_id(selected_document.ifc_definition_id)
props.document_attributes.clear()
core.add_reference(tool.Ifc, tool.Document)
expanded_docs = []
try:
expanded_docs = json.loads(props.json_string)
except (AttributeError, json.JSONDecodeError):
pass
if parent.id() not in expanded_docs:
expanded_docs.append(parent.id())
props.json_string = json.dumps(expanded_docs)
bpy.ops.bim.load_project_documents()
class EditDocument(bpy.types.Operator, tool.Ifc.Operator):
@@ -116,7 +154,9 @@ class EditDocument(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
props = tool.Document.get_document_props()
core.edit_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(props.active_document_id))
if props.active_document_id:
core.edit_document(tool.Ifc, tool.Document, ifc_document=tool.Ifc.get().by_id(props.active_document_id))
props.active_document_id = 0
class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator):
@@ -126,7 +166,7 @@ class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator):
document: bpy.props.IntProperty()
def _execute(self, context):
core.remove_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(self.document))
core.remove_document(tool.Ifc, tool.Document, ifc_document=tool.Ifc.get().by_id(self.document))
class AssignDocument(bpy.types.Operator, tool.Ifc.Operator):
@@ -138,12 +178,15 @@ class AssignDocument(bpy.types.Operator, tool.Ifc.Operator):
document: bpy.props.IntProperty()
def _execute(self, context):
document = tool.Ifc.get().by_id(self.document)
objs = [bpy.data.objects[self.obj]] if self.obj else tool.Blender.get_selected_objects()
for obj in objs:
element = tool.Ifc.get_entity(obj)
if element:
core.assign_document(tool.Ifc, product=element, document=document)
core.assign_document(tool.Ifc, product=element, ifc_document=tool.Ifc.get().by_id(self.document))
tool.Document.update_document_objects(self.document)
ObjectDocumentData.load()
return {"FINISHED"}
class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator):
@@ -154,12 +197,25 @@ class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator):
document: bpy.props.IntProperty()
def _execute(self, context):
document = tool.Ifc.get().by_id(self.document)
objs = [bpy.data.objects.get(self.obj)] if self.obj else tool.Blender.get_selected_objects()
for obj in objs:
element = tool.Ifc.get_entity(obj)
if element:
core.unassign_document(tool.Ifc, product=element, document=document)
if obj:
element = tool.Ifc.get_entity(obj)
if element:
core.unassign_document(tool.Ifc, product=element, ifc_document=tool.Ifc.get().by_id(self.document))
props = tool.Document.get_document_props()
active_document_id = None
if props.active_document:
active_document_id = props.active_document.ifc_definition_id
if active_document_id and active_document_id != self.document:
tool.Document.update_document_objects(active_document_id)
else:
tool.Document.update_document_objects()
ObjectDocumentData.load()
return {"FINISHED"}
class SelectDocumentObjects(bpy.types.Operator):
@@ -182,3 +238,81 @@ class SelectDocumentObjects(bpy.types.Operator):
i += 1
self.report({"INFO"}, f"{i} objects selected.")
return {"FINISHED"}
class LoadObjectDocuments(bpy.types.Operator):
bl_idname = "bim.load_object_documents"
bl_label = "Load Object Documents"
bl_description = "Load documents to assign to the selected object"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
core.load_project_documents(tool.Document)
props = tool.Document.get_document_props()
props.is_object_editing = True
ObjectDocumentData.load()
return {"FINISHED"}
class OpenIFCDocument(bpy.types.Operator):
bl_idname = "bim.open_ifc_document"
bl_label = "Open IFC Document"
bl_description = "Open the IFC document in a new Blender instance and load the project"
bl_options = {"REGISTER", "UNDO"}
uri: bpy.props.StringProperty(name="URI")
def execute(self, context):
import subprocess
import os
if not self.uri or not self.uri.lower().startswith("file://"):
self.report({"ERROR"}, "Only local file:// URIs are supported")
return {"CANCELLED"}
filepath = self.uri[7:]
if not os.path.exists(filepath):
self.report({"ERROR"}, f"File not found: {filepath}")
return {"CANCELLED"}
blender_path = bpy.app.binary_path
args = [
blender_path,
"--python-expr",
"import bpy; bpy.ops.bim.load_project(filepath='{}')".format(filepath),
]
subprocess.Popen(args)
self.report({"INFO"}, f"Opening {filepath} in a new Blender instance")
return {"FINISHED"}
class ToggleDocument(bpy.types.Operator):
bl_idname = "bim.toggle_document"
bl_label = "Toggle Document"
bl_options = {"REGISTER", "UNDO"}
document: bpy.props.IntProperty()
option: bpy.props.StringProperty()
def execute(self, context):
expanded_documents = []
props = tool.Document.get_document_props()
try:
expanded_documents = json.loads(props.json_string)
except (AttributeError, json.JSONDecodeError):
expanded_documents = []
document_id = self.document
document = tool.Ifc.get().by_id(document_id)
if document:
if self.option == "Expand" and document_id not in expanded_documents:
expanded_documents.append(document_id)
elif self.option == "Collapse" and document_id in expanded_documents:
expanded_documents.remove(document_id)
props.json_string = json.dumps(expanded_documents)
bpy.ops.bim.load_project_documents()
return {"FINISHED"}
+50 -28
View File
@@ -1,24 +1,7 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 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 bonsai.tool as tool
from bonsai.bim.prop import StrProperty, Attribute
from bonsai.bim.module.document.data import refresh
from bpy.types import PropertyGroup
from bpy.props import (
PointerProperty,
@@ -30,6 +13,7 @@ from bpy.props import (
FloatVectorProperty,
CollectionProperty,
)
from bonsai.bim.module.document.data import DocumentData
from typing import TYPE_CHECKING, Union
@@ -49,18 +33,50 @@ def update_document_identification(self: "Document", context: bpy.types.Context)
tool.Document.set_external_reference_id(document, self.identification)
def update_active_document_index(self, context):
refresh()
if document := self.active_document:
if document.ifc_definition_id:
DocumentData.load_document_objects_into_props(document.ifc_definition_id)
class Document(PropertyGroup):
name: StringProperty(name="Name", update=update_document_name)
identification: StringProperty(name="Identification", update=update_document_identification)
is_information: BoolProperty(
name="Is Information",
description="Whether element is IfcDocumentInformation, otherwise it's IfcDocumentReference.",
name: StringProperty(name="Name")
identification: StringProperty(name="Identification")
description: StringProperty(name="Description")
ifc_definition_id: IntProperty(name="IFC Definition ID")
location: StringProperty(name="Location", default="")
tree_depth: IntProperty(name="Tree Depth", default=0)
has_children: BoolProperty(name="Has Children", default=False)
is_expanded: BoolProperty(name="Is Expanded", default=False)
document_type: EnumProperty(
name="Document Type",
items=[
("PROJECT", "Project", "Virtual project root node"),
("INFORMATION", "Information", "IfcDocumentInformation"),
("REFERENCE", "Reference", "IfcDocumentReference"),
],
default="INFORMATION",
)
if TYPE_CHECKING:
name: str
identification: str
description: str
ifc_definition_id: int
location: str
tree_depth: int
has_children: bool
is_expanded: bool
document_type: str
class DocumentObject(PropertyGroup):
name: StringProperty(name="Name")
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
identification: str
is_information: bool
name: str
ifc_definition_id: int
@@ -68,17 +84,23 @@ class BIMDocumentProperties(PropertyGroup):
document_attributes: CollectionProperty(name="Document Attributes", type=Attribute)
active_document_id: IntProperty(name="Active Document Id")
documents: CollectionProperty(name="Documents", type=Document)
breadcrumbs: CollectionProperty(name="Breadcrumbs", type=StrProperty)
active_document_index: IntProperty(name="Active Document Index")
active_document_index: IntProperty(name="Active Document Index", update=update_active_document_index)
is_editing: BoolProperty(name="Is Editing", default=False)
is_object_editing: BoolProperty(name="Is Object Editing", default=False)
document_objects: CollectionProperty(name="Document Objects", type=DocumentObject)
active_document_object_index: IntProperty(name="Active Document Object Index")
json_string: StringProperty(name="JSON String", default="[]")
if TYPE_CHECKING:
document_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
active_document_id: int
documents: bpy.types.bpy_prop_collection_idprop[Document]
breadcrumbs: bpy.types.bpy_prop_collection_idprop[StrProperty]
active_document_index: int
is_editing: bool
is_object_editing: bool
document_objects: bpy.types.bpy_prop_collection_idprop[DocumentObject]
active_document_object_index: int
json_string: str
@property
def active_document(self) -> Union[Document, None]:
+225 -59
View File
@@ -16,6 +16,7 @@
# 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 bonsai.tool as tool
from bpy.types import Panel, UIList
from bonsai.bim.helper import draw_attributes
@@ -42,7 +43,8 @@ class BIM_PT_documents(Panel):
self.props = tool.Document.get_document_props()
row = self.layout.row(align=True)
row.label(text="{} Documents Found".format(DocumentData.data["total_information"]), icon="FILE")
row.label(text="{} Documents found".format(DocumentData.data["total_documents"]), icon="FILE")
if self.props.is_editing:
row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL")
else:
@@ -52,34 +54,54 @@ class BIM_PT_documents(Panel):
return
row = self.layout.row(align=True)
if self.props.breadcrumbs:
row.operator("bim.load_parent_document", text="", icon="FRAME_PREV")
row.label(text=DocumentData.data["parent_document"])
else:
row.alignment = "RIGHT"
row.operator("bim.add_information", text="", icon="ADD")
if self.props.breadcrumbs:
row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN")
row.alignment = "RIGHT"
active_document = self.props.active_document
if self.props.active_document_id:
if self.props.active_document_id > 0:
row.operator("bim.edit_document", text="", icon="CHECKMARK")
row.operator("bim.disable_editing_document", text="", icon="CANCEL")
elif active_document:
ifc_definition_id = active_document.ifc_definition_id
row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = (
ifc_definition_id
)
row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id
row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id
row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id
else:
if not self.props.active_document or self.props.active_document.document_type in ["INFORMATION", "PROJECT"]:
row.operator("bim.add_information", text="", icon="ADD")
if self.props.active_document and (
self.props.active_document.document_type == "INFORMATION"
and self.props.active_document.document_type != "PROJECT"
):
row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN")
active_document = self.props.active_document
if active_document:
ifc_definition_id = active_document.ifc_definition_id
if active_document.document_type != "PROJECT":
row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = (
ifc_definition_id
)
row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id
row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = (
ifc_definition_id
)
row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id
self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index")
if self.props.active_document_id:
if self.props.active_document_id > 0:
active_document = self.props.active_document
draw_attributes(self.props.document_attributes, self.layout)
if self.props.is_editing and self.props.active_document:
document = self.props.active_document
box = self.layout.box()
row = box.row(align=True)
row.label(text="Assigned Objects", icon="OUTLINER_OB_EMPTY")
box.template_list(
"BIM_UL_document_objects",
"",
self.props,
"document_objects",
self.props,
"active_document_object_index",
)
class BIM_PT_object_documents(Panel):
bl_label = "Documents"
@@ -102,65 +124,209 @@ class BIM_PT_object_documents(Panel):
return True
def draw(self, context):
obj = context.active_object
if not ObjectDocumentData.is_loaded:
ObjectDocumentData.load()
obj = context.active_object
self.oprops = tool.Blender.get_object_bim_props(obj)
self.props = tool.Document.get_document_props()
self.file = tool.Ifc.get()
self.draw_add_ui()
if not ObjectDocumentData.data["documents"]:
row = self.layout.row(align=True)
row.label(text="No Documents", icon="FILE")
for document in ObjectDocumentData.data["documents"]:
row = self.layout.row(align=True)
row.label(text=document["identification"] or "*", icon="FILE")
row.label(text=document["name"] or "Unnamed")
if document["location"]:
row.operator("bim.open_uri", icon="URL", text="").uri = document["location"]
row.operator("bim.unassign_document", text="", icon="X").document = document["id"]
def draw_add_ui(self):
if not self.props.is_editing:
row = self.layout.row(align=True)
row.operator("bim.load_project_documents", text="Assign Document References", icon="ADD")
return
doc_count = len(ObjectDocumentData.data["documents"])
row = self.layout.row(align=True)
if self.props.breadcrumbs:
row.operator("bim.load_parent_document", text="", icon="FRAME_PREV")
row.label(text=DocumentData.data["parent_document"])
row.label(text="{} Documents Assigned".format(doc_count), icon="FILE")
if self.props.is_object_editing:
row.operator("bim.disable_object_document_editing_ui", text="", icon="CANCEL")
else:
row.operator("bim.load_object_documents", text="", icon="IMPORT")
if not self.props.is_object_editing and doc_count == 0:
row = self.layout.row()
row.label(text="No documents assigned", icon="INFO")
return
if self.props.is_object_editing:
self.draw_add_ui()
box = self.layout.box()
row = box.row(align=True)
row.label(text="Assigned Documents", icon="OUTLINER_OB_EMPTY")
if doc_count > 0:
col = box.column(align=True)
for document in ObjectDocumentData.data["documents"]:
row = col.row(align=True)
# Create a split layout to separate left and right sides
split = row.split(factor=0.7) # Adjust factor as needed (0.7 = 70% left, 30% right)
# Left side - Document identification and name
left_side = split.row(align=True)
left_side.alignment = "LEFT"
left_side.label(text=document["identification"] or "*", icon="FILE")
left_side.label(text=document["name"] or "Unnamed")
# Right side - Action buttons
right_side = split.row(align=True)
right_side.alignment = "RIGHT" # Align buttons to the right
if document["location"]:
if document["location"].lower().endswith(".ifc"):
right_side.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = document[
"location"
]
right_side.operator("bim.open_uri", icon="URL", text="").uri = document["location"]
right_side.operator("bim.unassign_document", text="", icon="X").document = document["id"]
def draw_add_ui(self):
if self.props.is_object_editing:
row = self.layout.row(align=True)
row.alignment = "RIGHT"
if self.props.documents and self.props.active_document_index < len(self.props.documents):
document = self.props.documents[self.props.active_document_index]
if not document.is_information:
row.operator("bim.assign_document", text="", icon="ADD").document = document.ifc_definition_id
row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL")
if self.props.active_document:
document = self.props.active_document
self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index")
assigned_doc_ids = []
for doc in ObjectDocumentData.data["documents"]:
assigned_doc_ids.append(doc["id"])
if (
document.document_type == "INFORMATION"
and document.document_type != "PROJECT"
and document.ifc_definition_id not in assigned_doc_ids
):
doc_op = row.operator("bim.assign_document", text="", icon="BRUSH_DATA")
doc_op.document = document.ifc_definition_id
elif document.ifc_definition_id in assigned_doc_ids:
row.label(text="", icon="CHECKMARK")
self.layout.template_list(
"BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index"
)
class BIM_UL_documents(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
if item:
row = layout.row(align=True)
indent_depth = 0
if item.is_information:
op = row.operator("bim.load_document", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT")
op.document = item.ifc_definition_id
row.label(text="", icon="FILE")
else:
if item.document_type != "PROJECT":
if item.tree_depth > 1:
indent_depth = item.tree_depth - 1
for i in range(indent_depth):
row.label(text="", icon="BLANK1")
row.label(text="", icon="FILE_HIDDEN")
if item.document_type == "PROJECT":
row.label(text="", icon="OUTLINER_COLLECTION")
row.label(text=item.name)
return
if item.document_type == "INFORMATION" and item.has_children:
op = row.operator(
"bim.toggle_document", icon="TRIA_DOWN" if item.is_expanded else "TRIA_RIGHT", text="", emboss=False
)
op.document = item.ifc_definition_id
op.option = "Collapse" if item.is_expanded else "Expand"
elif item.document_type == "INFORMATION":
row.label(text="", icon="BLANK1")
if item.document_type == "INFORMATION":
row.label(text="", icon="FILE")
text = " - ".join([x for x in [item.location, item.description, item.name] if x])
else:
row.label(text="", icon="FILE_HIDDEN")
text = " - ".join([x for x in [item.location, item.description] if x])
split1 = row.split(factor=0.1)
# split1.label(text=item.identification)
split1.prop(item, "identification", text="", emboss=False)
split2 = split1.split(factor=0.9)
split2.prop(item, "name", text="", emboss=False)
split2 = split1.split(factor=0.8)
split2.label(text=text)
if item.location:
uri = ObjectDocumentData.convert_to_file_uri(item.location)
if item.location.lower().endswith(".ifc"):
row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = uri
row.operator("bim.open_uri", icon="URL", text="").uri = uri
class BIM_UL_document_objects(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
if item:
row = layout.row(align=True)
row.prop(item, "name", text="", emboss=False, icon="OBJECT_DATA")
row.operator("bim.select_object", text="", icon="RESTRICT_SELECT_OFF").obj_name = item.name
props = tool.Document.get_document_props()
if props.active_document:
document = props.active_document
op = row.operator("bim.unassign_document", text="", icon="X")
op.document = document.ifc_definition_id
op.obj = item.name
def add_object_documents_context_menu(self, context):
if not context.active_object:
return
if not tool.Blender.get_ifc_definition_id(context.active_object):
return
self.layout.separator()
self.layout.menu("BIM_MT_object_documents_context_menu", icon="FILE")
class BIM_MT_object_documents_context_menu(bpy.types.Menu):
bl_idname = "BIM_MT_object_documents_context_menu"
bl_label = "Documents"
def draw(self, context):
layout = self.layout
if not context.selected_objects:
layout.label(text="No documents", icon="INFO")
return
if len(context.selected_objects) > 1:
layout.label(text="Select a single object to see its referenced documents", icon="INFO")
return
obj = context.active_object
if not obj or not tool.Blender.get_ifc_definition_id(obj):
layout.label(text="No documents", icon="INFO")
return
if not ObjectDocumentData.is_loaded:
ObjectDocumentData.load()
if not ObjectDocumentData.data["documents"]:
layout.label(text="No Documents", icon="FILE")
else:
for document in ObjectDocumentData.data["documents"]:
row = layout.row(align=True)
with_ifc_icon = document["location"] and document["location"].lower().endswith(".ifc")
with_url_icon = bool(document["location"])
if with_ifc_icon:
row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = document["location"]
else:
row.label(text="", icon="BLANK1")
if with_url_icon:
row.operator("bim.open_uri", icon="URL", text="").uri = document["location"]
else:
row.label(text="", icon="BLANK1")
doc_entity = None
if "id" in document:
doc_entity = tool.Ifc.get().by_id(document["id"])
if doc_entity and doc_entity.is_a("IfcDocumentReference"):
display_text = document.get("description") or ""
else:
display_text = document.get("name") or ""
row.label(text=f"{document['identification'] or ''}: {display_text}")
@@ -158,6 +158,7 @@ def menu_func(self, context):
if element and element.is_a("IfcAnnotation") and element.ObjectType in ["SECTION", "ELEVATION"]:
self.layout.operator("bim.activate_drawing_by_annotation", text="Go to Drawing")
def register():
if not bpy.app.background:
bpy.utils.register_tool(workspace.AnnotationTool, after={"bim.bim_tool"}, separator=True, group=False)
@@ -170,7 +171,7 @@ def register():
bpy.app.handlers.load_post.append(handler.load_post)
bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler)
bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button)
bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
def unregister():
@@ -313,15 +313,13 @@ def format_distance(
if not feet and not add_inches:
tx_dist += str(feet) + "'"
# Add "0' - " when we have inches but no feet
# But only add " - " separator if we actually have inches to show
if not feet and add_inches:
tx_dist += "0' - "
if value < 0:
tx_dist += "-0' - "
else:
tx_dist += "0' - "
elif feet and add_inches:
tx_dist += " - "
if not feet and value < 0:
tx_dist += "-"
if add_inches:
if feet == 0 and inches == 0 and not frac:
# Special case: exactly zero, show "0"
@@ -353,20 +353,20 @@ class CreateDrawing(bpy.types.Operator):
# Clear any local camera setup and force viewport to use scene camera
for area in context.screen.areas:
if area.type == 'VIEW_3D':
if area.type == "VIEW_3D":
for space in area.spaces:
if space.type == 'VIEW_3D':
if space.type == "VIEW_3D":
# Clear local camera to ensure we use scene.camera
space.use_local_camera = False
space.camera = context.scene.camera
space.region_3d.view_perspective = 'CAMERA'
space.region_3d.view_perspective = "CAMERA"
print(f"Set viewport camera to: {context.scene.camera.name}")
break
# Force complete scene update
context.view_layer.update()
context.evaluated_depsgraph_get()
underlay_svg = self.generate_underlay(context)
with profile("Generate linework"):
@@ -3078,9 +3078,9 @@ class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
filename_ext = ".svg"
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement)
directory: bpy.props.StringProperty(subtype='DIR_PATH')
directory: bpy.props.StringProperty(subtype="DIR_PATH")
def _execute(self, context):
# Handle both single and multiple file selection
@@ -3355,14 +3355,14 @@ class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator):
for i, literal_backup in enumerate(literals_backup):
if i < len(props.literals):
literal_props = props.literals[i]
if assigned_product_obj:
literal_props.product_used = assigned_product_obj
elif "product_used" in literal_backup and literal_backup["product_used"]:
product_name = literal_backup["product_used"]
if product_name in bpy.data.objects:
literal_props.product_used = bpy.data.objects[product_name]
literal_props.element_value_rows.clear()
if "element_value_rows" in literal_backup:
for row_data in literal_backup["element_value_rows"]:
@@ -4251,63 +4251,62 @@ class ActivateDrawingByAnnotation(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Activate Drawing"
bl_description = "Activate the drawing corresponding to the selected annotation"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
# Check if an annotation object is selected
if not context.selected_objects:
cls.poll_message_set("No object selected")
return False
active_obj = context.active_object
if not active_obj:
cls.poll_message_set("No active object")
return False
element = tool.Ifc.get_entity(active_obj)
if not element:
cls.poll_message_set("Selected object is not an IFC element")
return False
# Check if it's an IfcAnnotation with ObjectType = "SECTION" or "ELEVATION"
if not element.is_a("IfcAnnotation") or element.ObjectType not in ["SECTION", "ELEVATION"]:
cls.poll_message_set("Selected object is not a drawing annotation")
return False
return True
def _execute(self, context):
active_obj = context.active_object
element = tool.Ifc.get_entity(active_obj)
if not element or not element.is_a("IfcAnnotation") or element.ObjectType not in ["SECTION", "ELEVATION"]:
self.report({"ERROR"}, "Selected object is not a drawing annotation")
return {"CANCELLED"}
# Find the drawing/camera element that this annotation references
drawing_element = self.find_drawing_from_annotation(element)
if not drawing_element:
self.report({"ERROR"}, "Could not find drawing element for this annotation")
return {"CANCELLED"}
# Use the existing ActivateDrawing operator with the drawing element's ID
bpy.ops.bim.activate_drawing(drawing=drawing_element.id())
return {"FINISHED"}
def find_drawing_from_annotation(self, annotation_element):
"""Find the drawing/camera element that this annotation references."""
ifc = tool.Ifc.get()
# Check IfcRelAssignsToProduct relationships
for rel in ifc.get_inverse(annotation_element):
if rel.is_a("IfcRelAssignsToProduct") and rel.RelatingProduct:
if rel.RelatingProduct.is_a("IfcAnnotation"):
# Found the drawing element!
return rel.RelatingProduct
return None
@@ -5062,7 +5061,7 @@ class AddElementValueRow(bpy.types.Operator):
new_row.category = literal_props.category_for_adding
new_row.element_key = ""
new_row.formatted_value = ""
if len(literal_props.element_value_rows) == 1:
new_row.separator = ""
else:
@@ -5106,10 +5105,10 @@ class ElementValueSuggestionsPopup(bpy.types.Operator):
row_index: bpy.props.IntProperty()
category: bpy.props.StringProperty()
search_query: bpy.props.StringProperty(name="Search", description="Search for element values")
collection_keys: bpy.props.CollectionProperty(type=StrProperty)
collection_descriptions: bpy.props.CollectionProperty(type=StrProperty)
selected_key: bpy.props.StringProperty()
def invoke(self, context, event):
@@ -5157,13 +5156,13 @@ class ElementValueSuggestionsPopup(bpy.types.Operator):
def draw(self, context):
layout = self.layout
layout.prop_search(self, "selected_key", self, "collection_descriptions", text="Value")
def execute(self, context):
if not self.selected_key:
return {"CANCELLED"}
obj = context.active_object
if not obj:
return {"CANCELLED"}
@@ -5177,7 +5176,7 @@ class ElementValueSuggestionsPopup(bpy.types.Operator):
return {"CANCELLED"}
value_row = literal_props.element_value_rows[self.row_index]
for idx, desc_item in enumerate(self.collection_descriptions):
if desc_item.name == self.selected_key:
actual_key = self.collection_keys[idx].name
@@ -5260,10 +5259,7 @@ class FormatElementValueRow(bpy.types.Operator):
custom_expression: bpy.props.StringProperty(
name="Custom Expression",
description=(
"Custom expression using functions\n"
"Use {{value}} as placeholder for the current row's value."
),
description=("Custom expression using functions\n" "Use {{value}} as placeholder for the current row's value."),
default='concat({{value}}, " - additional text")',
)
@@ -5288,51 +5284,51 @@ class FormatElementValueRow(bpy.types.Operator):
def _load_formatting_from_row(self, row):
"""Parse the formatted_value to load existing formatting settings"""
import re
formatted_value = row.formatted_value
if not formatted_value or formatted_value == f"{{{{{row.element_key}}}}}":
self.formatting_type = "NONE"
return
if formatted_value.startswith("``") and formatted_value.endswith("``"):
expression = formatted_value[2:-2].strip()
else:
self.formatting_type = "NONE"
return
if match := re.match(r"upper\(\{\{[^}]+\}\}\)", expression):
self.formatting_type = "UPPER"
elif match := re.match(r"lower\(\{\{[^}]+\}\}\)", expression):
self.formatting_type = "LOWER"
elif match := re.match(r"title\(\{\{[^}]+\}\}\)", expression):
self.formatting_type = "TITLE"
elif match := re.match(r"int\(\{\{[^}]+\}\}\)", expression):
self.formatting_type = "INT"
elif match := re.match(r"round\(\{\{[^}]+\}\},\s*([^)]+)\)", expression):
self.formatting_type = "ROUND"
self.round_precision = match.group(1).strip()
elif match := re.match(r"number\(\{\{[^}]+\}\},\s*([^,]+),\s*([^)]+)\)", expression):
self.formatting_type = "NUMBER"
self.decimal_separator = match.group(1).strip()
self.thousands_separator = match.group(2).strip()
elif match := re.match(r"metric_length\(\{\{[^}]+\}\},\s*([^,]+),\s*([^)]+)\)", expression):
self.formatting_type = "METRIC_LENGTH"
self.metric_precision = match.group(1).strip()
self.metric_decimals = int(match.group(2).strip())
elif match := re.match(r'imperial_length\(\{\{[^}]+\}\},\s*(\d+),\s*"([^"]+)",\s*"([^"]+)"\)', expression):
self.formatting_type = "IMPERIAL_LENGTH"
self.imperial_precision = int(match.group(1).strip())
self.imperial_input_unit = match.group(2).strip()
self.imperial_output_unit = match.group(3).strip()
else:
self.formatting_type = "CUSTOM"
self.custom_expression = expression
@@ -5450,9 +5446,9 @@ class ApplyElementValueRowsToLiteral(bpy.types.Operator):
default_format = f"{{{{{row.element_key}}}}}"
row.formatted_value = default_format
value_part = default_format
parts.append(row.separator + value_part)
concatenated_value = "".join(parts)
for attr in literal_props.attributes:
@@ -5469,12 +5465,12 @@ class ApplyElementValueRowsToLiteral(bpy.types.Operator):
This preserves formatting functions like upper(), round(), etc.
"""
import re
pattern = r'\{\{[^}]+\}\}'
pattern = r"\{\{[^}]+\}\}"
new_base_value = f"{{{{{new_element_key}}}}}"
updated_value = re.sub(pattern, new_base_value, old_formatted_value)
return updated_value
+13 -11
View File
@@ -714,7 +714,9 @@ class ElementValueRow(PropertyGroup):
)
element_key: StringProperty(
name="Element Key", description="The element value key (e.g., 'id', 'Name', 'Pset_WallCommon.Reference')", default=""
name="Element Key",
description="The element value key (e.g., 'id', 'Name', 'Pset_WallCommon.Reference')",
default="",
)
formatted_value: StringProperty(
@@ -756,33 +758,33 @@ def get_category_items_with_counts(self, context):
("Coordinates", "Coordinates", "Coordinate information", "EMPTY_ARROWS"),
("Custom String", "Custom String", "Add custom text (no element key)", "SMALL_CAPS"),
]
obj = context.active_object
if obj and tool.Ifc.get_entity(obj):
try:
element = tool.Ifc.get_entity(obj)
text_element = element
if hasattr(self, 'product_used'):
if hasattr(self, "product_used"):
if self.product_used:
element = tool.Ifc.get_entity(self.product_used)
else:
assigned = tool.Drawing.get_assigned_product(text_element)
if assigned:
element = assigned
available_keys = ElementValuesData.get_available_element_value_keys(element)
items = []
for i, (identifier, base_name, description, icon) in enumerate(category_metadata):
count = len(available_keys.get(identifier, []))
display_name = f"{base_name} ({count})" if count > 0 else base_name
items.append((identifier, display_name, description, icon, i))
return items
except Exception as e:
pass
return [(id, name, desc, icon, i) for i, (id, name, desc, icon) in enumerate(category_metadata)]
@@ -843,16 +845,16 @@ class LiteralProps(PropertyGroup):
)
element_value_rows: CollectionProperty(
name="Element Value Rows",
name="Element Value Rows",
type=ElementValueRow,
description="Collection of element value rows for building the literal value"
description="Collection of element value rows for building the literal value",
)
category_for_adding: EnumProperty(
name="Category for Adding",
items=get_category_items_with_counts,
default=0,
description="Category to use when adding a new element value row"
description="Category to use when adding a new element value row",
)
if TYPE_CHECKING:
@@ -115,9 +115,13 @@ def parse_markdown_it(text: str) -> list[dict[str, Union[str, None]]]:
if tokens[j].type == "inline":
for child in tokens[j].children or []:
if child.type == "softbreak":
segments.append({"text": None, "url": None, "break": True, "bold": False, "italic": False})
segments.append(
{"text": None, "url": None, "break": True, "bold": False, "italic": False}
)
elif child.type == "html_inline" and child.content.strip().lower() == "<br>":
segments.append({"text": None, "url": None, "break": True, "bold": False, "italic": False})
segments.append(
{"text": None, "url": None, "break": True, "bold": False, "italic": False}
)
elif child.type == "strong_open":
bold = True
elif child.type == "strong_close":
@@ -133,11 +137,27 @@ def parse_markdown_it(text: str) -> list[dict[str, Union[str, None]]]:
elif child.type == "link_close" and link_opening:
url = link_opening.attrGet("href")
if url and link_text:
segments.append({"text": link_text, "url": url, "break": False, "bold": bold, "italic": italic})
segments.append(
{
"text": link_text,
"url": url,
"break": False,
"bold": bold,
"italic": italic,
}
)
link_opening = None
link_text = None
elif child.type == "text" and not link_opening:
segments.append({"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic})
segments.append(
{
"text": child.content,
"url": None,
"break": False,
"bold": bold,
"italic": italic,
}
)
j += 1
i = j
else:
@@ -168,7 +188,9 @@ def parse_markdown_it(text: str) -> list[dict[str, Union[str, None]]]:
link_opening = None
link_text = None
elif child.type == "text" and not link_opening:
segments.append({"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic})
segments.append(
{"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic}
)
i += 1
segments = [seg for seg in segments if seg.get("text") is not None or seg.get("break", False)]
if not segments:
@@ -257,14 +279,16 @@ class SvgWriter:
self.height = self.raw_height * self.svg_scale
def add_stylesheet(self):
path = self.resource_paths["Stylesheet"]
if not path:
paths = self.resource_paths["Stylesheet"]
if not paths:
return
if not os.path.exists(path):
print(f"WARNING. Couldn't find stylesheet for the drawing by the path: {path}")
return
with open(path, "r") as stylesheet:
self.svg.defs.add(self.svg.style(stylesheet.read()))
path_list = [p.strip() for p in paths.split(",")]
for path in path_list:
if not os.path.exists(path):
print(f"WARNING. Couldn't find stylesheet for the drawing by the path: {path}")
continue
with open(path, "r") as stylesheet:
self.svg.defs.add(self.svg.style(stylesheet.read()))
def add_markers(self):
path = self.resource_paths["Markers"]
+17 -14
View File
@@ -476,7 +476,6 @@ class BIM_PT_sheets(Panel):
op = row3.operator("bim.activate_drawing_from_sheet", icon="OUTLINER_OB_CAMERA", text="")
if active_sheet.reference_type == "DRAWING":
drawingnamesvg = active_sheet.name
drawingname = drawingnamesvg.split(".svg")[0]
@@ -679,14 +678,14 @@ class BIM_PT_text(Panel):
if len(literal_props.attributes) > 0 and i < len(props.literal_apply_settings):
row = box.row(align=True)
row.prop(literal_props.attributes[0], "string_value", text="Literal")
bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True)
expand_icon = "DOWNARROW_HLT" if getattr(literal_props, "show_element_values", False) else "RIGHTARROW"
op = row.operator("bim.toggle_element_values_panel", icon=expand_icon, text="")
op.literal_prop_id = i
row.prop(props.literal_apply_settings[i], "apply_text_to_all", text="", icon="COPYDOWN")
element = tool.Ifc.get_entity(obj)
assigned_element = tool.Drawing.get_assigned_product(element) or element
resolved_value = tool.Drawing.replace_text_literal_variables(
@@ -711,8 +710,10 @@ class BIM_PT_text(Panel):
element_values_row.prop(literal_props, "product_used", text="", icon="EYEDROPPER")
current_product = get_current_product_for_element_values(obj, literal_props)
product_name = current_product.name if (current_product and hasattr(current_product, "name")) else "Unknown"
product_name = (
current_product.name if (current_product and hasattr(current_product, "name")) else "Unknown"
)
source_row = values_box.row()
source_row.label(text=f"Source: {product_name}", icon="OBJECT_DATA")
@@ -720,29 +721,29 @@ class BIM_PT_text(Panel):
if element:
add_row = values_box.row(align=True)
add_row.prop(literal_props, "category_for_adding", text="")
op = add_row.operator("bim.add_element_value_row", text="Add Element", icon="ADD")
op.literal_prop_id = i
if len(literal_props.element_value_rows) > 0:
for row_idx, value_row in enumerate(literal_props.element_value_rows):
row = values_box.row(align=True)
is_custom_string = value_row.category == "Custom String"
if is_custom_string:
category_icon = get_category_icon(value_row.category)
row.prop(value_row, "element_key", text="", icon=category_icon)
else:
split = row.split(factor=0.25, align=True)
sep_col = split.row(align=True)
sep_col.prop(value_row, "separator", text="")
key_col = split.row(align=True)
category_icon = get_category_icon(value_row.category)
key_col.prop(value_row, "element_key", text="", icon=category_icon)
op = row.operator("bim.element_value_suggestions_popup", text="", icon="VIEWZOOM")
op.literal_prop_id = i
op.row_index = row_idx
@@ -758,7 +759,9 @@ class BIM_PT_text(Panel):
apply_row = values_box.row()
apply_row.scale_y = 1.2
op = apply_row.operator("bim.apply_element_value_rows_to_literal", text="Apply to Literal", icon="CHECKMARK")
op = apply_row.operator(
"bim.apply_element_value_rows_to_literal", text="Apply to Literal", icon="CHECKMARK"
)
op.literal_prop_id = i
else:
error_row = values_box.row()
+188 -49
View File
@@ -1205,13 +1205,84 @@ class OverrideDuplicateMove(bpy.types.Operator):
for obj in objects_to_remove:
tool.Blender.deselect_object(obj)
# Expand selection to include all parts of selected aggregates
objects_to_duplicate = set(context.selected_objects) - objects_to_remove
expanded_objects = set(objects_to_duplicate)
for obj in objects_to_duplicate:
element = tool.Ifc.get_entity(obj)
if element and element.is_a("IfcElementAssembly"):
parts = tool.Aggregate.get_parts_recursively(element)
for part in parts:
part_obj = tool.Ifc.get_object(part)
if part_obj:
expanded_objects.add(part_obj)
# Store parent aggregate relationships
parent_aggregates = {}
for obj in expanded_objects:
element = tool.Ifc.get_entity(obj)
if element and element.is_a("IfcElementAssembly"):
parent_aggregate = ifcopenshell.util.element.get_aggregate(element)
if parent_aggregate:
parent_aggregates[element] = parent_aggregate
old_to_new, new_active_obj = tool.Geometry.duplicate_ifc_objects(
set(context.selected_objects) - objects_to_remove,
expanded_objects,
linked=linked,
active_object=context.active_object,
)
# Restore parent aggregate relationships, but only for parents that were NOT duplicated
for old_elem, new_elems in old_to_new.items():
if old_elem in parent_aggregates:
old_parent = parent_aggregates[old_elem]
# Check if the parent was also duplicated
if old_parent in old_to_new:
# The duplication already created the correct nested relationship
continue
# Parent was NOT duplicated, so we need to assign to the original parent
for new_elem in new_elems:
new_obj = tool.Ifc.get_object(new_elem)
parent_obj = tool.Ifc.get_object(old_parent)
if new_obj and parent_obj:
bonsai.core.aggregate.assign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=parent_obj,
related_obj=new_obj,
)
# Select all duplicated objects and their parts
all_objects_to_select = set()
for old_elem, new_elems in old_to_new.items():
for new_elem in new_elems:
new_obj = tool.Ifc.get_object(new_elem)
if new_obj:
all_objects_to_select.add(new_obj)
# If it's an aggregate, also select all its parts
if new_elem.is_a("IfcElementAssembly"):
parts = tool.Aggregate.get_parts_recursively(new_elem)
for part in parts:
part_obj = tool.Ifc.get_object(part)
if part_obj:
all_objects_to_select.add(part_obj)
# Deselect everything first
bpy.ops.object.select_all(action="DESELECT")
# Select all the duplicated objects
for obj in all_objects_to_select:
obj.select_set(True)
if new_active_obj:
context.view_layer.objects.active = new_active_obj
return old_to_new
@@ -1491,6 +1562,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
old_to_new = {}
original_data: dict[int, dict[int, dict[str, Any]]] = {}
# Define all nested functions FIRST
def delete_objects(element: ifcopenshell.entity_instance) -> None:
"""Remove IfcElementAssembly and it's parts."""
parts = ifcopenshell.util.element.get_parts(element)
@@ -1542,7 +1614,10 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
if r.is_a("IfcRelAssignsToGroup")
if self.group_name in r.RelatingGroup.Name
).id()
original_data[group] = {}
# Initialize if not exists
if group not in original_data:
original_data[group] = {}
pset: dict[str, Any] = ifcopenshell.util.element.get_pset(element, self.pset_name)
index: int = pset["Index"]
@@ -1559,8 +1634,13 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
if parts:
for part in parts:
if part.is_a("IfcElementAssembly"):
# TODO: unused expression.
original_data | get_original_data(part)
# Recursively collect data from nested assemblies
nested_data = get_original_data(part)
# Merge nested data into original_data
for nested_group_id, nested_group_data in nested_data.items():
if nested_group_id not in original_data:
original_data[nested_group_id] = {}
original_data[nested_group_id].update(nested_group_data)
else:
try:
pset = ifcopenshell.util.element.get_pset(part, self.pset_name)
@@ -1584,50 +1664,123 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
): # if element has parts it means it is the base of and aggregate or sub-aggregate
aggregate = element
group = next(
r.RelatingGroup
for r in getattr(aggregate, "HasAssignments", []) or []
if r.is_a("IfcRelAssignsToGroup")
if self.group_name in r.RelatingGroup.Name
).id()
if not group:
# Get the new group
new_group_entity = next(
(
r.RelatingGroup
for r in getattr(aggregate, "HasAssignments", []) or []
if r.is_a("IfcRelAssignsToGroup")
if self.group_name in r.RelatingGroup.Name
),
None,
)
if not new_group_entity:
return
pset = ifcopenshell.util.element.get_pset(element, self.pset_name)
if not pset:
return
index = pset["Index"]
# Find the matching old group by looking for the same aggregate name
matching_group_id = None
if index == 0:
obj.name = pset["Name"] + "_" + str(original_data[group][index]["Aggregate_Index"])
# This is a root assembly - find by Name
aggregate_name = pset.get("Name")
for group_id, group_data in original_data.items():
if 0 in group_data and group_data[0].get("Name") == aggregate_name:
matching_group_id = group_id
break
else:
# This is a part - find the group that has this index
for group_id, group_data in original_data.items():
if index in group_data:
matching_group_id = group_id
break
if matching_group_id is None:
return
if index == 0:
obj.name = pset["Name"] + "_" + str(original_data[matching_group_id][index]["Aggregate_Index"])
ifc_file = tool.Ifc.get()
ifcopenshell.api.pset.edit_pset(
ifc_file,
ifc_file.by_id(pset["id"]),
properties={"Aggregate_Index": int(original_data[group][index]["Aggregate_Index"])},
properties={"Aggregate_Index": int(original_data[matching_group_id][index]["Aggregate_Index"])},
)
bonsai.core.spatial.assign_container(
tool.Ifc,
tool.Collector,
tool.Spatial,
container=original_data[group][index]["Container"],
element_obj=obj,
)
for part in ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(obj)):
tool.Collector.assign(tool.Ifc.get_object(part))
assignments = original_data[group][index]["Assignment"]
if assignments:
assign_to_annotations(obj, assignments)
# Only assign container if element is not already aggregated under another element
# Aggregated elements should not be in the spatial structure
if not ifcopenshell.util.element.get_aggregate(element):
container = original_data[matching_group_id][index]["Container"]
bonsai.core.spatial.assign_container(
tool.Ifc,
tool.Collector,
tool.Spatial,
container=container,
element_obj=obj,
)
# Get the container's collection for moving parts in the outliner
container_obj = tool.Ifc.get_object(container)
container_collection = container_obj.BIMObjectProperties.collection if container_obj else None
# Move all parts to the container's collection in the outliner
if container_collection:
for part in ifcopenshell.util.element.get_parts(element):
part_obj = tool.Ifc.get_object(part)
if part_obj:
# Remove from all previous collections
for col in part_obj.users_collection[:]:
col.objects.unlink(part_obj)
# Link to container collection
if part_obj.name not in container_collection.objects:
container_collection.objects.link(part_obj)
# Recursively handle nested parts
for nested_part in ifcopenshell.util.element.get_parts(part):
nested_part_obj = tool.Ifc.get_object(nested_part)
if nested_part_obj:
for col in nested_part_obj.users_collection[:]:
col.objects.unlink(nested_part_obj)
if nested_part_obj.name not in container_collection.objects:
container_collection.objects.link(nested_part_obj)
else:
try:
obj.name = original_data[group][index]["Name"]
obj.name = original_data[matching_group_id][index]["Name"]
except:
pass
try:
assignments = original_data[group][index]["Assignment"]
assignments = original_data[matching_group_id][index]["Assignment"]
except:
assignments = []
if assignments:
assign_to_annotations(obj, assignments)
def get_original_matrix(
element: ifcopenshell.entity_instance, base_instance: ifcopenshell.entity_instance
) -> tuple[Matrix, tuple[Vector, Quaternion, Vector]]:
selected_obj = tool.Ifc.get_object(base_instance)
selected_matrix = selected_obj.matrix_world
object_duplicate = tool.Ifc.get_object(element)
duplicate_matrix = object_duplicate.matrix_world.decompose()
return selected_matrix, duplicate_matrix
def set_new_matrix(
selected_matrix: Matrix, duplicate_matrix: tuple[Vector, Quaternion, Vector], old_to_new: dict
) -> None:
for old, new in old_to_new.items():
new_obj = tool.Ifc.get_object(new[0])
new_base_matrix = Matrix.LocRotScale(*duplicate_matrix)
matrix_diff = Matrix.inverted(selected_matrix) @ new_obj.matrix_world
new_obj_matrix = new_base_matrix @ matrix_diff
new_obj.matrix_world = new_obj_matrix
def get_element_assembly(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
if element.is_a("IfcElementAssembly"):
return element
@@ -1671,26 +1824,6 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
return list(set(linked_aggregate_groups)), selected_parents
def get_original_matrix(
element: ifcopenshell.entity_instance, base_instance: ifcopenshell.entity_instance
) -> tuple[Matrix, tuple[Vector, Quaternion, Vector]]:
selected_obj = tool.Ifc.get_object(base_instance)
selected_matrix = selected_obj.matrix_world
object_duplicate = tool.Ifc.get_object(element)
duplicate_matrix = object_duplicate.matrix_world.decompose()
return selected_matrix, duplicate_matrix
def set_new_matrix(
selected_matrix: Matrix, duplicate_matrix: tuple[Vector, Quaternion, Vector], old_to_new: dict
) -> None:
for old, new in old_to_new.items():
new_obj = tool.Ifc.get_object(new[0])
new_base_matrix = Matrix.LocRotScale(*duplicate_matrix)
matrix_diff = Matrix.inverted(selected_matrix) @ new_obj.matrix_world
new_obj_matrix = new_base_matrix @ matrix_diff
new_obj.matrix_world = new_obj_matrix
active_element = tool.Ifc.get_entity(context.active_object)
if not active_element:
self.report({"INFO"}, "Object has no Ifc metadata.")
@@ -1727,6 +1860,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
base_pset = ifcopenshell.util.element.get_pset(base_instance, self.pset_name)
base_obj = tool.Ifc.get_object(base_instance)
base_obj.name = base_pset["Name"] + "_" + str(base_pset["Aggregate_Index"])
for element in instances_to_refresh:
if element.GlobalId == base_instance.GlobalId:
continue
@@ -1735,7 +1869,12 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
selected_matrix, duplicate_matrix = get_original_matrix(element, base_instance)
original_data = get_original_data(element)
# Merge data instead of overwriting
element_original_data = get_original_data(element)
for group_id, group_data in element_original_data.items():
if group_id not in original_data:
original_data[group_id] = {}
original_data[group_id].update(group_data)
delete_objects(element)
@@ -1749,7 +1888,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
set_new_matrix(selected_matrix, duplicate_matrix, old_to_new)
for old, new in old_to_new.items():
if element_aggregate and new[0].is_a("IfcElementAssembly"):
if element_aggregate and new[0].is_a("IfcElementAssembly") and old == base_instance:
new_aggregate = ifcopenshell.util.element.get_aggregate(new[0])
if not new_aggregate:
@@ -53,12 +53,13 @@ class GeoreferenceDecorator:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
def draw_batch(self, shader_type, content_pos, color, indices=None, should_scale=True):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
props = tool.Georeference.get_georeference_props()
self.scale = props.visualization_scale
content_pos = [v * self.scale for v in content_pos]
if should_scale:
scale = tool.Georeference.get_georeference_props().visualization_scale
content_pos = [v * scale for v in content_pos]
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
@@ -141,12 +142,13 @@ class GeoreferenceDecorator:
if wcs["blender_location"].length < 1000:
position = wcs["blender_location"].copy()
position -= Vector((0, 0.1, 0))
self.draw_text_at_position(context, text, position, should_scale=False)
else:
position = wcs["blender_location"].normalized() * 3
text += "\n(Warning: Actual XYZ Not Shown)"
position -= Vector((0, 0.1, 0))
self.draw_text_at_position(context, text, position)
position -= Vector((0, 0.1, 0))
self.draw_text_at_position(context, text, position)
if props.has_blender_offset:
text = "IFC Local Origin"
@@ -165,8 +167,10 @@ class GeoreferenceDecorator:
self.draw_text_at_position(context, text, location)
blf.disable(self.font_id, blf.SHADOW)
def draw_text_at_position(self, context, text, position):
position = [v * self.scale for v in position]
def draw_text_at_position(self, context, text, position, should_scale=True):
if should_scale:
scale = tool.Georeference.get_georeference_props().visualization_scale
position = [v * scale for v in position]
coords_2d = location_3d_to_region_2d(context.region, context.region_data, position)
if not coords_2d:
return
@@ -309,8 +313,8 @@ class GeoreferenceDecorator:
if wcs["blender_location"].length < 1000:
verts = [Vector((0, 0, 0)), wcs["blender_location"]]
edges = [[0, 1]]
self.draw_batch("LINES", verts, decorator_color_special, edges)
self.draw_batch("POINTS", verts[1:], decorator_color_special)
self.draw_batch("LINES", verts, decorator_color_special, edges, should_scale=False)
self.draw_batch("POINTS", verts[1:], decorator_color_special, should_scale=False)
else:
location = wcs["blender_location"].normalized()
edges = [[0, 1]]
@@ -332,7 +336,7 @@ class GeoreferenceDecorator:
self.draw_batch("LINES", verts, decorator_color_special, edges)
self.draw_dashed_line(location * 3, location * 6, decorator_color_error)
def draw_dashed_line(self, start, end, colour):
def draw_dashed_line(self, start, end, colour, should_scale=True):
direction = (end - start).normalized()
distance = (end - start).length
current_distance = Vector((0, 0, 0))
@@ -347,7 +351,7 @@ class GeoreferenceDecorator:
edges = [[i, i + 1] for i in range(0, len(points), 2)]
verts = points
self.draw_batch("LINES", verts, colour, edges)
self.draw_batch("LINES", verts, colour, edges, should_scale=should_scale)
def calculate_angles(self, context):
self.pn_angle = 0.0
@@ -215,7 +215,7 @@ class BIMGeoreferenceProperties(PropertyGroup):
description="Affects the georeference decorator size",
default=1,
soft_min=0.1,
soft_max=50,
soft_max=100,
)
grid_north_angle: StringProperty(name="Grid North Angle", update=update_grid_north_angle)
x_axis_abscissa: StringProperty(name="X Axis Abscissa", update=update_grid_north_vector)
@@ -176,6 +176,7 @@ class ObjectMaterialData:
cls.data["active_material_constituents"] = cls.active_material_constituents()
# after material_name and type_material
cls.data["is_type_material_overridden"] = cls.is_type_material_overridden()
cls.data["bbim_material_layer_pset"] = cls.bbim_material_layer_pset()
cls.is_loaded = True
@@ -426,3 +427,35 @@ class ObjectMaterialData:
# so we check occurrence material explicitly
occurrence_material = ifcopenshell.util.element.get_material(cls.element, should_inherit=False)
return bool(occurrence_material)
@classmethod
def bbim_material_layer_pset(cls) -> Union[dict[str, Any], None]:
"""Load BBIM_MaterialLayer pset data for display in UI."""
if not cls.element:
return None
pset_data = ifcopenshell.util.element.get_pset(cls.element, "BBIM_MaterialLayer")
if not pset_data or not pset_data.get("UseCustomOffset", False):
return None
# Keep offset in SI units - format_distance will handle conversion
custom_offset_si = pset_data.get("CustomOffset", 0.0)
# Get the appropriate reference based on usage type
usage_type = tool.Model.get_usage_type(cls.element)
custom_reference = None
reference_label = None
if usage_type == "LAYER2":
custom_reference = pset_data.get("CustomWallReference", "")
reference_label = "Wall Reference"
elif usage_type == "LAYER3":
custom_reference = pset_data.get("CustomSlabReference", "")
reference_label = "Slab Reference"
return {
"use_custom_offset": pset_data.get("UseCustomOffset", False),
"custom_offset": custom_offset_si, # Store in SI units
"custom_reference": custom_reference,
"reference_label": reference_label,
}
@@ -509,6 +509,10 @@ class EnableEditingAssignedMaterial(bpy.types.Operator):
bonsai.bim.helper.import_attributes(material[0], props.material_set_attributes)
else:
bonsai.bim.helper.import_attributes(material, props.material_set_attributes)
# Load custom offset from BBIM_MaterialLayer pset
tool.Model.load_custom_offset_from_pset(element, obj)
return {"FINISHED"}
def import_attributes_callback(
@@ -622,12 +626,16 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
layer_sets_to_regenerate.add(obj_material_usage.ForLayerSet)
# Save custom offset to BBIM_MaterialLayer pset
tool.Model.save_custom_offset_to_pset(obj_element, obj)
for layer_set in layer_sets_to_regenerate:
wall.DumbWallPlaner().regenerate_from_layer_set(layer_set)
slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set)
if material_set_usage.is_a("IfcMaterialProfileSetUsage"):
attributes["CardinalPoint"] = int(attributes["CardinalPoint"])
if "CardinalPoint" in attributes:
attributes["CardinalPoint"] = int(attributes["CardinalPoint"])
ifcopenshell.api.material.edit_profile_usage(
self.file,
usage=material_set_usage,
+142 -48
View File
@@ -20,6 +20,8 @@ from __future__ import annotations
import bonsai.bim.helper
import bonsai.tool as tool
import bpy
import ifcopenshell.util.element
import ifcopenshell.util.unit
from bpy.types import Panel, UIList
from bonsai.bim.helper import draw_attributes
from bonsai.bim.helper import prop_with_search
@@ -134,8 +136,6 @@ class BIM_PT_object_material(Panel):
@classmethod
def poll(cls, context):
if not tool.Blender.is_tab(context, "GEOMETRY"):
return False
if not (obj := context.active_object):
return False
ifc_id = tool.Blender.get_ifc_definition_id(obj)
@@ -228,31 +228,47 @@ class BIM_PT_object_material(Panel):
self.draw_read_only_set_ui()
def draw_editable_set_ui(self):
bonsai.bim.helper.draw_attributes(self.props.material_set_attributes, self.layout)
bonsai.bim.helper.draw_attributes(self.props.material_set_usage_attributes, self.layout)
# Material Set Attributes Section
row = self.layout.row(align=True)
box = row.box()
bonsai.bim.helper.draw_attributes(self.props.material_set_attributes, box)
bonsai.bim.helper.draw_attributes(self.props.material_set_usage_attributes, box)
# Custom Offset Section
self.draw_custom_offset()
if ObjectMaterialData.data["set_item_name"] == "profile" and not self.mprops.profiles:
row = self.layout.row(align=True)
row.label(text="No Profiles Available")
row.operator("bim.add_profile_def", icon="ADD", text="")
else:
layout = self.layout
layout.separator()
layout.separator()
row = self.layout.row(align=True)
if ObjectMaterialData.data["set_item_name"] == "profile":
prop_with_search(row, self.mprops, "profiles", icon="ITALIC", text="")
prop_with_search(row, self.props, "material", icon="MATERIAL", text="")
op = row.operator(f"bim.add_{ObjectMaterialData.data['set_item_name']}", icon="ADD", text="")
setattr(op, f"{ObjectMaterialData.data['set_item_name']}_set", ObjectMaterialData.data["set"]["id"])
# Dynamic header based on material set type
set_item_name = ObjectMaterialData.data["set_item_name"]
header_map = {
"layer": "Material Layers",
"profile": "Material Profiles",
"constituent": "Material Constituents",
"list_item": "Material List Items",
}
header_text = header_map.get(set_item_name, "Material Items")
self.layout.label(text=header_text)
total_items = len(ObjectMaterialData.data["set_items"])
layout = self.layout
box = layout.box()
row = self.layout.row(align=True)
box = row.box()
# Add Material Section (at the top of this box)
if ObjectMaterialData.data["set_item_name"] == "profile" and not self.mprops.profiles:
box_row = box.row(align=True)
box_row.label(text="No Profiles Available")
box_row.operator("bim.add_profile_def", icon="ADD", text="")
else:
box_row = box.row(align=True)
if ObjectMaterialData.data["set_item_name"] == "profile":
prop_with_search(box_row, self.mprops, "profiles", icon="ITALIC", text="")
prop_with_search(box_row, self.props, "material", icon="MATERIAL", text="")
op = box_row.operator(f"bim.add_{ObjectMaterialData.data['set_item_name']}", icon="ADD", text="")
setattr(op, f"{ObjectMaterialData.data['set_item_name']}_set", ObjectMaterialData.data["set"]["id"])
active_object = bpy.context.active_object
self.layerset_bounds(box, active_object, location="Top_Exterior")
self.layerset_bounds(box, active_object, location="Top_Interior")
if not ObjectMaterialData.data["set_items"]:
row = box.row()
@@ -269,7 +285,7 @@ class BIM_PT_object_material(Panel):
else:
self.draw_read_only_set_item_ui(box, set_item)
self.layerset_bounds(box, active_object, location="Bottom_Interior")
self.layerset_bounds(box, active_object, location="Bottom_Exterior")
def draw_editable_set_item_profile_ui(self, box, set_item):
# box = self.layout.box()
@@ -335,29 +351,100 @@ class BIM_PT_object_material(Panel):
setattr(op, f"{ObjectMaterialData.data['set_item_name']}_index", set_item["index"])
def draw_read_only_set_ui(self):
# Material Set Information Section
row = self.layout.row(align=True)
box = row.box()
if ObjectMaterialData.data["material_class"] != "IfcMaterialList":
row = self.layout.row(align=True)
box_row = box.row(align=True)
set_name = ObjectMaterialData.data["set"]["name"]
row.label(text="Name")
row.label(text=set_name)
box_row.label(text="Name")
box_row.label(text=set_name)
if value := ObjectMaterialData.data["set"]["description"]:
row = self.layout.row(align=True)
row.label(text="Description")
row.label(text=value)
box_row = box.row(align=True)
box_row.label(text="Description")
box_row.label(text=value)
if ObjectMaterialData.data["material_class"] == "IfcMaterialProfileSetUsage":
if value := ObjectMaterialData.data["set_usage"].get("cardinal_point"):
row = self.layout.row(align=True)
row.label(text="Cardinal Point")
row.label(text=value)
box_row = box.row(align=True)
box_row.label(text="Cardinal Point")
box_row.label(text=value)
if ObjectMaterialData.data["total_thickness"]:
row = self.layout.row(align=True)
row.label(text="Total Thickness*")
row.label(text=ObjectMaterialData.data["total_thickness"])
box_row = box.row(align=True)
box_row.label(text="Total Thickness*")
box_row.label(text=ObjectMaterialData.data["total_thickness"])
box = self.layout.box()
# Display OffsetFromReferenceLine for layer sets
if "Layer" in ObjectMaterialData.data["material_class"]:
obj = bpy.context.active_object
if obj:
element = tool.Ifc.get_entity(obj)
if element:
material = ifcopenshell.util.element.get_material(element)
if material and material.is_a("IfcMaterialLayerSetUsage"):
offset_value = material.OffsetFromReferenceLine
# Format the offset value
unit_system = bpy.context.scene.unit_settings.system
prefs = tool.Blender.get_addon_preferences()
precision = None
if unit_system == "IMPERIAL":
precision = prefs.doc.imperial_precision
from bonsai.bim.module.drawing.helper import format_distance
formatted_offset = format_distance(
offset_value, precision=precision, suppress_zero_inches=True, in_unit_length=True
)
box_row = box.row(align=True)
box_row.label(text="Offset From Reference Line")
box_row.label(text=formatted_offset)
# BBIM_MaterialLayer Pset Section
if pset_data := ObjectMaterialData.data.get("bbim_material_layer_pset"):
self.layout.label(text="BBIM_MaterialLayer Pset")
row = self.layout.row(align=True)
box = row.box()
# Custom Offset value - format using format_distance
unit_system = bpy.context.scene.unit_settings.system
prefs = tool.Blender.get_addon_preferences()
precision = None
if unit_system == "IMPERIAL":
precision = prefs.doc.imperial_precision
from bonsai.bim.module.drawing.helper import format_distance
formatted_custom_offset = format_distance(
pset_data["custom_offset"], precision=precision, suppress_zero_inches=True, in_unit_length=True
)
box_row = box.row(align=True)
box_row.label(text="Custom Offset")
box_row.label(text=formatted_custom_offset)
# Reference (if exists)
if pset_data["custom_reference"]:
box_row = box.row(align=True)
box_row.label(text=pset_data["reference_label"])
box_row.label(text=pset_data["custom_reference"])
# Dynamic header based on material set type
set_item_name = ObjectMaterialData.data.get("set_item_name")
if set_item_name:
header_map = {
"layer": "Material Layers",
"profile": "Material Profiles",
"constituent": "Material Constituents",
"list_item": "Material List Items",
}
header_text = header_map.get(set_item_name, "Material Items")
else:
header_text = "Materials"
self.layout.label(text=header_text)
row = self.layout.row(align=True)
box = row.box()
active_object = bpy.context.active_object
self.layerset_bounds(box, active_object, location="Top_Interior")
@@ -403,20 +490,27 @@ class BIM_PT_object_material(Panel):
set_usage = ObjectMaterialData.data.get("set_usage", {})
layer_set_direction = set_usage.get("layer_set_direction")
if layer_set_direction:
box = self.layout.box()
row = box.row(align=True)
row.prop(self.props, "use_custom_offset", text="Use Custom Offset")
row = box.row(align=True)
if layer_set_direction == "AXIS2":
row.prop(self.props, "custom_wall_reference", text="Reference")
row.enabled = self.props.use_custom_offset
if layer_set_direction == "AXIS3":
row.prop(self.props, "custom_slab_reference", text="Reference")
row.enabled = self.props.use_custom_offset
row = self.layout.row(align=True)
row.label(text="BBIM_MaterialLayer Pset")
row = box.row(align=True)
row.prop(self.props, "custom_offset", text="Custom Offset")
row.enabled = self.props.use_custom_offset
# Add indentation with a row that has a separator
row = self.layout.row(align=True)
# row.separator(factor=2.0) # Adjust factor for more/less indent
box = row.box()
box_row = box.row(align=True)
box_row.prop(self.props, "use_custom_offset", text="Use Custom Offset")
box_row = box.row(align=True)
if layer_set_direction == "AXIS2":
box_row.prop(self.props, "custom_wall_reference", text="Reference")
box_row.enabled = self.props.use_custom_offset
if layer_set_direction == "AXIS3":
box_row.prop(self.props, "custom_slab_reference", text="Reference")
box_row.enabled = self.props.use_custom_offset
box_row = box.row(align=True)
box_row.prop(self.props, "custom_offset", text="Custom Offset")
box_row.enabled = self.props.use_custom_offset
class BIM_UL_materials(UIList):
+17 -17
View File
@@ -170,7 +170,7 @@ class FilledOpeningGenerator:
reuse_mapped_representation = True
else:
representation = ifcopenshell.util.representation.resolve_representation(representation)
if not reuse_mapped_representation:
# Check for library template before generating from filling
template_rep = self.get_opening_template_from_type(filling)
@@ -191,25 +191,25 @@ class FilledOpeningGenerator:
MappingSource=existing_mapping_source,
MappingTarget=tool.Ifc.get().create_entity(
"IfcCartesianTransformationOperator3D",
Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1., 0., 0.)),
Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 1., 0.)),
LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0., 0., 0.)),
Scale=1.,
Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 0., 1.))
)
Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 1.0, 0.0)),
LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
Scale=1.0,
Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
),
)
mapped_representation = tool.Ifc.get().create_entity(
"IfcShapeRepresentation",
ContextOfItems=context,
RepresentationIdentifier="Body",
RepresentationType="MappedRepresentation",
Items=[new_mapped_item]
Items=[new_mapped_item],
)
else:
mapped_representation = ifcopenshell.api.geometry.map_representation(
tool.Ifc.get(), representation=representation
)
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=opening, representation=mapped_representation
)
@@ -333,25 +333,25 @@ class FilledOpeningGenerator:
MappingSource=existing_mapping_source,
MappingTarget=tool.Ifc.get().create_entity(
"IfcCartesianTransformationOperator3D",
Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1., 0., 0.)),
Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 1., 0.)),
LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0., 0., 0.)),
Scale=1.,
Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 0., 1.))
)
Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 1.0, 0.0)),
LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
Scale=1.0,
Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
),
)
mapped_representation = tool.Ifc.get().create_entity(
"IfcShapeRepresentation",
ContextOfItems=context,
RepresentationIdentifier="Body",
RepresentationType="MappedRepresentation",
Items=[new_mapped_item]
Items=[new_mapped_item],
)
else:
mapped_representation = ifcopenshell.api.geometry.map_representation(
tool.Ifc.get(), representation=representation_to_use
)
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=opening, representation=mapped_representation
)
+240 -83
View File
@@ -35,7 +35,7 @@ import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from math import cos, pi
from math import cos, sin, pi, acos, degrees
from mathutils import Vector, Matrix
from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator
from bonsai.bim.module.model.polyline import PolylineOperator
@@ -225,16 +225,17 @@ class DumbSlabPlaner:
for inverse in tool.Ifc.get().get_inverse(layer_set):
if not inverse.is_a("IfcMaterialLayerSetUsage") or inverse.LayerSetDirection != "AXIS3":
continue
if tool.Ifc.get().schema == "IFC2X3":
for rel in tool.Ifc.get().get_inverse(inverse):
if not rel.is_a("IfcRelAssociatesMaterial"):
continue
for element in rel.RelatedObjects:
self.change_thickness(element, total_thickness)
self.change_thickness(element, total_thickness, preserve_offset=True)
else:
for rel in inverse.AssociatedTo:
for element in rel.RelatedObjects:
self.change_thickness(element, total_thickness)
self.change_thickness(element, total_thickness, preserve_offset=True)
def regenerate_from_type(self, usecase_path, ifc_file, settings):
relating_type = settings["relating_type"]
@@ -276,9 +277,10 @@ class DumbSlabPlaner:
return
self.change_thickness(element, total_thickness)
def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float) -> None:
def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float, preserve_offset: bool = False) -> None:
if tool.Model.get_usage_type(element) != "LAYER3":
return
layer_params = tool.Model.get_material_layer_parameters(element)
ifc_file = tool.Ifc.get()
body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
@@ -296,72 +298,48 @@ class DumbSlabPlaner:
if representation:
extrusion = tool.Model.get_extrusion(representation)
if extrusion:
# TODO Right now we don't have a reliable way to calculate the existing x_angle only based solely on the extrusion direction.
# For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a
# -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation.
# This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach.
existing_x_angle = obj.rotation_euler.x
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle
direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
offset_direction = direction_ratios.copy()
perpendicular_depth = thickness * abs(1 / cos(existing_x_angle))
perpendicular_offset = layer_offset * abs(1 / cos(existing_x_angle)) / self.unit_scale
# Calculate the actual extrusion angle from vertical
extrusion_angle = 0
if direction_ratios.length > 0:
cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1)))
extrusion_angle = acos(min(max(cos_angle, -1), 1))
# Check angle and z direction to determine whether the extrusion direction is positive or negative
if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or (
abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0
):
# The extrusion direction is positive. If the layer_parameter is set to negative,
# then the we change the extrusion direction.
if layer_params["direction_sense"] == "NEGATIVE":
direction_ratios *= -1
elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or (
abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0
):
# The extrusion direction is negative. If the layer_parameter is set to positive,
# then the we change the extrusion direction. And the offset direction should remain positive
# for either direction sense, so we change it.
offset_direction *= -1
if layer_params["direction_sense"] == "POSITIVE":
direction_ratios *= -1
# Only apply 1/cos factor when there's actual extrusion slope
if extrusion_angle > 1e-6:
perpendicular_depth = thickness * abs(1 / cos(extrusion_angle))
perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle))
else:
perpendicular_depth = thickness
perpendicular_offset = layer_offset
extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
extrusion.Depth = perpendicular_depth
# Update position
ifc_position = extrusion.Position
position = offset_direction * perpendicular_offset
material = ifcopenshell.util.element.get_material(element)
if material:
if material.is_a("IfcMaterialLayerSetUsage"):
material.OffsetFromReferenceLine = position.z
if ifc_position:
ifc_position.Location.Coordinates = position
else:
tool.Model.add_extrusion_position(extrusion, position)
else:
props = tool.Model.get_model_props()
x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle
new_rep = ifcopenshell.api.geometry.add_slab_representation(
tool.Ifc.get(),
context=body_context,
depth=thickness * self.unit_scale,
x_angle=x_angle,
)
for inverse in tool.Ifc.get().get_inverse(representation):
ifcopenshell.util.element.replace_attribute(inverse, representation, new_rep)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_rep,
)
bonsai.core.geometry.remove_representation(
tool.Ifc, tool.Geometry, obj=obj, representation=representation
)
return
if direction_ratios.length > 0:
offset_vector = direction_ratios.normalized() * perpendicular_offset
position = offset_vector
material = ifcopenshell.util.element.get_material(element)
if material and material.is_a("IfcMaterialLayerSetUsage"):
# Only set offset if not preserving it (preserves independent offsets per instance)
if not preserve_offset:
material.OffsetFromReferenceLine = position.z
if ifc_position:
ifc_position.Location.Coordinates = position
else:
tool.Model.add_extrusion_position(extrusion, position)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
else:
props = tool.Model.get_model_props()
x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle
@@ -374,13 +352,118 @@ class DumbSlabPlaner:
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=element, representation=representation
)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
def update_extrusion_direction(
element: ifcopenshell.entity_instance, new_direction_ratios: tuple, obj: bpy.types.Object = None
) -> None:
"""
Update extrusion direction while preserving overall object orientation.
Args:
element: The IFC element
new_direction_ratios: New extrusion direction ratios (x,y,z)
obj: Optional Blender object (will be fetched if not provided)
"""
if not obj:
obj = tool.Ifc.get_object(element)
if not obj:
return
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
return
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return
# Get current extrusion direction
old_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios)
if old_direction.length == 0:
old_direction = Vector((0, 0, 1)) # Default
new_direction = Vector(new_direction_ratios)
if new_direction.length == 0:
new_direction = Vector((0, 0, 1)) # Default
# Normalize both directions
old_direction_normalized = old_direction.normalized()
new_direction_normalized = new_direction.normalized()
# Store current object matrix
old_matrix = obj.matrix_world.copy()
# Calculate the rotation needed to keep same orientation
# When extrusion direction changes from A to B relative to local coordinates,
# we need to rotate the object by the inverse of that change
# Calculate rotation from old to new direction
rotation_axis = old_direction_normalized.cross(new_direction_normalized)
if rotation_axis.length > 1e-6:
rotation_axis.normalized()
dot_product = old_direction_normalized.dot(new_direction_normalized)
angle = acos(min(max(dot_product, -1), 1))
# Apply INVERSE rotation to object to compensate
rotation_matrix = Matrix.Rotation(-angle, 4, rotation_axis)
# Update object rotation
obj.matrix_world = old_matrix @ rotation_matrix
bpy.context.view_layer.update()
# Update extrusion direction (keeping magnitude)
if old_direction.length > 0:
# Preserve the magnitude of the original direction vector
magnitude = old_direction.length
new_direction = new_direction_normalized * magnitude
extrusion.ExtrudedDirection.DirectionRatios = tuple(new_direction)
# Update depth based on new extrusion angle
extrusion_angle = 0
if new_direction.length > 0:
cos_angle = new_direction_normalized.dot(Vector((0, 0, 1)))
extrusion_angle = acos(min(max(cos_angle, -1), 1))
# Get current depth (perpendicular depth)
current_perpendicular_depth = extrusion.Depth
# If we have material layer info, calculate actual thickness
material = ifcopenshell.util.element.get_material(element)
actual_thickness = current_perpendicular_depth
if material and material.is_a("IfcMaterialLayerSetUsage"):
layer_set = material.ForLayerSet
actual_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers])
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
actual_thickness *= unit_scale
# Convert to perpendicular depth if needed
if extrusion_angle > 1e-6:
new_perpendicular_depth = actual_thickness * abs(1 / cos(extrusion_angle))
else:
new_perpendicular_depth = actual_thickness
extrusion.Depth = new_perpendicular_depth
# Update position offset if needed
if extrusion.Position:
# Recalculate offset based on new direction
material = ifcopenshell.util.element.get_material(element)
if material and material.is_a("IfcMaterialLayerSetUsage"):
offset = material.OffsetFromReferenceLine
if extrusion_angle > 1e-6:
perpendicular_offset = offset * abs(1 / cos(extrusion_angle))
else:
perpendicular_offset = offset
offset_vector = new_direction_normalized * perpendicular_offset
extrusion.Position.Location.Coordinates = tuple(offset_vector)
class EnableEditingSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
@@ -657,6 +740,8 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
layer_params = tool.Model.get_material_layer_parameters(element)
usage_type = tool.Model.get_usage_type(element)
if extrusion.Position:
position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist())
position.translation *= self.unit_scale
@@ -669,22 +754,49 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
tranlation_matrix = Matrix.Translation(rot_offset)
position = position @ tranlation_matrix
# Restore Object rotation to zero
local_rot_mat = obj.rotation_euler.to_matrix()
rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X")
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
new_rot_euler = new_rot_mat.to_euler()
obj.rotation_euler = new_rot_euler
# For AXIS3 with dual rotation: Reset rotation to zero so profile is horizontal
if usage_type == "LAYER3":
# Store original rotation for later restoration
original_rotation_x = obj.rotation_euler.x
obj["pre_edit_rotation_x"] = original_rotation_x
# Reset rotation to zero - profile will be horizontal
current_z_rot = obj.rotation_euler.z
obj.rotation_euler.x = 0.0
obj.rotation_euler.z = current_z_rot
else:
# Original behavior: Restore Object rotation to zero
local_rot_mat = obj.rotation_euler.to_matrix()
rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X")
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
new_rot_euler = new_rot_mat.to_euler()
obj.rotation_euler = new_rot_euler
else:
position = Matrix()
tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle)
# Import profile with correct x_angle
if usage_type == "LAYER3":
# For LAYER3: Use x_angle=0 and scale by cos(rotation) to get horizontal projection
obj_x_rotation = original_rotation_x # Use stored original rotation
scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0
# Import with x_angle=0
tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=0)
# Scale the Y coordinates by cos(rotation) to get horizontal projection
bpy.ops.object.mode_set(mode="OBJECT")
for vert in obj.data.vertices:
vert.co.y *= scale_factor
else:
# For other types: Use existing_x_angle
tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle)
bpy.ops.object.mode_set(mode="EDIT")
ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_profile(context))
if not bpy.app.background:
tool.Blender.set_viewport_tool("bim.cad_tool")
return {"FINISHED"}
@@ -706,6 +818,8 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
extrusion = tool.Model.get_extrusion(body)
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
layer_params = tool.Model.get_material_layer_parameters(element)
usage_type = tool.Model.get_usage_type(element)
if extrusion.Position:
position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist())
position.translation *= self.unit_scale
@@ -718,17 +832,38 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
tranlation_matrix = Matrix.Translation(rot_offset)
position = position @ tranlation_matrix
# Restore Object rotation to x_angle
local_rot_mat = obj.rotation_euler.to_matrix()
rot_mat = Matrix.Rotation(existing_x_angle, 4, "X")
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
new_rot_euler = new_rot_mat.to_euler()
obj.rotation_euler = new_rot_euler
# Restore rotation
if usage_type == "LAYER3":
# Restore original rotation from before editing
if "pre_edit_rotation_x" in obj:
current_z_rot = obj.rotation_euler.z
obj.rotation_euler.x = obj["pre_edit_rotation_x"]
obj.rotation_euler.z = current_z_rot
del obj["pre_edit_rotation_x"]
else:
# Original behavior
local_rot_mat = obj.rotation_euler.to_matrix()
rot_mat = Matrix.Rotation(existing_x_angle, 4, "X")
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
new_rot_euler = new_rot_mat.to_euler()
obj.rotation_euler = new_rot_euler
else:
position = Matrix()
profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle)
# Export profile with correct x_angle
if usage_type == "LAYER3":
# Scale Y coordinates back up before exporting
obj_x_rotation = obj.rotation_euler.x
scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0
# Un-scale the profile before exporting
for vert in obj.data.vertices:
vert.co.y /= scale_factor # Inverse of import scaling
profile = tool.Model.export_profile(obj, position=position, x_angle=0)
else:
profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle)
if not profile:
@@ -780,6 +915,28 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
tool.Ifc.get(), product=element, representation=new_footprint
)
footprint_context = ifcopenshell.util.representation.get_context(
tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW"
)
if not footprint_context:
return
curves = [profile.OuterCurve]
if profile.is_a("IfcArbitraryProfileDefWithVoids"):
curves.extend(profile.InnerCurves)
new_footprint = ifcopenshell.api.geometry.add_footprint_representation(
tool.Ifc.get(), context=footprint_context, curves=curves
)
old_footprint = ifcopenshell.util.representation.get_representation(element, "Plan", "FootPrint", "SKETCH_VIEW")
if old_footprint:
for inverse in tool.Ifc.get().get_inverse(old_footprint):
ifcopenshell.util.element.replace_attribute(inverse, old_footprint, new_footprint)
bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_footprint)
else:
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=element, representation=new_footprint
)
class ResetVertex(bpy.types.Operator):
bl_idname = "bim.reset_vertex"
+122 -46
View File
@@ -43,7 +43,7 @@ import bonsai.core.geometry
import bonsai.core.model as core
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from math import pi, sin, cos, degrees, atan2
from math import pi, sin, cos, degrees, atan2, acos
from mathutils import Vector, Matrix
from bonsai.bim.module.model.opening import FilledOpeningGenerator
from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator
@@ -397,27 +397,46 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator):
for obj in selected_objs:
element = tool.Ifc.get_entity(obj)
assert element
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
continue
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
continue
# Get extrusion direction
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
# Calculate angle from vertical
x_angle = Vector((0, 1)).angle_signed(Vector((y, z)))
extrusion.Depth = self.depth / si_conversion * (1 / cos(x_angle))
# For sloped walls, compensate so VERTICAL height = target depth
cos_angle = cos(x_angle)
compensation_factor = abs(1 / cos_angle) if abs(cos_angle) > 1e-6 else 1.0
new_depth_ifc = (self.depth / si_conversion) * compensation_factor
extrusion.Depth = new_depth_ifc
# IMPORTANT: Refresh the geometry to reflect the IFC changes
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
if tool.Model.get_usage_type(element) == "LAYER2":
for rel in element.ConnectedFrom:
if rel.is_a() == "IfcRelConnectsElements":
ifcopenshell.api.geometry.disconnect_element(
ifc_file,
relating_element=rel.RelatingElement,
related_element=element,
)
layer2_objs.append(obj)
related_element = rel.RelatedElement
if related_element.is_a() == "IfcWall":
layer2_objs.append(tool.Ifc.get_object(related_element))
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
return {"FINISHED"}
@@ -437,81 +456,143 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
layer2_objs: list[bpy.types.Object] = []
x_angle = 0 if tool.Cad.is_x(self.x_angle, 0, tolerance=0.001) else self.x_angle
x_angle = 0 if tool.Cad.is_x(self.x_angle, pi, tolerance=0.001) else self.x_angle
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
selected_objs = tool.Model.get_selected_mesh_ifc_objects()
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
x_angle = self.x_angle
for obj in selected_objs:
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
assert element
if not element:
continue
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
continue
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
continue
# Get current object rotation matrix
obj_rotation = obj.matrix_world.to_3x3()
# Get current extrusion direction in LOCAL coordinates
current_local_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios)
if current_local_direction.length == 0:
current_local_direction = Vector((0, 0, 1))
current_local_direction_normalized = current_local_direction.normalized()
# Calculate what the current extrusion direction is in WORLD coordinates
current_world_direction = obj_rotation @ current_local_direction_normalized
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
# Calculate the NEW local extrusion direction based on x_angle
new_local_direction = Vector((0.0, sin(x_angle), cos(x_angle)))
# Check if extrusion direction is actually changing
current_local_norm = current_local_direction_normalized
new_local_norm = new_local_direction.normalized()
# Compare the LOCAL directions
local_direction_changed = (new_local_norm - current_local_norm).length > 1e-6
if tool.Model.get_usage_type(element) == "LAYER2":
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
depth = extrusion.Depth / abs(1 / cos(existing_x_angle))
perpendicular_depth = depth * abs(1 / cos(x_angle))
extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle))
layer2_objs.append(obj)
# Update extrusion direction
if local_direction_changed:
extrusion.ExtrudedDirection.DirectionRatios = tuple(new_local_direction)
# Always update depth
extrusion.Depth = perpendicular_depth
layer2_objs.append(obj)
else:
if tool.Model.get_usage_type(element) == "LAYER3":
existing_x_angle = obj.rotation_euler.x
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
# For slabs, handle polyline scaling
existing_obj_x_angle = obj.rotation_euler.x
existing_obj_x_angle = (
0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle
)
existing_obj_x_angle = (
0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle
)
# Scale the polyline coordinates
coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve)
coord_list = [
(p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list
] # Reset the transformation and returns to the original points with 0 degrees
] # Reset the transformation
coord_list = [
(p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list
] # Apply the transformation for the new x_angle
builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list)
# The extrusion direction calculated previously default to the positive direction
# Here we set the extrusion direction to negative if that's the case
direction_ratios = Vector((0.0, sin(x_angle), cos(x_angle)))
# direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
# Calculate new extrusion direction with direction sense
base_local_direction = Vector((0.0, sin(x_angle), cos(x_angle)))
layer_params = tool.Model.get_material_layer_parameters(element)
perpendicular_depth = layer_params["thickness"] * abs(1 / cos(x_angle)) / unit_scale
perpendicular_offset = layer_params["offset"] * abs(1 / cos(x_angle)) / unit_scale
offset_direction = direction_ratios.copy()
offset_direction = base_local_direction.copy()
# Check angle and z direction to determine whether the extrusion direction is positive or negative
if (abs(x_angle) < (pi / 2) and direction_ratios.z > 0) or (
abs(x_angle) > (pi / 2) and direction_ratios.z < 0
# Apply direction sense
final_local_direction = base_local_direction.copy()
if (abs(x_angle) < (pi / 2) and base_local_direction.z > 0) or (
abs(x_angle) > (pi / 2) and base_local_direction.z < 0
):
# The extrusion direction is positive. If the layer_parameter is set to negative,
# then the we change the extrusion direction.
if layer_params["direction_sense"] == "NEGATIVE":
direction_ratios *= -1
elif ((x_angle) > (pi / 2) and direction_ratios.z > 0) or (
(x_angle) < (pi / 2) and direction_ratios.z < 0
final_local_direction *= -1
elif (x_angle > (pi / 2) and base_local_direction.z > 0) or (
x_angle < (pi / 2) and base_local_direction.z < 0
):
# The extrusion direction is negative. If the layer_parameter is set to positive,
# then the we change the extrusion direction.
# then the we change the extrusion direction. And the offset direction should remain positive
# for either direction sense, so we change it.
offset_direction *= -1
if layer_params["direction_sense"] == "POSITIVE":
direction_ratios *= -1
final_local_direction *= -1
extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
# Check if extrusion direction actually changed
final_local_norm = final_local_direction.normalized()
local_direction_changed = (final_local_norm - current_local_norm).length > 1e-6
# Update extrusion properties
extrusion.ExtrudedDirection.DirectionRatios = tuple(final_local_direction)
extrusion.Depth = perpendicular_depth
if extrusion.Position or perpendicular_offset != 0:
position = offset_direction * perpendicular_offset
tool.Model.add_extrusion_position(extrusion, position)
# Adjust object rotation if extrusion direction changed
if local_direction_changed:
# Calculate what the NEW world direction would be with current object rotation
expected_new_world_direction = obj_rotation @ final_local_norm
# The rotation needed is from expected_new_world_direction to current_world_direction
rotation_axis = expected_new_world_direction.cross(current_world_direction)
if rotation_axis.length > 1e-6:
rotation_axis.normalize()
dot_product = expected_new_world_direction.dot(current_world_direction)
angle = acos(min(max(dot_product, -1), 1))
# Rotate around object's own origin
# Decompose the matrix to get translation, rotation, scale
translation, rotation, scale = obj.matrix_world.decompose()
# Create rotation matrix and convert to quaternion
rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis)
rotation_quat = rotation_matrix.to_quaternion()
# Apply rotation to existing rotation (quaternion multiplication)
new_rotation = rotation_quat @ rotation
# Reconstruct matrix_world with same translation, new rotation, same scale
obj.matrix_world = (
Matrix.Translation(translation) @ new_rotation.to_matrix().to_4x4() @ Matrix.Scale(1, 4)
)
bpy.context.view_layer.update()
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
@@ -519,12 +600,6 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
representation=representation,
)
# Object rotation
current_z_rot = obj.rotation_euler.z
rot_mat = mathutils.Matrix.Rotation(x_angle, 4, "X")
obj.rotation_euler = rot_mat.to_euler()
obj.rotation_euler.z = current_z_rot
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
return {"FINISHED"}
@@ -1022,6 +1097,7 @@ class DumbWallGenerator:
obj=obj,
representation=representation,
)
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric")
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"})
material = ifcopenshell.util.element.get_material(element)
@@ -399,6 +399,16 @@ class EditItemUI:
assert obj
mesh_props = tool.Geometry.get_mesh_props(obj.data)
# Get the parent element from representation_obj to check for layer set usage
has_layer_set_usage = False
props = tool.Geometry.get_geometry_props()
if props.representation_obj:
parent_element = tool.Ifc.get_entity(props.representation_obj)
if parent_element:
material_usage = tool.Model.get_usage_type(parent_element)
has_layer_set_usage = material_usage == "LAYER3"
if AuthoringData.data["is_representation_item_swept_solid"]:
# TODO: support EndSweptArea for IfcRevolvedAreaSolidTapered,
# will need to add second attribute for this.
@@ -412,8 +422,12 @@ class EditItemUI:
op.profile_id = int(mesh_props.item_profile)
for item_attribute in mesh_props.item_attributes:
# Skip depth attribute for LAYER3 objects with layer set usage
if has_layer_set_usage and item_attribute.name.lower() == "depth":
continue
row = cls.layout.row()
draw_attribute(item_attribute, cls.layout)
if len(mesh_props.item_attributes) or AuthoringData.data["is_representation_item_swept_solid"]:
row = cls.layout.row()
row.operator("bim.update_item_attributes", icon="FILE_REFRESH", text="")
@@ -1062,18 +1062,34 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
and not self.is_advanced
):
filepath = self.get_filepath()
suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix
if str(filepath).lower().endswith(".ifc"):
metadata_path = Path(str(filepath)[:-4] + suffix)
else:
metadata_path = Path(str(filepath) + suffix)
if metadata_path.exists() and metadata_path.is_file():
try:
bpy.ops.bim.load_blend_metadata_and_ifc(filepath=filepath)
self.report({"INFO"}, f"Loaded metadata file: {metadata_path.name}")
return {"FINISHED"}
except Exception as e:
self.report({"WARNING"}, f"Failed to load metadata file, using regular load: {e}")
# First, load the IFC file temporarily to check for metadata document
temp_ifc = None
has_metadata_doc = False
try:
temp_ifc = ifcopenshell.open(str(filepath))
for doc in temp_ifc.by_type("IfcDocumentInformation"):
if getattr(doc, "Scope", None) == "BLEND_METADATA":
has_metadata_doc = True
break
except:
pass
finally:
temp_ifc = None
if has_metadata_doc:
suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix
if str(filepath).lower().endswith(".ifc"):
metadata_path = Path(str(filepath)[:-4] + suffix)
else:
metadata_path = Path(str(filepath) + suffix)
if metadata_path.exists() and metadata_path.is_file():
try:
bpy.ops.bim.load_blend_metadata_and_ifc(filepath=filepath)
self.report({"INFO"}, f"Loaded metadata file: {metadata_path.name}")
return {"FINISHED"}
except Exception as e:
self.report({"WARNING"}, f"Failed to load metadata file, using regular load: {e}")
@persistent
def load_handler(*args):
@@ -1121,6 +1137,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
props.is_loading = True
props.total_elements = len(tool.Ifc.get().by_type("IfcElement"))
props.use_relative_project_path = self.use_relative_path
metadata_doc = tool.Project.get_metadata_document_information()
props.should_save_metadata_for_this_file = metadata_doc is not None
tool.Blender.register_toolbar()
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
@@ -1749,6 +1769,22 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
settings.json_version = self.json_version
settings.json_compact = self.json_compact
pprops = tool.Project.get_project_props()
if tool.Blender.get_addon_preferences().save_metadata_blend_file and pprops.should_save_metadata_for_this_file:
suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix
if output_file.lower().endswith(".ifc"):
metadata_filename = os.path.basename(output_file)[:-4] + suffix
else:
metadata_filename = os.path.basename(output_file) + suffix
if not tool.Project.get_metadata_document_information():
tool.Project.create_metadata_document_information(metadata_filename)
else:
tool.Project.update_metadata_document_information(metadata_filename)
else:
if not pprops.should_save_metadata_for_this_file:
tool.Project.remove_metadata_document_information()
ifc_exporter = export_ifc.IfcExporter(settings)
print("Starting export")
settings.logger.info("Starting export")
@@ -1765,7 +1801,8 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
tool.Ifc.set_path(output_file)
bim_props.is_dirty = False
if tool.Blender.get_addon_preferences().save_metadata_blend_file:
pprops = tool.Project.get_project_props()
if tool.Blender.get_addon_preferences().save_metadata_blend_file and pprops.should_save_metadata_for_this_file:
try:
bpy.ops.bim.save_blend_metadata_file()
suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix
@@ -3128,6 +3165,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
return {"FINISHED"}
class LoadBlendMetadataAndIFC(bpy.types.Operator):
bl_idname = "bim.load_blend_metadata_and_ifc"
bl_label = "Load Blend Metadata and IFC"
@@ -3165,4 +3203,4 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
bpy.app.handlers.load_post.append(load_handler)
bpy.ops.wm.open_mainfile(filepath=metadata_path)
return {"FINISHED"}
return {"FINISHED"}
@@ -411,6 +411,11 @@ class BIMProjectProperties(PropertyGroup):
)
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False)
should_save_metadata_for_this_file: BoolProperty(
name="Save Session Data for This File",
description="Enable saving session data (window layout, settings) to a metadata blend file for this specific IFC file",
default=False,
)
queried_obj: bpy.props.PointerProperty(type=bpy.types.Object)
queried_obj_root: bpy.props.PointerProperty(type=bpy.types.Object)
clipping_planes: bpy.props.CollectionProperty(type=ObjProperty)
@@ -504,6 +509,7 @@ class BIMProjectProperties(PropertyGroup):
parent_library: str
use_relative_project_path: bool
should_save_metadata_for_this_file: bool
queried_obj: Union[bpy.types.Object, None]
queried_obj_root: Union[bpy.types.Object, None]
clipping_planes: bpy.types.bpy_prop_collection_idprop[ObjProperty]
+3 -3
View File
@@ -338,9 +338,9 @@ class BIM_PT_project(Panel):
else:
metadata_filename = os.path.basename(props.ifc_file) + suffix
row = self.layout.row(align=True)
col = row.column()
col.enabled = False
col.label(text=f"Saving session data to: {metadata_filename}")
row.use_property_split = False
pprops = tool.Project.get_project_props()
row.prop(pprops, "should_save_metadata_for_this_file", text=f"Save session data to: {metadata_filename}")
class BIM_PT_new_project_wizard(Panel):
@@ -175,31 +175,84 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator):
else:
return
def get_root_aggregate(element):
"""Traverse up the aggregate hierarchy to find the top-most aggregate"""
current = element
root = None
while aggregate := ifcopenshell.util.element.get_aggregate(current):
root = aggregate
current = aggregate
return root
def get_all_parts_recursive(element):
"""Recursively get all parts of an aggregate"""
parts = []
for part in ifcopenshell.util.element.get_parts(element):
parts.append(part)
# Recursively get nested parts
parts.extend(get_all_parts_recursive(part))
return parts
objs: list[bpy.types.Object] = []
# In IFC element can be either contained of aggregated,
# tehrefore we skip aggregated elements here to prevent confusion.
# Can't handle it in `poll` since user might just select bunch of elements
# and try to assign a container to them
# and excluding aggregates because of the `poll` failing might get awkward.
skipped_aggregates = 0
processed_elements = set() # Track elements we've already handled (by IFC ID)
promoted_parts = 0 # Count how many parts were promoted to their root aggregate
for obj in tool.Blender.get_selected_objects():
if not (element := tool.Ifc.get_entity(obj)):
continue
if ifcopenshell.util.element.get_aggregate(element):
skipped_aggregates += 1
continue
objs.append(obj)
# Check if element is part of an aggregate (at any level)
if root_aggregate := get_root_aggregate(element):
# Skip if we've already processed this root aggregate
if root_aggregate.id() in processed_elements:
continue
# Get the root aggregate object and add it instead
if root_aggregate_obj := tool.Ifc.get_object(root_aggregate):
objs.append(root_aggregate_obj)
processed_elements.add(root_aggregate.id())
if root_aggregate != element: # Only count as promoted if different from selected
promoted_parts += 1
else:
# Element is not part of any aggregate
if element.id() not in processed_elements:
objs.append(obj)
processed_elements.add(element.id())
# Get the container's collection
container_obj = tool.Ifc.get_object(container)
container_collection = container_obj.BIMObjectProperties.collection if container_obj else None
for element_obj in objs:
element = tool.Ifc.get_entity(element_obj)
# Only assign container to the ROOT aggregate (this updates IFC relationships)
if self.remove_from_other_containers:
for col in element_obj.users_collection[:]:
col.objects.unlink(element_obj)
core.assign_container(tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj)
aggregates_msg = ""
if skipped_aggregates:
aggregates_msg = f" {skipped_aggregates} aggregated elements skipped."
self.report({"INFO"}, f"{len(objs)} elements assigned.{aggregates_msg}")
# For parts, only move them in Blender collections (don't change IFC relationships)
if container_collection:
all_parts = get_all_parts_recursive(element)
for part in all_parts:
if part_obj := tool.Ifc.get_object(part):
# Always remove from ALL previous collections when moving to new container
for col in part_obj.users_collection[:]:
col.objects.unlink(part_obj)
# Link to new container collection (Blender-only, no IFC change)
if part_obj.name not in container_collection.objects:
container_collection.objects.link(part_obj)
# Disable editing mode for all selected objects
for obj in tool.Blender.get_selected_objects():
core.disable_editing_container(tool.Spatial, obj=obj)
promoted_msg = ""
if promoted_parts:
promoted_msg = f" {promoted_parts} nested parts promoted to their root aggregates."
self.report({"INFO"}, f"{len(objs)} elements assigned.{promoted_msg}")
class EnableEditingContainer(bpy.types.Operator):
@@ -98,6 +98,8 @@ def update_name(self: "BIMContainer", context: bpy.types.Context) -> None:
tool.Spatial.edit_container_name(element, self.name)
if obj := tool.Ifc.get_object(element):
tool.Root.set_object_name(obj, element)
if collection := tool.Blender.get_object_bim_props(obj).collection:
collection.name = f"{element.is_a()}/{element.Name or 'Unnamed'}"
bonsai.bim.handler.refresh_ui_data()
@@ -239,7 +239,7 @@ class SelectSimilarType(bpy.types.Operator):
for related_object in objects:
relating_type = ifcopenshell.util.element.get_type(tool.Ifc.get_entity(related_object))
if not relating_type:
related_object.select_set(False)
# Keep objects without a type selected (retain current selection)
continue
relating_types.add(relating_type)
+21 -228
View File
@@ -33,15 +33,6 @@ import bonsai.bim
import bonsai.tool as tool
import bonsai.bim.handler
from enum import Enum
from bonsai.bim.helper import (
get_all_tab_panels,
get_tab_visibility,
set_tab_visibility,
get_tab_names,
get_panel_config,
initialize_panel_properties,
initialize_tab_visibilities,
)
from bpy_extras.io_utils import ImportHelper
from bonsai.bim import import_ifc
from bonsai.bim.prop import StrProperty
@@ -364,8 +355,6 @@ bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}')
return {"FINISHED"}
# TODO: Unused operator.
# Is there a need for this or 'DIR_PATH' propety subtype does almost the same,
# but also has alt+click?
@@ -1737,156 +1726,6 @@ class BIM_OT_attribute_remove_subitem(bpy.types.Operator):
return {"FINISHED"}
class BIM_UL_tab_panels(bpy.types.UIList):
"""UIList for Tab Panels"""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row(align=True)
row.label(text=item["bl_label"])
row.operator(
"bim.toggle_panel_visibility",
text="",
icon="HIDE_OFF" if item.get("visible", True) else "HIDE_ON",
).action = f"TOGGLE_VISIBILITY_{item.name}"
row.operator(
"bim.bookmark_panel",
text="",
icon="SOLO_ON" if item.get("bookmarked", False) else "SOLO_OFF",
).action = f"BOOKMARK_{item.name}"
class BIM_OT_toggle_panel_visibility(bpy.types.Operator):
"""Toggle Panel Visibility"""
bl_idname = "bim.toggle_panel_visibility"
bl_label = "Toggle Panel Visibility"
bl_options = {"REGISTER", "UNDO"}
action: bpy.props.StringProperty()
def execute(self, context):
panel_name = self.action.replace("TOGGLE_VISIBILITY_", "")
active_tab = getattr(context.scene, "active_tab_name", None) or getattr(
tool.Blender.get_bim_props(), "tab", None
)
is_bookmark_tab = active_tab == "BOOKMARK"
panel_config = get_panel_config(panel_name, create_if_missing=True)
if panel_config:
if is_bookmark_tab:
panel_config.is_visible_in_bookmarks = not panel_config.is_visible_in_bookmarks
new_value = panel_config.is_visible_in_bookmarks
else:
panel_config.is_visible_in_tab = not panel_config.is_visible_in_tab
new_value = panel_config.is_visible_in_tab
for item in context.scene.tab_panels:
if item.name == panel_name:
item["visible"] = new_value
break
for area in bpy.context.window.screen.areas:
if area.type == "PROPERTIES":
area.tag_redraw()
tab_context = "Bookmarks" if is_bookmark_tab else "Tab"
self.report({"INFO"}, f"Toggled visibility for {panel_name} in {tab_context}.")
return {"FINISHED"}
class BIM_OT_bookmark_panel(bpy.types.Operator):
"""Bookmark Panel"""
bl_idname = "bim.bookmark_panel"
bl_label = "Bookmark Panel"
bl_options = {"REGISTER", "UNDO"}
action: bpy.props.StringProperty()
def execute(self, context):
panel_name = self.action.replace("BOOKMARK_", "")
panel_config = get_panel_config(panel_name, create_if_missing=True)
if panel_config:
panel_config.is_bookmarked = not panel_config.is_bookmarked
for item in context.scene.tab_panels:
if item.name == panel_name:
item["bookmarked"] = panel_config.is_bookmarked
break
for area in bpy.context.window.screen.areas:
if area.type == "PROPERTIES":
area.tag_redraw()
self.report({"INFO"}, f"Toggled bookmark for {panel_name}.")
return {"FINISHED"}
class BIM_OT_manage_tab_panels(bpy.types.Operator):
"""Manage Tab Panels"""
bl_idname = "bim.manage_tab_panels"
bl_label = "Manage Tab Panels"
bl_options = {"REGISTER", "UNDO"}
tab_name: bpy.props.StringProperty()
def invoke(self, context, event):
context.scene.active_tab_name = self.tab_name
context.scene.tab_panels.clear()
initialize_tab_visibilities()
initialize_panel_properties()
all_panels = get_all_tab_panels(force_refresh=True)
for panel_data in all_panels.get(self.tab_name, []):
panel_name = panel_data.get("bl_idname", "")
panel_label = panel_data.get("bl_label", "")
if not panel_name or not panel_label:
continue
item = context.scene.tab_panels.add()
item.name = panel_name
item["bl_label"] = panel_label
panel_config = get_panel_config(panel_name, create_if_missing=True)
if panel_config:
if self.tab_name == "BOOKMARK":
item["visible"] = panel_config.is_visible_in_bookmarks
else:
item["visible"] = panel_config.is_visible_in_tab
item["bookmarked"] = panel_config.is_bookmarked
else:
item["visible"] = True
item["bookmarked"] = False
return context.window_manager.invoke_popup(self)
def draw(self, context):
layout = self.layout
layout.label(text=f"Manage Panels for {self.tab_name} Tab")
row = layout.row()
row.template_list("BIM_UL_tab_panels", "", context.scene, "tab_panels", context.scene, "active_tab_panel_index")
def execute(self, context):
for item in context.scene.tab_panels:
panel_config = get_panel_config(item.name, create_if_missing=True)
if panel_config:
if self.tab_name == "BOOKMARK":
panel_config.is_visible_in_bookmarks = item["visible"]
else:
panel_config.is_visible_in_tab = item["visible"]
panel_config.is_bookmarked = item["bookmarked"]
self.report({"INFO"}, f"Panels for {self.tab_name} managed successfully.")
return {"FINISHED"}
class BIM_OT_manage_tab_visibility(bpy.types.Operator):
"""Manage Tab Visibility"""
@@ -1894,51 +1733,26 @@ class BIM_OT_manage_tab_visibility(bpy.types.Operator):
bl_label = "Manage Tab Visibility"
bl_options = {"REGISTER", "UNDO"}
def draw(self, context):
layout = self.layout
row = layout.row()
row = self.layout.row(align=True)
row.alignment = "RIGHT"
row.operator("bim.reset_ui_layout", icon="FILE_REFRESH", text="")
row = layout.row()
row = self.layout.row(align=True)
row.alignment = "CENTER"
for tab_name in get_tab_names():
row = layout.row()
row.label(text=tab_name)
is_visible = get_tab_visibility(tab_name)
icon = "HIDE_OFF" if is_visible else "HIDE_ON"
op = row.operator("bim.toggle_tab_visibility", text="", icon=icon)
op.tab_name = tab_name
def execute(self, context):
return {"FINISHED"}
from bonsai.bim.prop import get_tab
def invoke(self, context, event):
return context.window_manager.invoke_popup(self)
bprops = tool.Blender.get_bim_props()
bprops.tab_visibilities.clear()
bprops.panel_visibilities.clear()
tabs = [item[0] for item in get_tab(None, None) if item and item[0] != "BLENDER"]
for tab in tabs:
new = bprops.tab_visibilities.add()
new.name = tab
class BIM_OT_toggle_tab_visibility(bpy.types.Operator):
"""Toggle Tab Visibility"""
bl_idname = "bim.toggle_tab_visibility"
bl_label = "Toggle Tab Visibility"
bl_options = {"REGISTER", "UNDO"}
tab_name: bpy.props.StringProperty()
def execute(self, context):
if self.tab_name in get_tab_names():
current_visibility = get_tab_visibility(self.tab_name)
set_tab_visibility(self.tab_name, not current_visibility)
for area in bpy.context.window.screen.areas:
if area.type == "PROPERTIES":
area.tag_redraw()
self.report({"INFO"}, f"Toggled visibility for {self.tab_name}.")
for attr_name in dir(bpy.types):
if attr_name.startswith("BIM_PT_tab_"):
panel_class = getattr(bpy.types, attr_name)
if not hasattr(panel_class, "bl_idname"):
assert False, panel_class
new = bprops.panel_visibilities.add()
new.name = panel_class.bl_idname
new.label = panel_class.bl_label
new.tab_name = panel_class.bim_tab_name
return {"FINISHED"}
@@ -1950,29 +1764,8 @@ class BIM_OT_reset_ui_layout(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
for tab_name in get_tab_names():
set_tab_visibility(tab_name, True)
get_all_tab_panels()["BOOKMARK"] = [{}]
for tab_name, panels in get_all_tab_panels().items():
for panel in panels:
panel_name = panel.get("bl_idname", "")
if not panel_name:
continue
show_prop_name = f"show_{panel_name.lower()}"
if hasattr(context.scene, show_prop_name):
setattr(context.scene, show_prop_name, True)
bookmark_prop_name = f"bookmark_{panel_name.lower()}"
if hasattr(context.scene, bookmark_prop_name):
setattr(context.scene, bookmark_prop_name, False)
for area in bpy.context.window.screen.areas:
if area.type == "PROPERTIES":
area.tag_redraw()
self.report({"INFO"}, "UI layout reset to default.")
bprops = tool.Blender.get_bim_props()
bprops.tab_visibilities.clear()
bprops.panel_visibilities.clear()
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
+31 -12
View File
@@ -60,6 +60,10 @@ def update_tab(self: "BIMAreaProperties", context: bpy.types.Context) -> None:
self.previous_tab = self.tab
def update_is_visible(self: "BIMTabVisibility", context: bpy.types.Context) -> None:
bonsai.bim.handler.refresh_ui_data()
def update_global_tab(self: "BIMTabProperties", context: bpy.types.Context) -> None:
tool.Blender.setup_tabs()
screen = context.id_data
@@ -537,21 +541,24 @@ class BIMTabProperties(PropertyGroup):
class BIMTabVisibility(PropertyGroup):
name: StringProperty(name="Tab Name")
is_visible: BoolProperty(name="Is Visible", default=True)
is_visible: BoolProperty(name="Is Visible", default=True, update=update_is_visible)
if TYPE_CHECKING:
name: str
is_visible: bool
class BIMPanelProperties(PropertyGroup):
is_visible_in_tab: BoolProperty(name="Is Visible in Tab", default=True)
is_visible_in_bookmarks: BoolProperty(name="Is Visible in Bookmarks", default=True)
is_bookmarked: BoolProperty(name="Is Bookmarked", default=False)
class BIMPanelVisibility(PropertyGroup):
name: StringProperty(name="Name")
label: StringProperty(name="Label")
tab_name: StringProperty(name="Tab Name")
is_visible: BoolProperty(name="Is Visible in Tab", default=True, update=update_is_visible)
is_bookmarked: BoolProperty(name="Is Bookmarked", default=False, update=update_is_visible)
if TYPE_CHECKING:
is_visible_in_tab: bool
is_visible_in_bookmarks: bool
name: str
tab_name: str
is_visible: bool
is_bookmarked: bool
@@ -628,7 +635,6 @@ class BIMProperties(PropertyGroup):
name="Mass Unit",
default="KILOGRAM",
)
time_unit: EnumProperty(
items=[
("SECOND", "Second", "Seconds"),
@@ -638,9 +644,11 @@ class BIMProperties(PropertyGroup):
],
name="Time Unit",
default="HOUR",
)
)
tab_visibilities: CollectionProperty(type=BIMTabVisibility, name="Tab Visibilities")
panel_properties: CollectionProperty(type=BIMPanelProperties, name="Panel Properties")
active_tab_visibility_index: IntProperty(name="Active Tab Visibility Index")
panel_visibilities: CollectionProperty(type=BIMPanelVisibility, name="Panel Properties")
active_panel_visibility_index: IntProperty(name="Active Panel Property Index")
if TYPE_CHECKING:
is_dirty: bool
@@ -656,8 +664,10 @@ class BIMProperties(PropertyGroup):
volume_unit: str
mass_unit: str
time_unit: str
tab_visibilities: bpy.types.bpy_prop_collection[BIMTabVisibility]
panel_properties: bpy.types.bpy_prop_collection[BIMPanelProperties]
tab_visibilities: bpy.types.bpy_prop_collection_idprop[BIMTabVisibility]
active_tab_visibility_index: int
panel_visibilities: bpy.types.bpy_prop_collection_idprop[BIMPanelVisibility]
active_panel_visibility_index: int
class IfcParameter(PropertyGroup):
@@ -791,12 +801,21 @@ class BIMFacet(PropertyGroup):
("!*=", "does not contain", ""),
],
)
filter_mode: EnumProperty(
items=[
("ADD", "Add", "Add results to the current selection"),
("SUBTRACT", "Subtract", "Remove results from the current selection"),
("FILTER", "Filter", "Filter the current selection"),
],
default="ADD",
)
if TYPE_CHECKING:
pset: str
value: str
type: str
comparison: Literal["=", "!=", ">=", "<=", ">", "<", "*=", "!*="]
filter_mode: Literal["ADD", "SUBTRACT", "FILTER"]
class BIMFilterGroup(PropertyGroup):
+148 -238
View File
@@ -36,19 +36,6 @@ import bonsai.bim
import bonsai.tool as tool
from ifcopenshell.util.file import IfcHeaderExtractor
from bonsai.bim.prop import Attribute
from bonsai.bim.helper import (
get_tab_names,
get_panel_tab_name,
should_show_panel,
get_tab_visibility,
set_tab_visibility,
get_panel_visibility,
is_panel_bookmarked,
get_panel_config,
get_all_tab_panels,
initialize_tab_visibilities,
initialize_panel_properties,
)
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty
from bonsai.bim.module.pset.prop import IfcProperty
from bonsai.bim.module.model.prop import (
@@ -255,6 +242,39 @@ class BIM_UL_generic(bpy.types.UIList):
layout.label(text="", translate=False)
class BIM_UL_tab_visibilities(bpy.types.UIList):
def draw_item(
self,
context,
layout: bpy.types.UILayout,
data: bpy.types.PropertyGroup,
item: bpy.types.PropertyGroup,
icon,
active_data,
active_propname,
) -> None:
row = layout.row()
row.prop(item, "name", text="", emboss=False)
row.prop(item, "is_visible", text="", icon="HIDE_OFF" if item.is_visible else "HIDE_ON", emboss=False)
class BIM_UL_panel_visibilities(bpy.types.UIList):
def draw_item(
self,
context,
layout: bpy.types.UILayout,
data: bpy.types.PropertyGroup,
item: bpy.types.PropertyGroup,
icon,
active_data,
active_propname,
) -> None:
row = layout.row()
row.prop(item, "label", text="", emboss=False)
row.prop(item, "is_visible", text="", icon="HIDE_OFF" if item.is_visible else "HIDE_ON", emboss=False)
row.prop(item, "is_bookmarked", text="", icon="SOLO_ON" if item.is_bookmarked else "SOLO_OFF", emboss=False)
class GizmoPreferencesDoor(bpy.types.PropertyGroup):
"""Property group for door gizmo visibility settings."""
@@ -724,11 +744,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
description="Custom suffix for the metadata blend file. Will be appended to the filename (without .ifc).",
default=".ifc.metadata.blend",
)
user_ui_customization: BoolProperty(
name="User UI Customization",
description="Enable user interface customization features (hide/show tabs and panels, bookmark panels) and save the session settings as part of the metadata blend file",
default=False,
)
if TYPE_CHECKING:
svg2pdf_command: str
@@ -770,7 +785,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
chain_filter_with_set_operations: bool
default_filter_with_set_operations_for_globalid_and_class: bool
save_metadata_blend_file: bool
user_ui_customization: bool
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
@@ -837,7 +851,11 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
gizmo_prop_names.update(("swing_arc", "flip_arc"))
for prop in door_gizmos.__annotations__:
try:
annotations = door_gizmos.__annotations__
except AttributeError:
annotations = type(door_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names:
layout.prop(door_gizmos, prop)
@@ -846,7 +864,11 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
window_gizmos = self.gizmos.window
gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props}
for prop in window_gizmos.__annotations__:
try:
annotations = window_gizmos.__annotations__
except AttributeError:
annotations = type(window_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names:
layout.prop(window_gizmos, prop)
@@ -857,7 +879,11 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
special_gizmo_names = {"lock", "plus", "minus", "cycle"}
for prop in stair_gizmos.__annotations__:
try:
annotations = stair_gizmos.__annotations__
except AttributeError:
annotations = type(stair_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names or prop in special_gizmo_names:
layout.prop(stair_gizmos, prop)
@@ -966,9 +992,26 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
row = layout.row()
row.separator()
row.prop(self, "metadata_blend_file_suffix")
row = layout.row()
row.separator()
row.prop(self, "user_ui_customization")
bprops = tool.Blender.get_bim_props()
if tab_visibilities := bprops.tab_visibilities:
row = layout.row()
row.operator("bim.reset_ui_layout", icon="LOOP_BACK")
row = layout.row(align=True)
row.template_list(
"BIM_UL_tab_visibilities", "", bprops, "tab_visibilities", bprops, "active_tab_visibility_index"
)
row.template_list(
"BIM_UL_panel_visibilities",
"",
bprops,
"panel_visibilities",
bprops,
"active_panel_visibility_index",
)
else:
row = layout.row()
row.operator("bim.manage_tab_visibility", icon="PREFERENCES")
# Scene panel groups
@@ -984,78 +1027,31 @@ class BIM_PT_tabs(Panel):
def draw(self, context):
if not UIData.is_loaded:
UIData.load()
is_ifc_project = bool(tool.Ifc.get())
aprops = tool.Blender.get_area_props(context)
addon_prefs = tool.Blender.get_addon_preferences()
ifc_icon = f"{UIData.data['tabs_icon_color_mode']}_ifc"
split = self.layout.split(factor=0.9)
col_left = split.column(align=True)
row_left = col_left.row(align=True)
row_left.alignment = "CENTER"
if get_tab_visibility("PROJECT"):
row_left.operator(
"bim.set_tab",
text="",
emboss=aprops.tab == "PROJECT",
icon_value=bonsai.bim.icons[ifc_icon].icon_id,
).tab = "PROJECT"
if get_tab_visibility("OBJECT"):
self.draw_tab_entry(row_left, "FILE_3D", "OBJECT", is_ifc_project, aprops.tab == "OBJECT")
if get_tab_visibility("GEOMETRY"):
self.draw_tab_entry(row_left, "MATERIAL", "GEOMETRY", is_ifc_project, aprops.tab == "GEOMETRY")
if get_tab_visibility("DRAWINGS"):
self.draw_tab_entry(row_left, "DOCUMENTS", "DRAWINGS", is_ifc_project, aprops.tab == "DRAWINGS")
if get_tab_visibility("SERVICES"):
self.draw_tab_entry(row_left, "NETWORK_DRIVE", "SERVICES", is_ifc_project, aprops.tab == "SERVICES")
if get_tab_visibility("STRUCTURE"):
self.draw_tab_entry(row_left, "EDITMODE_HLT", "STRUCTURE", is_ifc_project, aprops.tab == "STRUCTURE")
if get_tab_visibility("SCHEDULING"):
self.draw_tab_entry(row_left, "NLA", "SCHEDULING", is_ifc_project, aprops.tab == "SCHEDULING")
if get_tab_visibility("FM"):
self.draw_tab_entry(row_left, "PACKAGE", "FM", True, aprops.tab == "FM")
if get_tab_visibility("QUALITY"):
self.draw_tab_entry(row_left, "COMMUNITY", "QUALITY", True, aprops.tab == "QUALITY")
if (
addon_prefs.save_metadata_blend_file
and addon_prefs.user_ui_customization
and get_tab_visibility("BOOKMARK")
):
self.draw_tab_entry(row_left, "SOLO_ON", "BOOKMARK", True, aprops.tab == "BOOKMARK")
row_left.operator("bim.switch_tab", text="", emboss=False, icon="UV_SYNC_SELECT")
row = self.layout.row()
row.alignment = "CENTER"
for tab in UIData.data["tabs"]:
self.draw_tab_entry(row, tab[1], tab[0], tab[2], aprops.tab == tab[0])
row.operator("bim.switch_tab", text="", emboss=False, icon="UV_SYNC_SELECT")
row_left = col_left.row(align=True)
row = self.layout.row()
# Yes, that's right.
row_left.alignment = "CENTER"
row_left.scale_y = 0.2
row.alignment = "CENTER"
row.scale_y = 0.2
if not (addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization):
row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False)
for tab in get_tab_names():
for tab in UIData.data["tabs"]:
# Draw a little underscore below the active tab icon.
if get_tab_visibility(tab):
if aprops.tab == tab:
row_left.prop(aprops, "active_tab", text="", icon="BLANK1")
else:
row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False)
row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) # space for Switch
col_right = split.column(align=True)
row_right = col_right.row(align=True)
row_right.alignment = "RIGHT"
if aprops.tab == tab[0]:
row.prop(aprops, "active_tab", text="", icon="BLANK1")
else:
row.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False)
row.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) # space for Switch
if addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization:
row_right.operator("bim.manage_tab_visibility", icon="PREFERENCES", text="")
row = self.layout.row(align=True)
row = self.layout.row()
row.prop(aprops, "tab", text="")
if addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization:
for tab in get_tab_names():
if get_tab_visibility(tab):
if aprops.tab == tab:
row.operator("bim.manage_tab_panels", text="", icon="PREFERENCES").tab_name = tab
if bonsai.REINSTALLED_BBIM_VERSION:
box = self.layout.box()
box.alert = True
@@ -1121,7 +1117,10 @@ class BIM_PT_tabs(Panel):
def draw_tab_entry(self, row, icon, tab_name, enabled=True, highlight=True):
tab_entry = row.row(align=True)
tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon=icon).tab = tab_name
if isinstance(icon, int):
tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon_value=icon).tab = tab_name
else:
tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon=icon).tab = tab_name
tab_entry.enabled = enabled
@@ -1135,9 +1134,7 @@ class BIM_PT_tab_new_project_wizard(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if not tool.Blender.is_tab(context, cls.bim_tab_name):
if not tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname):
return False
bim_props = tool.Blender.get_bim_props()
pprops = tool.Project.get_project_props()
@@ -1161,20 +1158,16 @@ class BIM_PT_tab_project_info(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname):
bim_props = tool.Blender.get_bim_props()
pprops = tool.Project.get_project_props()
if pprops.is_loading:
return True
elif tool.Ifc.get() or bim_props.ifc_file:
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
layout = self.layout
layout.label(text="This is the Project Info panel.")
pass
class BIM_PT_tab_spatial(Panel):
@@ -1187,11 +1180,8 @@ class BIM_PT_tab_spatial(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1207,11 +1197,8 @@ class BIM_PT_tab_project_setup(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname):
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1228,11 +1215,8 @@ class BIM_PT_tab_stakeholders(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1248,11 +1232,8 @@ class BIM_PT_tab_collaboration(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname):
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1269,11 +1250,8 @@ class BIM_PT_tab_grouping_and_filtering(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1297,11 +1275,8 @@ class BIM_PT_tab_geometry(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1317,11 +1292,8 @@ class BIM_PT_tab_status(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1337,11 +1309,8 @@ class BIM_PT_tab_qto(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1357,11 +1326,8 @@ class BIM_PT_tab_resources(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1377,11 +1343,8 @@ class BIM_PT_tab_cost(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1397,11 +1360,8 @@ class BIM_PT_tab_sequence(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1417,11 +1377,8 @@ class BIM_PT_tab_structural(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1437,11 +1394,8 @@ class BIM_PT_tab_services(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1457,11 +1411,8 @@ class BIM_PT_tab_lighting(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1477,11 +1428,8 @@ class BIM_PT_tab_zones(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1497,11 +1445,8 @@ class BIM_PT_tab_solar_analysis(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1517,11 +1462,8 @@ class BIM_PT_tab_quality_control(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname):
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1537,11 +1479,8 @@ class BIM_PT_tab_clash_detection(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname):
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1558,11 +1497,8 @@ class BIM_PT_tab_sandbox(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname):
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
row = self.layout.row()
@@ -1581,11 +1517,9 @@ class BIM_PT_tab_object_metadata(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
props = tool.Project.get_project_props()
if (
tool.Blender.is_tab(context, cls.bim_tab_name)
tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname)
and tool.Ifc.get()
and (obj := context.active_object)
# Hide links empty handles.
@@ -1596,7 +1530,6 @@ class BIM_PT_tab_object_metadata(Panel):
)
):
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1613,16 +1546,13 @@ class BIM_PT_tab_placement(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if (
tool.Blender.is_tab(context, cls.bim_tab_name)
tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname)
and tool.Ifc.get()
and (obj := context.active_object)
and tool.Ifc.get_entity(obj)
):
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1639,11 +1569,8 @@ class BIM_PT_tab_representations(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1661,11 +1588,8 @@ class BIM_PT_tab_geometric_relationships(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1683,11 +1607,8 @@ class BIM_PT_tab_parametric_geometry(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1704,11 +1625,8 @@ class BIM_PT_tab_object_materials(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1725,11 +1643,8 @@ class BIM_PT_tab_materials(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1746,11 +1661,8 @@ class BIM_PT_tab_styles(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1767,11 +1679,8 @@ class BIM_PT_tab_profiles(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1788,11 +1697,8 @@ class BIM_PT_tab_sheets(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1809,11 +1715,8 @@ class BIM_PT_tab_drawings(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1830,11 +1733,8 @@ class BIM_PT_tab_schedules(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1851,11 +1751,8 @@ class BIM_PT_tab_references(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1873,11 +1770,8 @@ class BIM_PT_tab_misc(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get():
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1894,11 +1788,8 @@ class BIM_PT_tab_handover(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname):
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1915,11 +1806,8 @@ class BIM_PT_tab_operations(Panel):
@classmethod
def poll(cls, context):
if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context):
return False
if tool.Blender.is_tab(context, cls.bim_tab_name):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname):
return True
return tool.Blender.is_tab(context, "BOOKMARK")
def draw(self, context):
pass
@@ -1960,8 +1848,8 @@ class UIData:
def load(cls):
cls.data = {
"version": cls.version(),
"tabs_icon_color_mode": cls.icon_color_mode("user_interface.wcol_regular.text"),
"menu_icon_color_mode": cls.icon_color_mode("user_interface.wcol_menu.text"),
"tabs": cls.tabs(),
}
cls.is_loaded = True
@@ -1973,6 +1861,28 @@ class UIData:
def icon_color_mode(cls, color_path):
return tool.Blender.detect_icon_color_mode(color_path)
@classmethod
def tabs(cls):
hidden_tabs = [t.name for t in tool.Blender.get_bim_props().tab_visibilities if not t.is_visible]
color_mode = cls.icon_color_mode("user_interface.wcol_regular.text")
is_ifc_project = bool(tool.Ifc.get())
return [
tab
for tab in [
("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True),
("OBJECT", "FILE_3D", is_ifc_project),
("GEOMETRY", "MATERIAL", is_ifc_project),
("DRAWINGS", "DOCUMENTS", is_ifc_project),
("SERVICES", "NETWORK_DRIVE", is_ifc_project),
("STRUCTURE", "EDITMODE_HLT", is_ifc_project),
("SCHEDULING", "NLA", is_ifc_project),
("FM", "PACKAGE", True),
("QUALITY", "COMMUNITY", True),
("BOOKMARK", "SOLO_ON", is_ifc_project),
]
if tab[0] not in hidden_tabs
]
def draw_statusbar(self, context):
if not UIData.is_loaded:
+48 -69
View File
@@ -20,7 +20,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
import bpy
import ifcopenshell
import bonsai.tool as tool
@@ -28,102 +27,82 @@ if TYPE_CHECKING:
def load_project_documents(document: tool.Document) -> None:
document.clear_document_tree()
document.import_project_documents()
document.clear_breadcrumbs()
document.enable_editing_ui()
def load_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None:
document_tool.clear_document_tree()
document_tool.import_subdocuments(document)
document_tool.import_references(document)
document_tool.disable_editing_document()
document_tool.add_breadcrumb(document)
def load_parent_document(document: tool.Document) -> None:
document.clear_document_tree()
document.remove_latest_breadcrumb()
parent = document.get_active_breadcrumb()
if parent:
document.import_subdocuments(parent)
document.import_references(parent)
document.disable_editing_document()
else:
document.import_project_documents()
def disable_document_editing_ui(document: tool.Document) -> None:
document.disable_editing_ui()
document.disable_editing_document()
def enable_editing_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None:
document_tool.import_document_attributes(document)
document_tool.set_active_document(document)
def disable_object_document_editing_ui(document: tool.Document) -> None:
document.disable_object_editing_ui()
def enable_editing_document(document: tool.Document, ifc_document: ifcopenshell.entity_instance) -> None:
document.set_active_document(ifc_document)
document.import_document_attributes(ifc_document)
def disable_editing_document(document: tool.Document) -> None:
document.disable_editing_document()
document.clear_active_document()
document.clear_document_attributes()
def add_information(ifc: tool.Ifc, document: tool.Document) -> None:
def add_information(ifc: tool.Ifc, document: tool.Document, parent=None) -> ifcopenshell.entity_instance:
document.clear_document_tree()
parent = document.get_active_breadcrumb()
if parent is None:
parent = document.get_default_parent_for_information()
information = ifc.run("document.add_information", parent=parent)
ifc.run("document.add_reference", information=information)
if parent:
document.import_subdocuments(parent)
document.import_references(parent)
else:
document.import_project_documents()
if document.is_document_information(parent):
document.expand_document(parent)
document.import_project_documents()
return information
def add_reference(ifc: tool.Ifc, document: tool.Document) -> None:
parent = document.get_active_breadcrumb()
assert parent
ifc.run("document.add_reference", information=parent)
parent = document.get_selected_document_information()
if parent:
reference = ifc.run("document.add_reference", information=parent)
reference.Location = ""
document.expand_document(parent)
document.import_project_documents()
def edit_document(ifc: tool.Ifc, document: tool.Document, ifc_document: ifcopenshell.entity_instance) -> None:
attributes = document.export_document_attributes()
if document.is_document_information(ifc_document):
ifc.run("document.edit_information", information=ifc_document, attributes=attributes)
else:
ifc.run("document.edit_reference", reference=ifc_document, attributes=attributes)
document.disable_editing_document()
document.clear_document_tree()
document.import_subdocuments(parent)
document.import_references(parent)
document.import_project_documents()
def edit_document(ifc: tool.Ifc, document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None:
attributes = document_tool.export_document_attributes()
if document_tool.is_document_information(document):
ifc.run("document.edit_information", information=document, attributes=attributes)
def remove_document(ifc: tool.Ifc, document: tool.Document, ifc_document: ifcopenshell.entity_instance) -> None:
document.clear_document_tree()
if document.is_document_information(ifc_document):
ifc.run("document.remove_information", information=ifc_document)
else:
ifc.run("document.edit_reference", reference=document, attributes=attributes)
document_tool.disable_editing_document()
document_tool.clear_document_tree()
parent = document_tool.get_active_breadcrumb()
if parent:
document_tool.import_subdocuments(parent)
document_tool.import_references(parent)
else:
document_tool.import_project_documents()
def remove_document(ifc: tool.Ifc, document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None:
document_tool.clear_document_tree()
if document_tool.is_document_information(document):
ifc.run("document.remove_information", information=document)
else:
ifc.run("document.remove_reference", reference=document)
parent = document_tool.get_active_breadcrumb()
if parent:
document_tool.import_subdocuments(parent)
document_tool.import_references(parent)
else:
document_tool.import_project_documents()
ifc.run("document.remove_reference", reference=ifc_document)
document.import_project_documents()
def assign_document(
ifc: tool.Ifc, product: ifcopenshell.entity_instance, document: ifcopenshell.entity_instance
ifc: tool.Ifc, product: ifcopenshell.entity_instance, ifc_document: ifcopenshell.entity_instance
) -> None:
ifc.run("document.assign_document", products=[product], document=document)
ifc.run("document.assign_document", products=[product], document=ifc_document)
def unassign_document(
ifc: tool.Ifc, product: ifcopenshell.entity_instance, document: ifcopenshell.entity_instance
ifc: tool.Ifc, product: ifcopenshell.entity_instance, ifc_document: ifcopenshell.entity_instance
) -> None:
ifc.run("document.unassign_document", products=[product], document=document)
ifc.run("document.unassign_document", products=[product], document=ifc_document)
+1 -1
View File
@@ -402,7 +402,7 @@ def update_drawing_name(
camera = ifc.get_object(drawing)
if camera and camera.name != name:
camera.name = name
group = drawing_tool.get_drawing_group(drawing)
if drawing_tool.get_name(group) != name:
ifc.run("attribute.edit_attributes", product=group, attributes={"Name": name})
+5 -5
View File
@@ -63,20 +63,20 @@ def copy_class(
def _has_material_styles(ifc: type[tool.Ifc], element: ifcopenshell.entity_instance) -> bool:
"""Check if element has styles defined through its material.
Returns True if any constituent material has a style representation,
which means styles should NOT be applied directly to the geometry.
"""
materials = ifcopenshell.util.element.get_materials(element)
if not materials:
return False
# Check if any of the constituent materials have styles
for material in materials:
if hasattr(material, 'HasRepresentation') and material.HasRepresentation:
if hasattr(material, "HasRepresentation") and material.HasRepresentation:
return True
return False
+14 -6
View File
@@ -287,21 +287,29 @@ class Debug:
@interface
class Document:
def add_breadcrumb(cls, document): pass
def clear_breadcrumbs(cls): pass
def clear_document_tree(cls): pass
def disable_editing_document(cls): pass
def disable_object_editing_ui(cls): pass
def disable_editing_ui(cls): pass
def enable_editing_ui(cls): pass
def export_document_attributes(cls): pass
def get_active_breadcrumb(cls): pass
def import_document_attributes(cls, document): pass
def import_project_documents(cls): pass
def import_references(cls, document): pass
def import_subdocuments(cls, document): pass
def is_document_information(cls, document): pass
def remove_latest_breadcrumb(cls): pass
def set_active_document(cls, document): pass
def clear_active_document(cls): pass
def clear_document_attributes(cls): pass
def expand_document(cls, document): pass
def get_default_parent_for_information(cls): pass
def get_selected_document_information(cls): pass
def get_document_information_id(cls, document): pass
def set_document_information_id(cls, document, value): pass
def get_external_reference_id(cls, reference): pass
def set_external_reference_id(cls, reference, value): pass
def get_document_references(cls, document): pass
def refresh_document_data(cls): pass
def load_document_objects_into_props(cls, document_id): pass
def update_document_objects(cls, document_id): pass
@interface
+22 -1
View File
@@ -19,6 +19,7 @@
from __future__ import annotations
import bonsai.core.geometry
from typing import TYPE_CHECKING, Optional
import ifcopenshell.util.element
if TYPE_CHECKING:
import bpy
@@ -32,14 +33,34 @@ def assign_type(
element: ifcopenshell.entity_instance,
type: ifcopenshell.entity_instance,
) -> None:
# Get the instance's current CardinalPoint before type assignment
instance_cardinal_point = None
instance_material = ifcopenshell.util.element.get_material(element)
if instance_material and instance_material.is_a("IfcMaterialProfileSetUsage"):
instance_cardinal_point = instance_material.CardinalPoint
ifc.run("type.assign_type", related_objects=[element], relating_type=type)
obj = ifc.get_object(element)
if type_tool.has_material_usage(element):
pass # for now, representation regeneration handled by API listeners
# Restore the instance's CardinalPoint to the new material usage
if instance_cardinal_point is not None:
new_instance_material = ifcopenshell.util.element.get_material(element)
if new_instance_material and new_instance_material.is_a("IfcMaterialProfileSetUsage"):
if new_instance_material.CardinalPoint != instance_cardinal_point:
new_instance_material.CardinalPoint = instance_cardinal_point
# Force representation regeneration
from bonsai.bim.module.model.profile import DumbProfileRecalculator
DumbProfileRecalculator().recalculate([obj])
# for now, representation regeneration handled by API listeners
else:
type_data = type_tool.get_object_data(ifc.get_object(type))
if type_data:
type_tool.change_object_data(obj, type_data, is_global=False)
type_tool.disable_editing(obj)
-2
View File
@@ -66,8 +66,6 @@ def assign_scene_units(ifc: type[tool.Ifc], unit: type[tool.Unit]) -> None:
else:
timeunit = ifc.run("unit.add_conversion_based_unit", name=time_unit_name.lower())
units += [massunit, timeunit]
print("Add mass and time units:", unit.add_mass_and_time_units())
print("Assigning units:", units)
ifc.run("unit.assign_unit", units=units)
+22 -10
View File
@@ -161,11 +161,19 @@ class Blender(bonsai.core.tool.Blender):
screen.BIMAreaProperties.add()
@classmethod
def is_tab(cls, context: bpy.types.Context, tab: str) -> bool:
def should_show_panel(cls, context: bpy.types.Context, tab: str, panel: str) -> bool:
aprops = cls.get_area_props(context)
if aprops.path_from_id() == "BIMAreaProperties" and context.area.spaces.active.search_filter:
return True
return aprops.tab == tab
if (is_bookmark_tab := aprops.tab == "BOOKMARK") or aprops.tab == tab:
bprops = tool.Blender.get_bim_props()
if not (panel_visibility := bprops.panel_visibilities.get(panel)):
return not is_bookmark_tab
if is_bookmark_tab:
if panel_visibility.is_bookmarked:
return True
elif panel_visibility.is_visible:
return True
@classmethod
def is_default_scene(cls) -> bool:
@@ -382,17 +390,14 @@ class Blender(bonsai.core.tool.Blender):
assert isinstance(space, bpy.types.SpaceNodeEditor)
if space.tree_type == "ShaderNodeTree":
context_override = {"area": area, "space": space, "screen": screen}
# Add window if screen differs from current context
context = bpy.context
if context and context.screen != screen:
window = next(
(w for w in context.window_manager.windows if w.screen == screen),
None
)
window = next((w for w in context.window_manager.windows if w.screen == screen), None)
if window:
context_override["window"] = window
return context_override
@classmethod
@@ -742,7 +747,11 @@ class Blender(bonsai.core.tool.Blender):
# Yes, accessing items through annotations is a bit hacky
# but it's the only way to get the dynamic enum items
# besides providing them to get_enum_safe explicitly.
prop_keywords = props.__annotations__[prop_name].keywords
try:
annotations = props.__annotations__
except AttributeError:
annotations = type(props).__annotations__
prop_keywords = annotations[prop_name].keywords
items = prop_keywords.get("items")
if items is None:
return None
@@ -1460,7 +1469,10 @@ class Blender(bonsai.core.tool.Blender):
def override_scene_panel(cls, original_panel: bpy.types.Panel) -> None:
@classmethod
def poll_check_blender_tab(cls, context):
return tool.Blender.is_tab(context, "BLENDER")
aprops = tool.Blender.get_area_props(context)
if aprops.path_from_id() == "BIMAreaProperties" and context.area.spaces.active.search_filter:
return True
return aprops.tab == "BLENDER"
polls = bonsai.bim.original_scene_panels_polls
-2
View File
@@ -44,8 +44,6 @@ class Collector(bonsai.core.tool.Collector):
# Note that tool.Geometry.is_locked is only checked within the if
# statements for efficiency as it is a slow check.
tool.Geometry.lock_scale(obj)
if element.is_a("IfcSlab"):
tool.Geometry.lock_rotation(obj, x=True)
if element.is_a("IfcGridAxis"):
if tool.Geometry.is_locked(element):
+189 -54
View File
@@ -22,6 +22,8 @@ import ifcopenshell.util.system
import bonsai.bim.helper
import bonsai.core.tool
import bonsai.tool as tool
import json
from natsort import natsorted
from typing import Any, Union, TYPE_CHECKING
if TYPE_CHECKING:
@@ -33,17 +35,6 @@ class Document(bonsai.core.tool.Document):
def get_document_props(cls) -> BIMDocumentProperties:
return bpy.context.scene.BIMDocumentProperties
@classmethod
def add_breadcrumb(cls, document: ifcopenshell.entity_instance) -> None:
props = cls.get_document_props()
new = props.breadcrumbs.add()
new.name = str(document.id())
@classmethod
def clear_breadcrumbs(cls) -> None:
props = cls.get_document_props()
props.breadcrumbs.clear()
@classmethod
def clear_document_tree(cls) -> None:
props = cls.get_document_props()
@@ -54,6 +45,11 @@ class Document(bonsai.core.tool.Document):
props = cls.get_document_props()
props.active_document_id = 0
@classmethod
def disable_object_editing_ui(cls) -> None:
props = cls.get_document_props()
props.is_object_editing = False
@classmethod
def disable_editing_ui(cls) -> None:
props = cls.get_document_props()
@@ -69,18 +65,15 @@ class Document(bonsai.core.tool.Document):
props = cls.get_document_props()
return bonsai.bim.helper.export_attributes(props.document_attributes)
@classmethod
def get_active_breadcrumb(cls) -> Union[ifcopenshell.entity_instance, None]:
props = cls.get_document_props()
if len(props.breadcrumbs):
return tool.Ifc.get().by_id(int(props.breadcrumbs[-1].name))
@classmethod
def import_document_attributes(cls, document: ifcopenshell.entity_instance) -> None:
props = cls.get_document_props()
props.document_attributes.clear()
def callback(attr_name: str, _, data: dict[str, Any]) -> Union[bool, None]:
def callback(attr_name: str, attr_value: Any, data: dict[str, Any]) -> Union[bool, None]:
if attr_name == "Location" and attr_value is None:
data[attr_name] = ""
return True
if attr_name != "Name":
return None # Proceed normally
@@ -100,52 +93,132 @@ class Document(bonsai.core.tool.Document):
def import_project_documents(cls) -> None:
props = cls.get_document_props()
props.documents.clear()
project = tool.Ifc.get().by_type("IfcProject")[0]
file = tool.Ifc.get()
try:
expanded_documents = json.loads(props.json_string)
except (AttributeError, json.JSONDecodeError):
expanded_documents = []
project = file.by_type("IfcProject")[0] if file.by_type("IfcProject") else None
if not project:
return
document_children = {}
for rel in file.by_type("IfcDocumentInformationRelationship"):
parent_id = rel.RelatingDocument.id()
if parent_id not in document_children:
document_children[parent_id] = []
for child in rel.RelatedDocuments:
document_children[parent_id].append(child)
is_ifc2x3 = file.schema == "IFC2X3"
if is_ifc2x3:
for ref in file.by_type("IfcDocumentReference"):
if ref.ReferenceToDocument:
parent = ref.ReferenceToDocument[0]
parent_id = parent.id()
if parent_id not in document_children:
document_children[parent_id] = []
document_children[parent_id].append(ref)
else:
for ref in file.by_type("IfcDocumentReference"):
if ref.ReferencedDocument:
parent = ref.ReferencedDocument
parent_id = parent.id()
if parent_id not in document_children:
document_children[parent_id] = []
document_children[parent_id].append(ref)
root_documents = []
for rel in project.HasAssociations or []:
if rel.is_a("IfcRelAssociatesDocument") and rel.RelatingDocument.is_a("IfcDocumentInformation"):
element = rel.RelatingDocument
new = props.documents.add()
new.ifc_definition_id = element.id()
new["name"] = element.Name or "Unnamed"
new.is_information = True
new["identification"] = cls.get_document_information_id(element)
is_child = False
for children in document_children.values():
if rel.RelatingDocument in children:
is_child = True
break
if not is_child:
root_documents.append(rel.RelatingDocument)
root = props.documents.add()
root.ifc_definition_id = -project.id()
root.document_type = "PROJECT"
root.name = f"Project Documents ({project.Name or 'Unnamed Project'})"
root.identification = ""
root.location = ""
root.tree_depth = 0
root.has_children = bool(root_documents)
root_id = -project.id()
root.is_expanded = root_id not in expanded_documents
if root.is_expanded:
root_documents = natsorted(
root_documents, key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "")
)
for doc in root_documents:
cls._process_document(doc, props, document_children, expanded_documents, 1)
@classmethod
def import_references(cls, document: ifcopenshell.entity_instance) -> None:
props = cls.get_document_props()
is_ifc2x3 = tool.Ifc.get_schema() == "IFC2X3"
references = cls.get_document_references(document)
for element in references:
new = props.documents.add()
new.ifc_definition_id = element.id()
# Use Description + Location instead of Name as IFC has a restriction
# for IfcDocumentReference to have Name only if it has no ReferencedDocument.
name = " - ".join([x for x in [element.Description, element.Location] if x])
new["name"] = name or "Unnamed"
new["identification"] = cls.get_external_reference_id(element)
new.is_information = False
def _process_document(cls, document, props, document_children, expanded_documents, depth):
new = props.documents.add()
new.ifc_definition_id = document.id()
new.document_type = "INFORMATION" if document.is_a("IfcDocumentInformation") else "REFERENCE"
new.tree_depth = depth
@classmethod
def import_subdocuments(cls, document: ifcopenshell.entity_instance) -> None:
props = cls.get_document_props()
if document.IsPointer:
for element in document.IsPointer[0].RelatedDocuments or []:
new = props.documents.add()
new.ifc_definition_id = element.id()
new["name"] = element.Name or "Unnamed"
new.is_information = True
new["identification"] = cls.get_document_information_id(element) or "*"
new.name = document.Name or ""
new.identification = (
cls.get_document_information_id(document)
if new.document_type == "INFORMATION"
else cls.get_external_reference_id(document)
)
new.identification = new.identification or ""
new.description = document.Description or ""
new.location = document.Location or ""
if new.document_type == "INFORMATION":
new.name = document.Name or "Unnamed"
elif new.document_type == "REFERENCE":
file = document.file
if file.schema == "IFC2X3":
if document.ReferenceToDocument and not new.name:
new.name = document.ReferenceToDocument[0].Name or ""
else:
if document.ReferencedDocument and not new.name:
new.name = document.ReferencedDocument.Name or ""
doc_id = document.id()
has_children = doc_id in document_children and bool(document_children[doc_id])
new.has_children = has_children
new.is_expanded = doc_id in expanded_documents
if has_children and new.is_expanded:
children = document_children[doc_id]
info_children = natsorted(
[d for d in children if d.is_a("IfcDocumentInformation")],
key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or ""),
)
ref_children = natsorted(
[d for d in children if not d.is_a("IfcDocumentInformation")],
key=lambda doc: (cls.get_external_reference_id(doc) or "", doc.Description or doc.Name or ""),
)
for child in info_children + ref_children:
cls._process_document(child, props, document_children, expanded_documents, depth + 1)
@classmethod
def is_document_information(cls, document: ifcopenshell.entity_instance) -> bool:
return document.is_a("IfcDocumentInformation")
@classmethod
def remove_latest_breadcrumb(cls) -> None:
props = cls.get_document_props()
if len(props.breadcrumbs):
props.breadcrumbs.remove(len(props.breadcrumbs) - 1)
@classmethod
def set_active_document(cls, document: ifcopenshell.entity_instance) -> None:
props = cls.get_document_props()
@@ -179,3 +252,65 @@ class Document(bonsai.core.tool.Document):
if document.file.schema == "IFC2X3":
return document.DocumentReferences or ()
return document.HasDocumentReferences
@classmethod
def clear_active_document(cls) -> None:
props = cls.get_document_props()
props.active_document_id = 0
@classmethod
def clear_document_attributes(cls) -> None:
props = cls.get_document_props()
props.document_attributes.clear()
@classmethod
def expand_document(cls, document: ifcopenshell.entity_instance) -> None:
props = cls.get_document_props()
try:
expanded_docs = json.loads(props.json_string)
except (AttributeError, json.JSONDecodeError):
expanded_docs = []
if document.id() not in expanded_docs:
expanded_docs.append(document.id())
props.json_string = json.dumps(expanded_docs)
@classmethod
def get_default_parent_for_information(cls) -> Union[ifcopenshell.entity_instance, None]:
file = tool.Ifc.get()
projects = file.by_type("IfcProject")
return projects[0] if projects else None
@classmethod
def get_selected_document_information(cls) -> Union[ifcopenshell.entity_instance, None]:
props = cls.get_document_props()
if props.active_document and props.active_document.document_type == "INFORMATION":
file = tool.Ifc.get()
return file.by_id(props.active_document.ifc_definition_id)
return None
@classmethod
def refresh_document_data(cls) -> None:
import bonsai.bim.module.document.data as document_data
document_data.DocumentData.is_loaded = False
document_data.DocumentData.load()
@classmethod
def load_document_objects_into_props(cls, document_id: int) -> None:
import bonsai.bim.module.document.data as document_data
document_data.DocumentData.load_document_objects_into_props(document_id)
@classmethod
def update_document_objects(cls, document_id: Union[int, None] = None) -> None:
cls.refresh_document_data()
if document_id is None:
props = cls.get_document_props()
if props.active_document and props.active_document.ifc_definition_id > 0:
document_id = props.active_document.ifc_definition_id
if document_id:
cls.load_document_objects_into_props(document_id)
+147 -18
View File
@@ -1021,7 +1021,7 @@ class Loader(bonsai.core.tool.Loader):
elif material.is_a("IfcMaterialLayerSetUsage"):
usage = material
layer_set = material.ForLayerSet
offset = usage.OffsetFromReferenceLine * cls.unit_scale
offset = usage.OffsetFromReferenceLine
sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1
elif material.is_a("IfcMaterialLayerSet"):
usage = None
@@ -1030,61 +1030,168 @@ class Loader(bonsai.core.tool.Loader):
sense_factor = 1
else:
return mesh
if len(layer_set.MaterialLayers) == 1:
return mesh
# Get mesh bounds
if len(mesh.vertices) > 0:
z_coords = [v.co.z for v in mesh.vertices]
mesh_z_min = min(z_coords)
mesh_z_max = max(z_coords)
bm = bmesh.new()
bm.from_mesh(mesh)
prev_co = None
advance_direction = None
if not usage:
sense_factor = 1 # Assume the extrusion vector points in the direction sense
sense_factor = 1
no = cls.get_extrusion_vector(element).normalized()
co = Vector((0.0, 0.0, offset))
advance_direction = no
elif usage.LayerSetDirection == "AXIS2":
co = Vector((0.0, offset, 0.0))
no = cls.get_extrusion_vector(element).normalized()
no = no.cross(Vector([1.0, 0.0, 0.0]))
# Get local extrusion direction
local_extrusion = Vector([0.0, 0.0, 1.0])
if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
local_extrusion = Vector(item.ExtrudedDirection.DirectionRatios).normalized()
break
# Thickness direction: perpendicular to extrusion and length
thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized()
if thickness_dir.y < 0:
thickness_dir = -thickness_dir
no = thickness_dir
# Find start point by projecting vertices onto thickness direction
if len(mesh.vertices) > 0:
projections = [Vector(v.co).dot(no) for v in mesh.vertices]
min_proj = min(projections)
max_proj = max(projections)
centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices)
centroid_proj = centroid.dot(no)
if sense_factor == 1:
start_proj = min_proj
else:
start_proj = max_proj
offset_dist = start_proj - centroid_proj
co = centroid + no * offset_dist
actual_mesh_height = max_proj - min_proj
else:
co = Vector((0.0, 0.0, 0.0))
advance_direction = thickness_dir
elif usage.LayerSetDirection == "AXIS3":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
# AXIS3 layers go through slab thickness (local Z)
no = Vector([0.0, 0.0, 1.0])
# Find start point by projecting vertices onto Z direction
if len(mesh.vertices) > 0:
projections = [Vector(v.co).dot(no) for v in mesh.vertices]
min_proj = min(projections)
max_proj = max(projections)
centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices)
centroid_proj = centroid.dot(no)
if sense_factor == 1:
start_proj = min_proj
else:
start_proj = max_proj
offset = start_proj - centroid_proj
co = centroid + no * offset
actual_mesh_height = max_proj - min_proj
else:
co = Vector((0.0, 0.0, 0.0))
advance_direction = no
elif usage.LayerSetDirection == "AXIS1":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
no *= sense_factor
# Cache this
advance_direction = no
# Apply DirectionSense
if usage and usage.LayerSetDirection == "AXIS2":
if sense_factor == -1:
advance_direction = -advance_direction
test_normal = -no
else:
test_normal = no
elif usage and usage.LayerSetDirection == "AXIS1":
no = no * sense_factor
advance_direction = advance_direction * sense_factor
test_normal = no
elif usage and usage.LayerSetDirection == "AXIS3":
if sense_factor == -1:
advance_direction = -advance_direction
test_normal = -no
else:
test_normal = no
else:
test_normal = no
# Cache material styles
body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
styles = {}
has_layer_styles = False
for i, material in enumerate(mesh.materials):
if style := tool.Ifc.get_entity(material):
styles[style] = i
layer_list = list(enumerate(layer_set.MaterialLayers))
# Calculate scale factor
total_layer_thickness = sum(layer.LayerThickness for _, layer in layer_list)
if 'actual_mesh_height' not in locals():
actual_mesh_height = mesh_z_max - mesh_z_min if len(mesh.vertices) > 0 else total_layer_thickness
thickness_scale = actual_mesh_height / total_layer_thickness if total_layer_thickness > 0 else 1.0
last_i = len(layer_set.MaterialLayers) - 1
for i, layer in enumerate(layer_set.MaterialLayers):
if i != last_i:
for idx, (original_i, layer) in enumerate(layer_list):
if idx != last_i:
prev_co = co.copy()
co += no * layer.LayerThickness * cls.unit_scale
advance_vector = advance_direction * layer.LayerThickness * thickness_scale
co += advance_vector
bisect_geom = bmesh.ops.bisect_plane(
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no
)
bmesh.ops.duplicate(bm, geom=bisect_geom["geom_cut"])
if not (style := ifcopenshell.util.representation.get_material_style(layer.Material, body)):
continue
if (material_index := styles.get(style, None)) is None:
material_index = len(mesh.materials)
mesh.materials.append(tool.Ifc.get_object(style))
if i == last_i:
if idx == last_i:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
center = face.calc_center_median()
if (center - co).dot(no) >= 0:
if (center - co).dot(test_normal) >= 0:
face.material_index = material_index
has_layer_styles = True
else:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
center = face.calc_center_median()
if (center - co).dot(no) < 0 and (center - prev_co).dot(no) >= 0:
if (center - co).dot(test_normal) < 0 and (center - prev_co).dot(test_normal) >= 0:
face.material_index = material_index
has_layer_styles = True
@@ -1097,13 +1204,35 @@ class Loader(bonsai.core.tool.Loader):
return mesh
@classmethod
def get_extrusion_vector(cls, wall):
if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"):
def get_extrusion_vector(cls, element):
"""Get the extrusion direction in WORLD coordinates (accounting for object rotation)"""
if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
return Vector(item.ExtrudedDirection.DirectionRatios)
local_direction = Vector(item.ExtrudedDirection.DirectionRatios)
# Transform to world coordinates using object rotation
obj = tool.Ifc.get_object(element)
if obj:
# Apply object rotation to get actual world direction
world_direction = obj.matrix_world.to_3x3() @ local_direction
return world_direction
return local_direction
return Vector([0.0, 0.0, 1.0])
@classmethod
def get_local_extrusion_vector(cls, element):
"""Get the extrusion direction in LOCAL coordinates (from IFC, no object rotation)"""
if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
local_direction = Vector(item.ExtrudedDirection.DirectionRatios)
return local_direction
return Vector([0.0, 0.0, 1.0])
@classmethod
+103 -18
View File
@@ -620,6 +620,71 @@ class Model(bonsai.core.tool.Model):
if not openings[i].obj:
openings.remove(i)
@classmethod
def save_custom_offset_to_pset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
"""Save custom offset settings to BBIM_MaterialLayer pset."""
props = tool.Material.get_object_material_props(obj)
if not props.use_custom_offset:
# Remove pset if custom offset is disabled
pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer")
if pset:
pset_entity = tool.Ifc.get().by_id(pset["id"])
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset_entity)
return
# Determine which reference to save based on usage type
usage_type = tool.Model.get_usage_type(element)
custom_wall_reference = None
custom_slab_reference = None
if usage_type == "LAYER2":
custom_wall_reference = props.custom_wall_reference
elif usage_type == "LAYER3":
custom_slab_reference = props.custom_slab_reference
# Get or create pset
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer")
if pset_data:
pset = tool.Ifc.get().by_id(pset_data["id"])
else:
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_MaterialLayer")
# Save properties (store in SI units)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
properties = {
"UseCustomOffset": props.use_custom_offset,
"CustomOffset": props.custom_offset / unit_scale,
"CustomWallReference": custom_wall_reference if custom_wall_reference else "",
"CustomSlabReference": custom_slab_reference if custom_slab_reference else "",
}
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties=properties)
@classmethod
def load_custom_offset_from_pset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
"""Load custom offset settings from BBIM_MaterialLayer pset."""
pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer")
if not pset:
return
props = tool.Material.get_object_material_props(obj)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
# Load properties
props.use_custom_offset = pset.get("UseCustomOffset", False)
props.custom_offset = pset.get("CustomOffset", 0.0) * unit_scale # Convert from SI
# Load the appropriate reference based on usage type
usage_type = tool.Model.get_usage_type(element)
if usage_type == "LAYER2":
custom_wall_ref = pset.get("CustomWallReference", "")
if custom_wall_ref:
props.custom_wall_reference = custom_wall_ref
elif usage_type == "LAYER3":
custom_slab_ref = pset.get("CustomSlabReference", "")
if custom_slab_ref:
props.custom_slab_reference = custom_slab_ref
class MaterialLayerParameters(TypedDict):
"""Float values are in project units."""
@@ -652,13 +717,35 @@ class Model(bonsai.core.tool.Model):
)
@classmethod
def get_material_layer_custom_offset(cls, element: ifcopenshell.entity_instance, obj) -> MaterialLayerParameters:
def get_material_layer_custom_offset(
cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object
) -> Optional[float]:
"""Get custom offset value, reading from pset if props are not set."""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
layer_params = tool.Model.get_material_layer_parameters(element)
layer_offset = layer_params["offset"]
thickness = layer_params["thickness"] / unit_scale
props = tool.Material.get_object_material_props(obj)
if props.use_custom_offset:
# Try to load from pset if not already in props
if not props.use_custom_offset:
pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer")
if pset and pset.get("UseCustomOffset", False):
# Load from pset
custom_offset = pset.get("CustomOffset", 0.0)
usage_type = tool.Model.get_usage_type(element)
if usage_type == "LAYER2":
custom_offset_reference = pset.get("CustomWallReference", "CENTER")
elif usage_type == "LAYER3":
custom_offset_reference = pset.get("CustomSlabReference", "MIDDLE")
else:
return None
else:
return None
else:
# Use current props
custom_offset = props.custom_offset / unit_scale
if tool.Model.get_usage_type(element) == "LAYER2":
custom_offset_reference = props.custom_wall_reference
elif tool.Model.get_usage_type(element) == "LAYER3":
@@ -666,24 +753,22 @@ class Model(bonsai.core.tool.Model):
else:
return None
custom_offset = props.custom_offset
direction_sense = layer_params["direction_sense"]
if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}:
layer_offset = custom_offset - thickness * unit_scale
if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
layer_offset = custom_offset - (thickness / 2) * unit_scale
if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or (
direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"}
):
layer_offset = custom_offset
if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
layer_offset = custom_offset + (thickness / 2) * unit_scale
if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}:
layer_offset = custom_offset + thickness * unit_scale
direction_sense = layer_params["direction_sense"]
return layer_offset / unit_scale
if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}:
layer_offset = custom_offset - thickness * unit_scale
if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
layer_offset = custom_offset - (thickness / 2) * unit_scale
if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or (
direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"}
):
layer_offset = custom_offset
if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
layer_offset = custom_offset + (thickness / 2) * unit_scale
if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}:
layer_offset = custom_offset + thickness * unit_scale
return None
return layer_offset / unit_scale
@classmethod
def get_booleans(
+63
View File
@@ -513,3 +513,66 @@ class Project(bonsai.core.tool.Project):
if tmp.exists():
shutil.rmtree(tmp)
bpy.ops.bim.save_project(filepath=cls.TEMP_PROJECT_PATH.__str__(), should_save_as=True)
@classmethod
def get_metadata_document_information(cls) -> Optional[ifcopenshell.entity_instance]:
ifc_file = tool.Ifc.get()
if not ifc_file:
return None
for doc in ifc_file.by_type("IfcDocumentInformation"):
if getattr(doc, "Scope", None) == "BLEND_METADATA":
return doc
return None
@classmethod
def create_metadata_document_information(cls, metadata_filename: str) -> ifcopenshell.entity_instance:
ifc_file = tool.Ifc.get()
if not ifc_file:
raise Exception("No IFC file loaded")
doc = tool.Ifc.run("document.add_information", parent=None)
if ifc_file.schema == "IFC2X3":
tool.Ifc.run(
"document.edit_information",
information=doc,
attributes={
"DocumentId": "BLEND_METADATA",
"Name": "Blend Metadata",
"Scope": "BLEND_METADATA",
"Description": "References to blend metadata file for this IFC project",
"Location": metadata_filename,
},
)
else:
tool.Ifc.run(
"document.edit_information",
information=doc,
attributes={
"Identification": "BLEND_METADATA",
"Name": "Blend Metadata",
"Scope": "BLEND_METADATA",
"Description": "References to blend metadata file for this IFC project",
"Location": metadata_filename,
},
)
return doc
@classmethod
def update_metadata_document_information(cls, metadata_filename: str) -> None:
doc = cls.get_metadata_document_information()
if not doc:
return
ifc_file = tool.Ifc.get()
if not ifc_file:
return
tool.Ifc.run("document.edit_information", information=doc, attributes={"Location": metadata_filename})
@classmethod
def remove_metadata_document_information(cls) -> None:
doc = cls.get_metadata_document_information()
if doc:
tool.Ifc.run("document.remove_information", information=doc)
+10 -2
View File
@@ -553,7 +553,11 @@ class Snap(bonsai.core.tool.Snap):
def filter_snapping_points_by_type(snapping_points):
options = ["Plane", "Axis"]
props = tool.Snap.get_snap_props()
for prop in props.__annotations__.keys():
try:
annotations = props.__annotations__
except AttributeError:
annotations = type(props).__annotations__
for prop in annotations.keys():
if getattr(props, prop):
options.append(props.rna_type.properties[prop].name)
@@ -563,7 +567,11 @@ class Snap(bonsai.core.tool.Snap):
def filter_snapping_points_by_group(detected_snaps):
options = ["Wireframe", "Axis", "Plane"]
props = tool.Snap.get_snap_groups()
for prop in props.__annotations__.keys():
try:
annotations = props.__annotations__
except AttributeError:
annotations = type(props).__annotations__
for prop in annotations.keys():
if getattr(props, prop):
options.append(props.rna_type.properties[prop].name)
filtered_groups = [group for group in detected_snaps if group["group"] in options]
+1 -1
View File
@@ -74,7 +74,7 @@ class Type(bonsai.core.tool.Type):
def get_model_types(cls) -> list[ifcopenshell.entity_instance]:
ifc_file = tool.Ifc.get()
types = ifc_file.by_type("IfcElementType")
# exclude IfcSpatialElementType
types += ifc_file.by_type("IfcSpatialElementType")
types += ifc_file.by_type("IfcTypeProduct", include_subtypes=False)
if not tool.Ifc.get_schema().startswith("IFC4X3"):
types += ifc_file.by_type("IfcWindowStyle")
+5
View File
@@ -166,6 +166,11 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t
break
if inches is None:
inches = 0
# If feet is negative, inches should also be negative (subtractive)
if feet < 0:
inches = -inches
# Convert to meters
total_meters = (feet * 0.3048) + (inches * 0.0254)
return total_meters
@@ -200,6 +200,7 @@ Packaged installation
- **Arch Linux**: `Direct from Git <https://aur.archlinux.org/packages/ifcopenshell-git/>`__.
- **Chocolatey on Windows**: `Unstable <https://community.chocolatey.org/packages/bonsai-nightly/>`__.
- **Fedora Linux**: `IfcOpenShell Copr repository <https://copr.fedorainfracloud.org/coprs/bpostle/IfcOpenShell/>`__.
Tips for package managers
-------------------------
@@ -0,0 +1,24 @@
import bonsai, json, bpy
from pathlib import Path
repo_path = Path(bonsai.__file__).resolve().parent
install_path = Path(bonsai.__file__).absolute().parent
assert repo_path != install_path, "Run `dev_environment.py` to setup the development environment symlinks first."
repo_root = repo_path.parent.parent.parent
settings_path = repo_root / ".vscode" / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings = json.loads(settings_path.read_text()) if settings_path.exists() else {}
settings.update(
{
"bonsai.localRoot": repo_path.as_posix(),
"bonsai.remoteRoot": install_path.as_posix(),
"bonsai.blenderPath": Path(bpy.app.binary_path).parent.as_posix(),
}
)
json_data = json.dumps(settings, indent=2)
settings_path.write_text(json_data)
print("\n\nBonsai/VSCode development environment configured successfully!\n\n")
+12 -36
View File
@@ -6,23 +6,6 @@ Scenario: Load project documents
When I press "bim.load_project_documents"
Then nothing happens
Scenario: Load document
Given an empty IFC project
And I press "bim.load_project_documents"
And I press "bim.add_information"
And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()"
When I press "bim.load_document(document={information})"
Then nothing happens
Scenario: Load parent document
Given an empty IFC project
And I press "bim.load_project_documents"
And I press "bim.add_information"
And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()"
And I press "bim.load_document(document={information})"
When I press "bim.load_parent_document"
Then nothing happens
Scenario: Disable document editing UI
Given an empty IFC project
And I press "bim.load_project_documents"
@@ -57,7 +40,8 @@ Scenario: Add document reference
And I press "bim.load_project_documents"
And I press "bim.add_information"
And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()"
And I press "bim.load_document(document={information})"
And I press "bim.load_project_documents"
And I set "scene.BIMDocumentProperties.active_document_index" to "1"
When I press "bim.add_document_reference"
Then nothing happens
@@ -83,16 +67,12 @@ Scenario: Assign document
And I press "bim.load_project_documents"
And I press "bim.add_information"
And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()"
And I press "bim.load_document(document={information})"
And I press "bim.add_document_reference"
And the variable "reference" is "{ifc}.by_type('IfcDocumentReference')[-1].id()"
And I add a cube
And the object "Cube" is selected
And I look at the "Class" panel
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
When I press "bim.assign_document(document={reference})"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWall"
And I press "bim.assign_class"
When I press "bim.assign_document(document={information})"
Then nothing happens
Scenario: Unassign document
@@ -100,15 +80,11 @@ Scenario: Unassign document
And I press "bim.load_project_documents"
And I press "bim.add_information"
And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()"
And I press "bim.load_document(document={information})"
And I press "bim.add_document_reference"
And the variable "reference" is "{ifc}.by_type('IfcDocumentReference')[-1].id()"
And I add a cube
And the object "Cube" is selected
And I look at the "Class" panel
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I press "bim.assign_document(document={reference})"
When I press "bim.unassign_document(document={reference})"
Then nothing happens
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWall"
And I press "bim.assign_class"
And I press "bim.assign_document(document={information})"
When I press "bim.unassign_document(document={information})"
Then nothing happens
+10 -2
View File
@@ -96,7 +96,11 @@ class PanelSpy:
def __getattr__(self, attr: str) -> PanelSpy | Any:
self.spied_attr = attr
if annotation := self.blender_panel.__annotations__.get(attr, None):
try:
annotations = self.blender_panel.__annotations__
except AttributeError:
annotations = type(self.blender_panel).__annotations__
if annotation := annotations.get(attr, None):
return annotation.keywords.get("default", None) # An operator property
if attr == "layout":
return self
@@ -136,7 +140,11 @@ class PanelSpy:
prop_type = props.bl_rna.properties[name].type
enum_items = []
if prop_type == "ENUM":
prop_keywords = props.__annotations__[name].keywords
try:
annotations = props.__annotations__
except AttributeError:
annotations = type(props).__annotations__
prop_keywords = annotations[name].keywords
items = prop_keywords.get("items")
if items is not None:
if isinstance(items, (list, tuple)):
+48 -40
View File
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bonsai.core.document as subject
from test.core.bootstrap import ifc, document
@@ -25,21 +24,10 @@ class TestLoadProjectDocuments:
def test_run(self, document):
document.clear_document_tree().should_be_called()
document.import_project_documents().should_be_called()
document.clear_breadcrumbs().should_be_called()
document.enable_editing_ui().should_be_called()
subject.load_project_documents(document)
class TestLoadDocument:
def test_run(self, document):
document.clear_document_tree().should_be_called()
document.import_subdocuments("document").should_be_called()
document.import_references("document").should_be_called()
document.disable_editing_document().should_be_called()
document.add_breadcrumb("document").should_be_called()
subject.load_document(document, document="document")
class TestDisableDocumentEditingUi:
def test_run(self, document):
document.disable_editing_ui().should_be_called()
@@ -47,45 +35,71 @@ class TestDisableDocumentEditingUi:
subject.disable_document_editing_ui(document)
class TestDisableObjectDocumentEditingUi:
def test_run(self, document):
document.disable_object_editing_ui().should_be_called()
subject.disable_object_document_editing_ui(document)
class TestEnableEditingDocument:
def test_run(self, document):
document.import_document_attributes("document").should_be_called()
document.set_active_document("document").should_be_called()
subject.enable_editing_document(document, document="document")
document.import_document_attributes("document").should_be_called()
subject.enable_editing_document(document, ifc_document="document")
class TestDisableEditingDocument:
def test_run(self, document):
document.disable_editing_document().should_be_called()
document.clear_active_document().should_be_called()
document.clear_document_attributes().should_be_called()
subject.disable_editing_document(document)
class TestAddInformation:
def test_add_and_reload_tree_at_project_root(self, ifc, document):
document.clear_document_tree().should_be_called()
document.get_active_breadcrumb().should_be_called().will_return(None)
ifc.run("document.add_information", parent=None).should_be_called().will_return("information")
document.get_default_parent_for_information().should_be_called().will_return("default_parent")
ifc.run("document.add_information", parent="default_parent").should_be_called().will_return("information")
ifc.run("document.add_reference", information="information").should_be_called()
document.is_document_information("default_parent").should_be_called().will_return(True)
document.expand_document("default_parent").should_be_called()
document.import_project_documents().should_be_called()
subject.add_information(ifc, document)
def test_add_and_reload_tree_at_current_parent(self, ifc, document):
document.clear_document_tree().should_be_called()
document.get_active_breadcrumb().should_be_called().will_return("parent")
ifc.run("document.add_information", parent="parent").should_be_called().will_return("information")
ifc.run("document.add_reference", information="information").should_be_called()
document.import_subdocuments("parent").should_be_called()
document.import_references("parent").should_be_called()
subject.add_information(ifc, document)
document.is_document_information("parent").should_be_called().will_return(True)
document.expand_document("parent").should_be_called()
document.import_project_documents().should_be_called()
subject.add_information(ifc, document, parent="parent")
def test_add_without_expanding_if_parent_is_not_information(self, ifc, document):
document.clear_document_tree().should_be_called()
ifc.run("document.add_information", parent="parent").should_be_called().will_return("information")
ifc.run("document.add_reference", information="information").should_be_called()
document.is_document_information("parent").should_be_called().will_return(False)
document.import_project_documents().should_be_called()
subject.add_information(ifc, document, parent="parent")
class TestAddReference:
def test_run(self, ifc, document):
document.get_active_breadcrumb().should_be_called().will_return("parent")
def test_run_with_selected_parent(self, ifc, document):
document.get_selected_document_information().should_be_called().will_return("parent")
ifc.run("document.add_reference", information="parent").should_be_called()
document.clear_document_tree().should_be_called()
document.import_subdocuments("parent").should_be_called()
document.import_references("parent").should_be_called()
document.expand_document("parent").should_be_called()
document.import_project_documents().should_be_called()
subject.add_reference(ifc, document)
def test_run_without_selected_parent(self, ifc, document):
document.get_selected_document_information().should_be_called().will_return(None)
document.import_project_documents().should_be_called()
subject.add_reference(ifc, document)
@@ -96,9 +110,8 @@ class TestEditDocument:
ifc.run("document.edit_information", information="document", attributes="attributes").should_be_called()
document.disable_editing_document().should_be_called()
document.clear_document_tree().should_be_called()
document.get_active_breadcrumb().should_be_called().will_return(None)
document.import_project_documents().should_be_called()
subject.edit_document(ifc, document, document="document")
subject.edit_document(ifc, document, ifc_document="document")
def test_edit_reference(self, ifc, document):
document.export_document_attributes().should_be_called().will_return("attributes")
@@ -106,10 +119,8 @@ class TestEditDocument:
ifc.run("document.edit_reference", reference="document", attributes="attributes").should_be_called()
document.disable_editing_document().should_be_called()
document.clear_document_tree().should_be_called()
document.get_active_breadcrumb().should_be_called().will_return("parent")
document.import_subdocuments("parent").should_be_called()
document.import_references("parent").should_be_called()
subject.edit_document(ifc, document, document="document")
document.import_project_documents().should_be_called()
subject.edit_document(ifc, document, ifc_document="document")
class TestRemoveDocument:
@@ -117,27 +128,24 @@ class TestRemoveDocument:
document.clear_document_tree().should_be_called()
document.is_document_information("document").should_be_called().will_return(True)
ifc.run("document.remove_information", information="document").should_be_called()
document.get_active_breadcrumb().should_be_called().will_return(None)
document.import_project_documents().should_be_called()
subject.remove_document(ifc, document, document="document")
subject.remove_document(ifc, document, ifc_document="document")
def test_remove_reference(self, ifc, document):
document.clear_document_tree().should_be_called()
document.is_document_information("document").should_be_called().will_return(False)
ifc.run("document.remove_reference", reference="document").should_be_called()
document.get_active_breadcrumb().should_be_called().will_return("parent")
document.import_subdocuments("parent").should_be_called()
document.import_references("parent").should_be_called()
subject.remove_document(ifc, document, document="document")
document.import_project_documents().should_be_called()
subject.remove_document(ifc, document, ifc_document="document")
class TestAssignDocument:
def test_run(self, ifc):
ifc.run("document.assign_document", products=["product"], document="document").should_be_called()
subject.assign_document(ifc, product="product", document="document")
subject.assign_document(ifc, product="product", ifc_document="document")
class TestUnassignDocument:
def test_run(self, ifc):
ifc.run("document.unassign_document", products=["product"], document="document").should_be_called()
subject.unassign_document(ifc, product="product", document="document")
subject.unassign_document(ifc, product="product", ifc_document="document")
+46 -68
View File
@@ -22,6 +22,7 @@ import ifcopenshell.api
import ifcopenshell.api.document
import bonsai.core.tool
import bonsai.tool as tool
import json
from test.bim.bootstrap import NewFile
from bonsai.tool.document import Document as subject
@@ -31,24 +32,6 @@ class TestImplementsTool(NewFile):
assert isinstance(subject(), bonsai.core.tool.Document)
class TestAddBreadcrumb(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
tool.Ifc().set(ifc)
document = ifc.createIfcDocumentInformation()
subject.add_breadcrumb(document)
props = tool.Document.get_document_props()
assert props.breadcrumbs[0].name == str(document.id())
class TestClearBreadcrumbs(NewFile):
def test_run(self):
props = tool.Document.get_document_props()
props.breadcrumbs.add()
subject.clear_breadcrumbs()
assert len(props.breadcrumbs) == 0
class TestClearDocumentTree(NewFile):
def test_run(self):
props = tool.Document.get_document_props()
@@ -103,15 +86,6 @@ class TestExportDocumentAttributes(NewFile):
}
class TestGetActiveBreadcrumb(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
tool.Ifc().set(ifc)
document = ifc.createIfcDocumentInformation()
subject.add_breadcrumb(document)
assert subject.get_active_breadcrumb() == document
class TestImportDocumentAttributes(NewFile):
def test_importing_information(self):
ifc = ifcopenshell.file()
@@ -166,51 +140,64 @@ class TestImportDocumentAttributes(NewFile):
assert props.document_attributes["Description"].string_value == "Description"
class TestImportProjectDocuments(NewFile):
class TestImportProjectDocumentsExpanded(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
tool.Ifc().set(ifc)
ifc.createIfcProject()
document = ifcopenshell.api.document.add_information(ifc)
subject.import_project_documents()
props = tool.Document.get_document_props()
assert len(props.documents) == 1
assert props.documents[0].ifc_definition_id == document.id()
assert props.documents[0].name == "Unnamed"
assert props.documents[0].identification == "X"
assert props.documents[0].is_information is True
class TestImportReferences(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
tool.Ifc().set(ifc)
ifc.createIfcProject()
project = ifc.createIfcProject()
document = ifcopenshell.api.document.add_information(ifc)
reference = ifcopenshell.api.document.add_reference(ifc, information=document)
subject.import_references(document)
props = tool.Document.get_document_props()
assert len(props.documents) == 1
assert props.documents[0].ifc_definition_id == reference.id()
assert props.documents[0].name == "Unnamed"
assert props.documents[0].identification == "X"
assert props.documents[0].is_information is False
expanded_docs = [document.id()] # Mark document as expanded
props.json_string = json.dumps(expanded_docs)
subject.import_project_documents()
props = tool.Document.get_document_props()
# Should have project root + document + reference = 3 total
assert len(props.documents) == 3
assert props.documents[0].ifc_definition_id == -project.id()
assert props.documents[0].document_type == "PROJECT"
doc_info = next((d for d in props.documents if d.ifc_definition_id == document.id()), None)
assert doc_info is not None
assert doc_info.document_type == "INFORMATION"
doc_ref = next((d for d in props.documents if d.ifc_definition_id == reference.id()), None)
assert doc_ref is not None
assert doc_ref.location == ""
assert doc_ref.identification == "X"
assert doc_ref.document_type == "REFERENCE"
class TestImportSubdocuments(NewFile):
class TestImportProjectDocumentsCollapsed(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
tool.Ifc().set(ifc)
ifc.createIfcProject()
project = ifc.createIfcProject()
document = ifcopenshell.api.document.add_information(ifc)
subdocument = ifcopenshell.api.document.add_information(ifc, parent=document)
subject.import_subdocuments(document)
reference = ifcopenshell.api.document.add_reference(ifc, information=document)
props = tool.Document.get_document_props()
assert len(props.documents) == 1
assert props.documents[0].ifc_definition_id == subdocument.id()
assert props.documents[0].name == "Unnamed"
assert props.documents[0].identification == "X"
assert props.documents[0].is_information is True
props.json_string = json.dumps([]) # Empty expanded list
subject.import_project_documents()
props = tool.Document.get_document_props()
# Should have project root + document = 2 total (reference not imported because parent is collapsed)
assert len(props.documents) == 2
assert props.documents[0].ifc_definition_id == -project.id()
assert props.documents[0].document_type == "PROJECT"
doc_info = next((d for d in props.documents if d.ifc_definition_id == document.id()), None)
assert doc_info is not None
assert doc_info.document_type == "INFORMATION"
doc_ref = next((d for d in props.documents if d.ifc_definition_id == reference.id()), None)
assert doc_ref is None
class TestIsDocumentInformation(NewFile):
@@ -222,15 +209,6 @@ class TestIsDocumentInformation(NewFile):
assert subject.is_document_information(reference) is False
class TestRemoveLatestBreadcrumb(NewFile):
def test_run(self):
props = tool.Document.get_document_props()
props.breadcrumbs.add()
props.breadcrumbs.add()
subject.remove_latest_breadcrumb()
assert len(props.breadcrumbs) == 1
class TestSetActiveDocument(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
+4 -6
View File
@@ -184,7 +184,7 @@ class ClassPropertyContractV1(TypedDict):
qudtCodes: NotRequired[list[str]]
class PropertyContractV4(TypedDict):
class PropertyContractV5(TypedDict):
dictionaryUri: NotRequired[str]
activationDateUtc: str
code: str
@@ -710,16 +710,14 @@ class Client:
params = {k: v for k, v in params.items() if v is not None}
return self.get(endpoint, params)
def get_property(self, uri, include_classes=False, language_code="", version: int = 4) -> PropertyContractV4:
def get_property(self, uri, language_code="", version: int = 5) -> PropertyContractV5:
"""
Get Property Detail
this API replaces Property
Get Property details.
If you also need the list of classes using the property, then use api/Property/Classes
"""
endpoint = f"Property/v{version}"
params = {
"uri": uri,
"includeClasses": include_classes,
"LanguageCode": language_code,
}
return self.get(endpoint, params)
+224 -20
View File
@@ -14,6 +14,27 @@ namespace {
}
}
namespace {
template <typename T, typename Cmp = std::less<T>>
bool has_intersection(const std::set<T, Cmp>& A,
const std::set<T, Cmp>& B) {
auto itA = A.begin();
auto itB = B.begin();
while (itA != A.end() && itB != B.end()) {
if (Cmp()(*itA, *itB)) {
++itA;
} else if (Cmp()(*itB, *itA)) {
++itB;
} else {
return true;
}
}
return false;
}
}
taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, const IfcUtil::IfcBaseClass* inst, const taxonomy::function_item::ptr& fn, std::vector<cross_section>& cross_sections)
{
std::sort(cross_sections.begin(), cross_sections.end());
@@ -25,7 +46,7 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_,
// @todo currently only the case is handled where directrix returns a function_item
// @todo this "if" statement is not really required because the function returns at the start if the Directrix is not a function_item function
if (fn) {
function_item_evaluator evaluator(settings_,fn);
function_item_evaluator evaluator(settings_, fn);
double start = std::max(0., cross_sections.front().dist_along);
double end = std::min(fn->length(), cross_sections.back().dist_along);
@@ -45,6 +66,7 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_,
// parameter is minimum number of steps
num_steps = (size_t)std::ceil(param);
}
auto delta_step = curve_length / num_steps;
std::vector<double> longitudes;
for (auto& x : cross_sections) {
longitudes.push_back(x.dist_along);
@@ -52,7 +74,7 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_,
longitudes.push_back(std::numeric_limits<double>::infinity());
auto profile_index = longitudes.begin();
for (size_t i = 0; i <= num_steps; ++i) {
auto dist_along = start + curve_length / num_steps * i;
auto dist_along = start + delta_step * i;
while (dist_along > *(profile_index + 1)) {
profile_index++;
if (profile_index == longitudes.end()) {
@@ -60,6 +82,8 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_,
}
}
const bool is_last_placement_of_this_profile = profile_index + 1 >= longitudes.end() ? false : ((start + delta_step * (i+1)) > *(profile_index + 1));
auto relative_dist_along = (dist_along - *profile_index) / (*(profile_index + 1) - *profile_index);
const auto& profile_a = cross_sections[std::distance(longitudes.begin(), profile_index)].section_geometry;
const auto& offset_a = cross_sections[std::distance(longitudes.begin(), profile_index)].offset;
@@ -143,28 +167,203 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_,
} else if (rotation_a != rotation_b) {
Logger::Error("Direction vectors on cross section placements only supported when used consistently");
}
taxonomy::loop::ptr w1, w2;
taxonomy::edge::ptr e1, e2;
taxonomy::point3::ptr p1, p2;
for (auto tmp_ : boost::combine(loops_a, loops_b)) {
boost::tie(w1, w2) = tmp_;
if (w1->children.size() != w2->children.size()) {
Logger::Warning("Mismatching number of edges: " +
std::to_string(w1->children.size()) + " vs " +
std::to_string(w2->children.size()),
inst
);
return nullptr;
}
std::vector<taxonomy::point3::ptr> points;
for (auto tmp__ : boost::combine(w1->children, w2->children)) {
boost::tie(e1, e2) = tmp__;
auto& p1 = boost::get<taxonomy::point3::ptr>(e1->start);
auto& p2 = boost::get<taxonomy::point3::ptr>(e2->start);
auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval();
// auto p4 = (interpolated_rotation * p3).eval();
points.push_back(taxonomy::make<taxonomy::point3>(p3));
if (w1->closed != w2->closed) {
Logger::Warning("Mismatching closed property on loops", inst);
return nullptr;
}
if (w1->tags.is_initialized() != w2->tags.is_initialized()) {
Logger::Warning("Mismatching availability tags on loops", inst);
return nullptr;
}
if (w1->tags) {
// check uniqueness
std::set<std::string> tags_seen;
for (const auto& t : *w1->tags) {
if (tags_seen.find(t) != tags_seen.end()) {
Logger::Warning("Duplicate tag '" + t + "' on loft profile", inst);
return nullptr;
}
tags_seen.insert(t);
}
}
if (w2->tags) {
// check uniqueness
std::set<std::string> tags_seen;
for (const auto& t : *w2->tags) {
if (tags_seen.find(t) != tags_seen.end()) {
Logger::Warning("Duplicate tag '" + t + "' on loft profile", inst);
return nullptr;
}
tags_seen.insert(t);
}
}
std::map<std::string, taxonomy::point3::ptr> tag_to_point_on_w1, tag_to_point_on_w2;
auto loop_to_points = [](const taxonomy::loop::ptr& loop, const boost::optional<std::vector<std::string>>& input_tags) -> std::pair<std::vector<taxonomy::point3::ptr>, std::vector<std::set<std::string>>> {
std::vector<taxonomy::point3::ptr> points;
std::vector<std::set<std::string>> tags;
std::vector<std::string>::const_iterator tag_it;
if (!loop->closed.get_value_or(false)) {
points = {boost::get<taxonomy::point3::ptr>(loop->children[0]->start)};
if (input_tags) {
tags = {{input_tags->front()}};
tag_it = ++input_tags->begin();
}
}
for (auto& e : loop->children) {
const auto& p1_ = boost::get<taxonomy::point3::ptr>(e->start);
const auto& p2_ = boost::get<taxonomy::point3::ptr>(e->end);
if (input_tags && p1_->ccomponents() == p2_->ccomponents()) {
tags.back().insert(*tag_it);
++tag_it;
} else {
points.push_back(p2_);
if (input_tags) {
tags.emplace_back();
tags.back().insert(*tag_it);
++tag_it;
}
}
}
if (!input_tags) {
if (loop->closed.get_value_or(false)) {
// close polygon by referencing first point
points.push_back(points.front());
}
}
return {points, tags};
};
auto combine_tags = [](const std::vector<std::set<std::string>>& tag_sets) -> std::set<std::string> {
return std::accumulate(
tag_sets.begin(), tag_sets.end(), std::set<std::string>{},
[](std::set<std::string> acc,
const std::set<std::string>& m) {
acc.insert(m.begin(), m.end());
return acc;
});
};
auto join_tags = [](const std::set<std::string>& tag_set) -> std::string {
std::string result;
for (auto it = tag_set.begin(); it != tag_set.end(); ++it) {
if (it != tag_set.begin()) {
result += ", ";
}
result += *it;
}
return result;
};
auto [w1_points, w1_tags] = loop_to_points(w1, w1->tags);
auto [w2_points, w2_tags] = loop_to_points(w2, w2->tags);
if (w1->tags && w2->tags) {
{
auto it = w1_points.begin();
auto jt = w1_tags.begin();
while (it != w1_points.end() && jt != w1_tags.end()) {
for (auto& t : *jt) {
tag_to_point_on_w1[t] = *it;
}
++it;
++jt;
}
}
{
auto it = w2_points.begin();
auto jt = w2_tags.begin();
while (it != w2_points.end() && jt != w2_tags.end()) {
for (auto& t : *jt) {
tag_to_point_on_w2[t] = *it;
}
++it;
++jt;
}
}
auto w1_tags_combined = combine_tags(w1_tags);
auto w2_tags_combined = combine_tags(w2_tags);
// For every point (which can have multiple tags in case of 0-width edges) there needs to be a corresponding point on the other profile
for (auto& p1_tags : w1_tags) {
if (!has_intersection(p1_tags, w2_tags_combined)) {
Logger::Warning("No matching tags found on loft profiles: " + join_tags(p1_tags) + " not in " + join_tags(w2_tags_combined), inst);
return nullptr;
}
}
for (auto& p2_tags : w2_tags) {
if (!has_intersection(p2_tags, w1_tags_combined)) {
Logger::Warning("No matching tags found on loft profiles: " + join_tags(p2_tags) + " not in " + join_tags(w1_tags_combined), inst);
return nullptr;
}
}
} else {
if (w1->children.size() != w2->children.size()) {
Logger::Warning("Mismatching number of edges: " +
std::to_string(w1->children.size()) + " vs " +
std::to_string(w2->children.size()),
inst);
return nullptr;
}
}
std::vector<taxonomy::point3::ptr> points;
std::vector<std::string> common_tags_vec;
if (w1->tags) {
std::set<std::string> common_tags;
for (const auto& t : *w1->tags) {
if (tag_to_point_on_w2.find(t) == tag_to_point_on_w2.end()) {
continue;
}
const auto& p1_ = tag_to_point_on_w1[t];
const auto& p2_ = tag_to_point_on_w2[t];
auto p3 = (lerp(p1_->ccomponents(), p2_->ccomponents(), relative_dist_along) + interpolated_offset).eval();
std::set<std::string> tags_for_this_point_on_subsequent_profile = {t};
if (is_last_placement_of_this_profile) {
for (auto& ts : w2_tags) {
if (ts.find(t) != ts.end()) {
tags_for_this_point_on_subsequent_profile = ts;
}
}
}
for (auto& x : tags_for_this_point_on_subsequent_profile) {
points.push_back(taxonomy::make<taxonomy::point3>(p3));
common_tags_vec.push_back(x);
}
}
} else {
for (auto tmp__ : boost::combine(w1_points, w2_points)) {
boost::tie(p1, p2) = tmp__;
auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval();
points.push_back(taxonomy::make<taxonomy::point3>(p3));
}
}
/*
// This is handled in the loop_to_points() function above
if (!points.empty()) {
if (!w1->closed.get_value_or(true) && !w2->closed.get_value_or(true)) {
// open polygon, add last point
@@ -178,12 +377,17 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_,
points.push_back(points.front());
}
}
*/
auto interpolated_loop = polygon_from_points(points);
interpolated_loop->external = w1->external;
if (interpolated->kind() == taxonomy::FACE) {
std::static_pointer_cast<taxonomy::face>(interpolated)->children.push_back(interpolated_loop);
interpolated_loop->external = w1->external;
std::static_pointer_cast<taxonomy::face>(interpolated)->children.push_back(interpolated_loop);
} else {
if (w1->tags) {
std::static_pointer_cast<taxonomy::loop>(interpolated)->tags = common_tags_vec;
}
std::static_pointer_cast<taxonomy::loop>(interpolated)->closed = w1->closed;
std::static_pointer_cast<taxonomy::loop>(interpolated)->children = interpolated_loop->children;
}
}
+227 -29
View File
@@ -27,12 +27,34 @@
#include <BRepBuilderAPI_MakeFace.hxx>
#include <BRepOffsetAPI_ThruSections.hxx>
#include <BRepBuilderAPI_MakeSolid.hxx>
#include <BRepBuilderAPI_Transform.hxx>
using namespace ifcopenshell::geometry;
using namespace ifcopenshell::geometry::kernels;
using namespace IfcGeom;
using namespace IfcGeom::util;
// @todo duplicated
namespace {
template <typename T, typename Cmp = std::less<T>>
bool has_intersection(const std::set<T, Cmp>& A,
const std::set<T, Cmp>& B) {
auto itA = A.begin();
auto itB = B.begin();
while (itA != A.end() && itB != B.end()) {
if (Cmp()(*itA, *itB)) {
++itA;
} else if (Cmp()(*itB, *itA)) {
++itB;
} else {
return true;
}
}
return false;
}
}
bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& result) {
if (loft->children.size() < 2) {
return false;
@@ -110,37 +132,125 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
BRep_Builder BB;
BB.MakeCompound(comp);
// @todo this approach is
// potentially incorrect as there is no guarantee that the wires for
// subsequently placed profiles are traversed from an equivalent start vertex.
std::vector<TopoDS_Shape> shps(loft->children.size());
std::vector<std::vector<std::set<std::string>>> all_tags;
for (auto it = loft->children.begin(); it < loft->children.end() - 1; ++it) {
std::ostringstream oss;
loft->children[0]->print(oss);
loft->children[1]->print(oss);
auto s = oss.str();
std::wcout << s.c_str() << std::endl;
// First convert all taxonomy items to TopoDS_Wire/Face
for (auto it = loft->children.begin(); it < loft->children.end(); ++it) {
auto i = std::distance(loft->children.begin(), it);
if ((*it)->kind() == taxonomy::FACE) {
if (!convert(std::static_pointer_cast<taxonomy::face>((*it)), shps[i])) {
return false;
}
}
if ((*it)->kind() == taxonomy::LOOP) {
// @todo duplicated with infra_sweep_helper
// I think make_loft() where should just return a shell instead, because
// this faceted lofting does not depend on any functionality in the geometry library
// and the branching with tags needs to be solved twice otherwise
auto loop_to_points = [](const taxonomy::loop::ptr& loop, const boost::optional<std::vector<std::string>>& input_tags) -> std::pair<std::vector<taxonomy::point3::ptr>, std::vector<std::set<std::string>>> {
std::vector<taxonomy::point3::ptr> points;
std::vector<std::set<std::string>> tags;
std::vector<std::string>::const_iterator tag_it;
if (!loop->closed.get_value_or(false)) {
points = {boost::get<taxonomy::point3::ptr>(loop->children[0]->start)};
if (input_tags) {
tags = {{input_tags->front()}};
tag_it = ++input_tags->begin();
}
}
for (auto& e : loop->children) {
const auto& p1 = boost::get<taxonomy::point3::ptr>(e->start);
const auto& p2 = boost::get<taxonomy::point3::ptr>(e->end);
if (input_tags && p1->ccomponents() == p2->ccomponents()) {
tags.back().insert(*tag_it);
++tag_it;
} else {
points.push_back(p2);
if (input_tags) {
tags.emplace_back();
tags.back().insert(*tag_it);
++tag_it;
}
}
}
if (!input_tags) {
if (loop->closed.get_value_or(false)) {
// close polygon by referencing first point
points.push_back(points.front());
}
}
return {points, tags};
};
auto lp = std::static_pointer_cast<taxonomy::loop>(*it);
TopoDS_Wire w;
if (lp->tags) {
auto [points, tags] = loop_to_points(lp, lp->tags);
BRepBuilderAPI_MakePolygon mp;
for (auto& p : points) {
const auto& xyz = p->ccomponents();
mp.Add(gp_Pnt(xyz(0), xyz(1), xyz(2)));
}
w = mp.Wire();
if (lp->matrix && !lp->matrix->is_identity()) {
const auto& m = lp->matrix->ccomponents();
gp_Trsf tr;
tr.SetValues(
m(0, 0), m(0, 1), m(0, 2), m(0, 3), m(1, 0), m(1, 1), m(1, 2), m(1, 3), m(2, 0), m(2, 1), m(2, 2), m(2, 3));
w = TopoDS::Wire(BRepBuilderAPI_Transform(w, tr).Shape());
}
all_tags.push_back(tags);
} else {
if (!convert(std::static_pointer_cast<taxonomy::loop>((*it)), w)) {
return false;
}
}
shps[i] = w;
}
if (shps[i].ShapeType() != TopAbs_FACE && shps[i].ShapeType() != TopAbs_WIRE) {
return false;
}
}
/*
// With --dimensionality CURVES_SURFACES_AND_SOLIDS this will give the interpolated profiles as line geometry
{
for (auto& f : shps) {
BB.Add(comp, f);
}
}
result = comp;
return true;
*/
// @todo this approach is
// potentially incorrect as there is no guarantee that the wires for
// subsequently placed profiles are traversed from an equivalent start vertex.
for (auto it = shps.begin(); it < shps.end() - 1; ++it) {
auto ii = std::distance(shps.begin(), it);
auto jt = it + 1;
std::array<taxonomy::item::ptr, 2> fa = { *it, *jt };
std::array<TopoDS_Shape, 2> shps;
std::array<std::vector<TopoDS_Shape>::const_iterator, 2> fa = { it, jt };
std::vector<std::array<TopoDS_Wire, 2>> ws;
ws.emplace_back();
for (int i = 0; i < 2; ++i) {
if (fa[i]->kind() == taxonomy::FACE) {
if (!convert(std::static_pointer_cast<taxonomy::face>(fa[i]), shps[i])) {
return false;
}
}
if (fa[i]->kind() == taxonomy::LOOP) {
TopoDS_Wire w;
if (!convert(std::static_pointer_cast<taxonomy::loop>(fa[i]), w)) {
return false;
}
shps[i] = w;
}
if (shps[i].ShapeType() != TopAbs_FACE && shps[i].ShapeType() != TopAbs_WIRE) {
return false;
}
if (shps[i].ShapeType() == TopAbs_FACE) {
ws[0][i] = BRepTools::OuterWire(TopoDS::Face(shps[i]));
if (fa[i]->ShapeType() == TopAbs_FACE) {
ws[0][i] = BRepTools::OuterWire(TopoDS::Face(*fa[i]));
size_t j = 1;
for (TopExp_Explorer exp(shps[i], TopAbs_WIRE); exp.More(); exp.Next()) {
for (TopExp_Explorer exp(*fa[i], TopAbs_WIRE); exp.More(); exp.Next()) {
if (exp.Current() != ws[0][i]) {
while (ws.size() <= j) {
ws.emplace_back();
@@ -149,22 +259,110 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
}
}
} else {
ws[0][i] = TopoDS::Wire(shps[i]);
ws[0][i] = TopoDS::Wire(*fa[i]);
}
}
if (shps[0].ShapeType() == TopAbs_FACE) {
if (it->ShapeType() == TopAbs_FACE) {
// When processing a sectioned *surface* there are no
// begin and end caps that need to be added.
if (it == loft->children.begin()) {
if (it == shps.begin()) {
// faces.Append(shps[0]);
BB.Add(comp, shps[0]);
}
if (jt == loft->children.end() - 1) {
if (jt == shps.end() - 1) {
// faces.Append(shps[1]);
BB.Add(comp, shps[1]);
}
}
if (!all_tags.empty()) {
// only open profiles have tags for now, so there is only one wire, no inner wires
const auto& wp = ws[0];
std::array<std::vector<gp_Pnt>, 2> profile_points;
std::array<std::vector<std::vector<std::set<std::string>>>::const_iterator, 2> tag_pairs = {
all_tags.begin() + std::distance(shps.begin(), it),
all_tags.begin() + std::distance(shps.begin(), jt)};
for (size_t i = 0; i < 2; ++i) {
TopTools_IndexedDataMapOfShapeListOfShape ancestors;
const auto& wire = wp[i];
auto& result = profile_points[i];
TopExp::MapShapesAndAncestors(
wire,
TopAbs_VERTEX,
TopAbs_EDGE,
ancestors);
TopoDS_Vertex v0, vn, previous;
TopExp::Vertices(wire, v0, vn);
TopoDS_Vertex curr = v0;
result.push_back(BRep_Tool::Pnt(curr));
while (true) {
if (curr.IsSame(vn)) {
break;
}
const TopTools_ListOfShape& incidentEdges = ancestors.FindFromKey(curr);
for (TopTools_ListIteratorOfListOfShape it(incidentEdges); it.More(); it.Next()) {
const TopoDS_Edge& e = TopoDS::Edge(it.Value());
TopoDS_Vertex ev0, ev1;
TopExp::Vertices(e, ev0, ev1);
TopoDS_Vertex other_on_edge = curr.IsSame(ev0) ? ev1 : ev0;
if (other_on_edge.IsSame(previous)) {
continue;
} else {
previous = curr;
curr = other_on_edge;
result.push_back(BRep_Tool::Pnt(curr));
break;
}
}
}
}
auto a = profile_points[0].begin();
auto b = profile_points[1].begin();
auto c = tag_pairs[0]->begin();
auto d = tag_pairs[1]->begin();
if (!has_intersection(*c, *d)) {
throw std::runtime_error("Starting vertices do not have corresponding tags");
}
auto emit_triangle = [&](const gp_Pnt& p1, const gp_Pnt& p2, const gp_Pnt& p3) {
BB.Add(comp, BRepBuilderAPI_MakeFace(BRepBuilderAPI_MakePolygon(p1, p2, p3, true).Wire()).Face());
};
while (c != (tag_pairs[0]->end() - 1) && d != (tag_pairs[0]->end() - 1)) {
if (c != (tag_pairs[0]->end() - 1) && has_intersection(*(c + 1), *d)) {
emit_triangle(*a, *(a + 1), *b);
++a;
++c;
} else if (d != (tag_pairs[1]->end() - 1) && has_intersection(*c, *(d + 1))) {
emit_triangle(*a, *(b + 1), *b);
++b;
++d;
} else if (c != (tag_pairs[0]->end() - 1) && d != (tag_pairs[1]->end() - 1) && has_intersection(*(c + 1), *(d + 1))) {
emit_triangle(*a, *(a + 1), *b);
emit_triangle(*(a + 1), *(b + 1), *b);
++a;
++b;
++c;
++d;
} else {
throw std::runtime_error("Unable to construct surface");
}
}
continue;
}
for (auto& wp : ws) {
BRepTools_WireExplorer a(wp[0]);
BRepTools_WireExplorer b(wp[1]);
@@ -50,7 +50,6 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOpenCrossProfileDef* inst) {
if (tags.has_value() && !tags.get().empty()) {
tag = tags.get()[0];
}
// start->tag = tag;
auto widths = inst->Widths();
auto angles = inst->Slopes(); // these are actually angles, but the attribute is called Slopes
@@ -79,16 +78,13 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOpenCrossProfileDef* inst) {
tag = tags.get()[i+1];
}
// points.push_back(taxonomy::make<taxonomy::point3>(x, y, z, tag));
points.push_back(taxonomy::make<taxonomy::point3>(x, y, z));
}
auto mapped = polygon_from_points(points);
if (mapped->kind() == taxonomy::LOOP) {
auto r = taxonomy::loop::ptr((taxonomy::loop*)mapped->clone_());
r->closed = false;
return r;
}
mapped->closed = false;
mapped->tags = tags;
return mapped;
}
+6 -6
View File
@@ -822,11 +822,11 @@ namespace {
boost::optional<function_item::ptr> ifcopenshell::geometry::taxonomy::loop_to_function_item_upgrade_impl(ptr item) {
boost::optional<function_item::ptr> fi_;
boost::optional<function_item::ptr> function_item_;
auto loop_ = dcast<loop>(item);
if (loop_) {
if (loop_->fi.is_initialized()) {
fi_ = loop_->fi;
if (loop_->function_item.is_initialized()) {
function_item_ = loop_->function_item;
} else {
// piecewise_function is a specialization of function_item - callers don't need to know this detail
piecewise_function::spans_t spans;
@@ -880,9 +880,9 @@ boost::optional<function_item::ptr> ifcopenshell::geometry::taxonomy::loop_to_fu
return boost::none;
}
}
fi_ = make<piecewise_function>(0.0,spans);
loop_->fi = fi_;
function_item_ = make<piecewise_function>(0.0, spans);
loop_->function_item = function_item_;
}
}
return fi_;
return function_item_;
}
+2 -1
View File
@@ -928,7 +928,8 @@ typedef item const* ptr;
DECLARE_PTR(loop)
boost::optional<bool> external, closed;
boost::optional<taxonomy::function_item::ptr> fi;
boost::optional<taxonomy::function_item::ptr> function_item;
boost::optional<std::vector<std::string>> tags;
bool is_polyhedron() const {
for (auto& e : children) {
@@ -100,10 +100,15 @@ class Usecase:
size = self.convert_si_to_unit(1)
points = ((0.0, 0.0), (size, 0.0), (size, size), (0.0, size), (0.0, 0.0))
if self.polyline:
points = [
(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle))))
for p in self.polyline
]
# Only scale polyline if we have actual slope
if self.x_angle and abs(self.x_angle) > 1e-6:
points = [
(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle))))
for p in self.polyline
]
else:
points = [(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) for p in self.polyline]
if self.file.schema == "IFC2X3":
curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points])
else:
@@ -114,21 +119,23 @@ class Usecase:
else:
direction_ratios = (0.0, 0.0, 1.0)
offset_direction = direction_ratios # offset direction doesn't change if direction_sense is negative
extrusion_direction = self.file.createIfcDirection(direction_ratios)
if self.direction_sense == "NEGATIVE":
direction_ratios = tuple(-n for n in direction_ratios)
extrusion_direction = self.file.createIfcDirection(direction_ratios)
perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(self.x_angle))
perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(self.x_angle))
# Calculate depth based on extrusion angle
extrusion_angle = abs(self.x_angle) if self.x_angle else 0
if extrusion_angle > 1e-6:
perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(extrusion_angle))
perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(extrusion_angle))
else:
perpendicular_depth = self.convert_si_to_unit(self.depth)
perpendicular_offset = self.convert_si_to_unit(self.offset)
position = None
# default position for IFC2X3 where .Position is not optional
if self.file.schema == "IFC2X3" or self.offset != 0:
position_vector = (
offset_direction[0] * perpendicular_offset,
offset_direction[1] * perpendicular_offset,
offset_direction[2] * perpendicular_offset,
direction_ratios[0] * perpendicular_offset,
direction_ratios[1] * perpendicular_offset,
direction_ratios[2] * perpendicular_offset,
)
position = self.file.createIfcAxis2Placement3D(
self.file.createIfcCartesianPoint(position_vector),
@@ -85,7 +85,6 @@ class Usecase:
def create_item(self) -> ifcopenshell.entity_instance:
length = self.convert_si_to_unit(self.settings["length"])
thickness = self.convert_si_to_unit(self.settings["thickness"])
thickness *= 1 / cos(self.settings["x_angle"])
if self.settings["direction_sense"] == "NEGATIVE":
thickness *= -1
points = (
@@ -113,7 +112,7 @@ class Usecase:
self.file.createIfcDirection((1.0, 0.0, 0.0)),
),
extrusion_direction,
self.convert_si_to_unit(self.settings["height"]) * abs(1 / cos(self.settings["x_angle"])),
self.convert_si_to_unit(self.settings["height"]),
)
if self.settings["booleans"]:
extrusion = self.apply_booleans(extrusion)
@@ -243,6 +243,59 @@ class Usecase:
except RuntimeError:
return None
def material_sets_are_equal(self, set1: ifcopenshell.entity_instance, set2: ifcopenshell.entity_instance) -> bool:
"""Check if two material sets are structurally equivalent."""
if set1.is_a() != set2.is_a():
return False
ifc_class = set1.is_a()
if ifc_class == "IfcMaterialLayerSet":
layers1 = set1.MaterialLayers or []
layers2 = set2.MaterialLayers or []
if len(layers1) != len(layers2):
return False
for l1, l2 in zip(layers1, layers2):
if (l1.Material is None) != (l2.Material is None):
return False
if l1.Material and l1.Material.Name != l2.Material.Name:
return False
if l1.LayerThickness != l2.LayerThickness:
return False
elif ifc_class == "IfcMaterialConstituentSet":
constituents1 = set1.MaterialConstituents or []
constituents2 = set2.MaterialConstituents or []
if len(constituents1) != len(constituents2):
return False
for c1, c2 in zip(constituents1, constituents2):
if (c1.Material is None) != (c2.Material is None):
return False
if c1.Material and c1.Material.Name != c2.Material.Name:
return False
if c1.Name != c2.Name:
return False
elif ifc_class == "IfcMaterialProfileSet":
profiles1 = set1.MaterialProfiles or []
profiles2 = set2.MaterialProfiles or []
if len(profiles1) != len(profiles2):
return False
for p1, p2 in zip(profiles1, profiles2):
if (p1.Material is None) != (p2.Material is None):
return False
if p1.Material and p1.Material.Name != p2.Material.Name:
return False
if (p1.Profile is None) != (p2.Profile is None):
return False
if p1.Profile:
profile_name1 = getattr(p1.Profile, "ProfileName", None)
profile_name2 = getattr(p2.Profile, "ProfileName", None)
if profile_name1 != profile_name2:
return False
return True
def get_existing_element(self, element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""Get existing element for a library element.
@@ -264,13 +317,17 @@ class Usecase:
name = element.Name
return next((e for e in self.file.by_type("IfcMaterial") if e.Name == name), None)
elif element in MATERIAL_SETS:
elif element.is_a() in MATERIAL_SETS:
ifc_class = element.is_a()
name_attr = "LayerSetName" if ifc_class == "IfcMaterialLayerSet" else "Name"
material_set_name = getattr(element, name_attr)
if material_set_name is None:
return
return next((e for e in self.file.by_type(ifc_class) if getattr(e, name_attr) == material_set_name), None)
for candidate in self.file.by_type(ifc_class):
if getattr(candidate, name_attr) == material_set_name:
if self.material_sets_are_equal(element, candidate):
return candidate
return None
elif element.is_a("IfcProfileDef"):
profile_name = element.ProfileName
@@ -665,12 +722,11 @@ class Usecase:
name_attr = "LayerSetName" if ifc_class == "IfcMaterialLayerSet" else "Name"
material_set_name = getattr(element, name_attr)
if material_set_name is not None:
existing_material_set = next(
(e for e in ifc_file.by_type(ifc_class) if getattr(e, name_attr) == material_set_name), None
)
if existing_material_set is not None:
reuse_identities[element_identity] = existing_material_set
return existing_material_set
for candidate in ifc_file.by_type(ifc_class):
if getattr(candidate, name_attr) == material_set_name:
if self.material_sets_are_equal(element, candidate):
reuse_identities[element_identity] = candidate
return candidate
elif element.is_a("IfcPresentationStyle"):
style_name = element.Name
@@ -212,7 +212,7 @@ class FormatTransformer(lark.Transformer):
"""Handle variable substitution like {{z}} or {{Pset_Wall.FireRating}}"""
if self.element is None:
return "0" # Default value if no element context
query_path = args[0]
try:
value = get_element_value(self.element, query_path)
@@ -399,11 +399,11 @@ class GetElementTransformer(lark.Transformer):
def format(query: str, element: Optional[ifcopenshell.entity_instance] = None) -> str:
"""Format a query string with optional element context for variable substitution.
:param query: Format query string (can include {{variable}} placeholders)
:param element: Optional IFC element for variable substitution
:return: Formatted string
Example:
format("{{z}} / 2", element) # Substitutes element's z value
format("imperial_length({{z}} / 2, 4)", element) # Uses z in calculation
@@ -1257,4 +1257,4 @@ class FacetTransformer(lark.Transformer):
if comparison.startswith("!"):
return not result
return result
return result