Files
IfcOpenShell/src/bonsai/bonsai/bim/module/debug/operator.py
T
Thomas Krijnen be3c2ee770 Expose geometry types in snake case
Generated with the assistance of an AI coding tool.
2026-08-09 04:44:18 +02:00

1110 lines
43 KiB
Python

# 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 logging
import os
import platform
import random
import subprocess
import sys
import time
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never, get_args
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.unit
from bpy_extras.io_utils import ExportHelper, ImportHelper
import bonsai.bim.handler
import bonsai.bim.import_ifc as import_ifc
import bonsai.core.debug as core
import bonsai.core.profile
import bonsai.core.type
import bonsai.tool as tool
from bonsai import format_debug_info, get_debug_info
from bonsai.bim.ifc import IfcStore
if TYPE_CHECKING:
from bonsai.bim.prop import Attribute
class CopyDebugInformation(bpy.types.Operator):
bl_idname = "bim.copy_debug_information"
bl_label = "Copy Debug Information"
bl_description = "Copies debugging information to your clipboard for use in bug reports"
def execute(self, context):
info = get_debug_info()
if tool.Ifc.get():
info.update(
{
"ifc": os.path.basename(tool.Ifc.get_path()) if os.path.isfile(tool.Ifc.get_path()) else "Unsaved",
"schema": tool.Ifc.get().schema,
"preprocessor_version": tool.Ifc.get().header.file_name.preprocessor_version,
"originating_system": tool.Ifc.get().header.file_name.originating_system,
}
)
text = format_debug_info(info)
text_with_backticks = f"```\n{text}\n```"
print("-" * 80)
print(text_with_backticks)
print("-" * 80)
assert context.window_manager
context.window_manager.clipboard = text_with_backticks
return {"FINISHED"}
class PrintIfcFile(bpy.types.Operator):
bl_idname = "bim.print_ifc_file"
bl_label = "Print IFC File"
bl_description = "Prints the file contents in the system console.\nAccess it with Window > Toggle System Console"
@classmethod
def poll(cls, context):
return tool.Ifc.get()
def execute(self, context):
print(tool.Ifc.get().to_string())
return {"FINISHED"}
class ConvertToBlender(bpy.types.Operator):
bl_idname = "bim.convert_to_blender"
bl_label = "Convert To Blender File"
bl_description = "Removes all IFC data and revert to basic Blender objects.\nWarning : Cannot be undone."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
for obj in bpy.data.objects:
if obj.library:
continue
if obj.type in {"MESH", "EMPTY"}:
tool.Ifc.unlink(obj=obj)
data = obj.data
if tool.Geometry.has_mesh_properties(data):
if data.library:
continue
tool.Geometry.get_mesh_props(data).ifc_definition_id = 0
for material in bpy.data.materials:
if material.library:
continue
tool.Ifc.unlink(obj=material)
bim_props = tool.Blender.get_bim_props()
bim_props.ifc_file = ""
tool.Debug.get_debug_props().attributes.clear()
IfcStore.purge()
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
class ValidateIfcFile(bpy.types.Operator):
bl_idname = "bim.validate_ifc_file"
bl_label = "Validate IFC File"
@classmethod
def poll(cls, context):
return tool.Ifc.get()
def execute(self, context):
import ifcopenshell.validate
class LogDetectionHandler(logging.Handler):
message_logged = False
def emit(self, record):
if not self.message_logged:
self.message_logged = True
logger = logging.getLogger("validate")
logger.setLevel(logging.DEBUG)
# use LogDetectionHandler to check whether there were any validation errors
# during `ifcopenshell.validate.validate`
handler = LogDetectionHandler()
logger.addHandler(handler)
ifcopenshell.validate.validate(tool.Ifc.get(), logger, express_rules=True)
logger.removeHandler(handler)
if handler.message_logged:
self.report({"INFO"}, "Check validation results in the system console.")
else:
self.report({"INFO"}, "No validation issues found.")
return {"FINISHED"}
class ValidateIfcAssets(bpy.types.Operator):
bl_idname = "bim.validate_ifc_assets"
bl_label = "Validate IFC Assets"
bl_description = (
"Run Bonsai validation for IFC assets.\n\n"
"There's an internal Bonsai convention to treat some IFC assets "
"as unique based on their name (e.g. profiles, materials, styles).\n"
"Though it's not required by IFC, it is a generally good practice "
"to keep asset names unique and it also helps with various issues.\n"
"If it's not conformed, it could lead to duplicated assets or "
"the opposite - different assets of the same name treated as one."
)
bl_options = set()
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("IFC file is not loaded.")
return False
return True
def execute(self, context):
ifc_file = tool.Ifc.get()
ifc_classes = {
"IfcMaterial": "Name",
"IfcMaterialLayerSet": "LayerSetName",
"IfcMaterialConstituentSet": "Name",
"IfcMaterialProfileSet": "Name",
"IfcProfileDef": "ProfileName",
"IfcPresentationStyle": "Name",
}
issues_found = False
unique_assets: defaultdict[str, list[ifcopenshell.entity_instance]]
for ifc_class, name_attr in ifc_classes.items():
unique_assets = defaultdict(list)
for asset in ifc_file.by_type(ifc_class):
asset_name: Union[str, None] = getattr(asset, name_attr)
if asset_name is None:
continue
unique_assets[asset_name].append(asset)
msg = ""
for asset_name, assets in unique_assets.items():
if len(assets) == 1:
continue
msg += f"{ifc_class} name '{asset_name}' is used by multiple assets:\n"
for asset in assets:
msg += f"- {asset}\n"
if msg:
issues_found = True
msg = f"Found issues validating {ifc_class} assets.\n" + msg
print(msg)
if issues_found:
self.report({"INFO"}, "Check asset validation results in the system console.")
else:
self.report({"INFO"}, "No asset validation issues found.")
return {"FINISHED"}
class ProfileImportIFC(bpy.types.Operator):
profile_filename = "blender.prof"
bl_idname = "bim.profile_import_ifc"
bl_label = "Profile Import IFC"
bl_description = f"Reload currently loaded project and save cprofile stats for reloading to '{profile_filename}'"
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file loaded.")
return False
bim_props = tool.Blender.get_bim_props()
if not bim_props.ifc_file:
cls.poll_message_set("Current IFC file is not saved.")
return False
return True
def execute(self, context):
import cProfile
import pstats
profile_file = Path(self.profile_filename)
cProfile.run("import bpy; bpy.ops.bim.load_project_elements()", str(profile_file))
p = pstats.Stats(str(profile_file))
p.sort_stats("cumulative").print_stats(50)
self.report({"INFO"}, f'Profile stats are saved to "{profile_file.absolute()}".')
return {"FINISHED"}
class CreateAllShapes(bpy.types.Operator):
bl_idname = "bim.create_all_shapes"
bl_label = "Test All Shapes"
bl_description = (
"Look for errors in all the shapes contained in the file.\n\nSee system console for the detailed results."
)
bl_options = {"REGISTER"}
geometry_library: bpy.props.EnumProperty(
name="Geometry Library",
description="Geometry library to use for testing shape creation.",
items=[(i, i, "") for i in get_args(ifcopenshell.geom.GEOMETRY_LIBRARY)],
# By default use the same library as used for importing ifc project.
default="hybrid-cgal-simple-opencascade",
)
custom_geometry_library: bpy.props.StringProperty(
name="Custom Geometry Library",
description="Provide a custom geometry library name, will override the 'geometry library' property.",
)
if TYPE_CHECKING:
geometry_library: ifcopenshell.geom.GEOMETRY_LIBRARY
custom_geometry_library: str
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file is loaded.")
return False
return True
def execute(self, context):
self.file = tool.Ifc.get()
geometry_library = self.custom_geometry_library or self.geometry_library
elements = self.file.by_type("IfcElement") + self.file.by_type("IfcSpace")
print(f"Testing geometry library '{geometry_library}'.")
total = len(elements)
settings = ifcopenshell.geom.settings()
settings.set("keep-bounding-boxes", True)
settings_2d = ifcopenshell.geom.settings()
settings_2d.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
failures: list[ifcopenshell.entity_instance] = []
excludes = () # For the developer to debug with
for i, element in enumerate(elements, 1):
if element.GlobalId in excludes:
continue
if not element.Representation:
continue
print(f"{i}/{total}:", element)
start = time.time()
shape = None
try:
shape = ifcopenshell.geom.create_shape(settings, element, geometry_library=geometry_library)
except:
try:
shape = ifcopenshell.geom.create_shape(settings_2d, element, geometry_library=geometry_library)
except:
failures.append(element)
print("***** FAILURE *****")
if shape:
assert isinstance(shape, W.triangulation_element)
geom = shape.geometry
print(
f"Success {time.time() - start:.3f}s "
f"V:{(len(geom.verts)//3)} E:{(len(geom.edges)//2)} F:{(len(geom.faces)//3)}"
)
self.report({"INFO"}, f"Failed shapes: {len(failures)}, check the system console for details.")
for failure in failures:
print(failure)
return {"FINISHED"}
class CreateShapeFromStepId(bpy.types.Operator):
bl_idname = "bim.create_shape_from_step_id"
bl_label = "Create Shape From STEP ID"
bl_description = "Recreate a mesh object from a STEP ID"
bl_options = {"REGISTER", "UNDO"}
should_include_curves: bpy.props.BoolProperty(default=True)
step_id: bpy.props.IntProperty(default=0)
geometry_library: bpy.props.EnumProperty(
name="Geometry Library",
items=[(i, i, "") for i in get_args(ifcopenshell.geom.GEOMETRY_LIBRARY)],
default="opencascade",
)
custom_geometry_library: bpy.props.StringProperty(
name="Custom Geometry Library",
description="Provide a custom geometry library name, will override the 'geometry library' property.",
)
if TYPE_CHECKING:
should_include_curves: bool
step_id: int
geometry_library: ifcopenshell.geom.GEOMETRY_LIBRARY
custom_geometry_library: str
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file is loaded.")
return False
return True
def execute(self, context):
assert context.scene
geometry_library = self.custom_geometry_library or self.geometry_library
logger = logging.getLogger("ImportIFC")
self.ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger)
self.file = tool.Ifc.get()
element = self.file.by_id(self.step_id or int(tool.Debug.get_debug_props().step_id))
settings = ifcopenshell.geom.settings()
settings.set("keep-bounding-boxes", True)
if self.should_include_curves:
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
shape = ifcopenshell.geom.create_shape(settings, element, geometry_library=geometry_library)
if shape:
ifc_importer = import_ifc.IfcImporter(self.ifc_import_settings)
ifc_importer.file = self.file
mesh = ifc_importer.create_mesh(element, shape)
else:
mesh = None
obj = bpy.data.objects.new(f"Debug/{element.is_a()}/{element.id()}", mesh)
obj.location = context.scene.cursor.location
context.scene.collection.objects.link(obj)
return {"FINISHED"}
class SelectHighPolygonMeshes(bpy.types.Operator):
bl_idname = "bim.select_high_polygon_meshes"
bl_label = "Select High Polygon Meshes"
bl_description = "Select objects containing more polygons than the specified number"
bl_options = {"REGISTER", "UNDO"}
threshold: bpy.props.IntProperty()
def execute(self, context):
assert context.view_layer
for obj in context.view_layer.objects:
if isinstance(obj.data, bpy.types.Mesh) and len(obj.data.polygons) > self.threshold:
obj.select_set(True)
return {"FINISHED"}
class SelectHighestPolygonMeshes(bpy.types.Operator):
bl_idname = "bim.select_highest_polygon_meshes"
bl_label = "Select Highest Polygon Meshes"
bl_description = "Select objects with a number of polygons superior to the specified percentile"
bl_options = {"REGISTER", "UNDO"}
percentile: bpy.props.IntProperty()
def execute(self, context):
assert context.view_layer
objects = [obj for obj in context.view_layer.objects if obj.type == "MESH"]
if objects:
percentile = len(max(objects, key=lambda o: len(o.data.polygons)).data.polygons) * self.percentile / 100
print(f"Selected all Meshes with more than {int(percentile)} polygons")
[obj.select_set(True) for obj in objects if len(obj.data.polygons) > percentile]
return {"FINISHED"}
class RewindInspector(bpy.types.Operator):
bl_idname = "bim.rewind_inspector"
bl_label = "Rewind Inspector"
bl_description = "Rewind the Inspector to the previously inspected element"
def execute(self, context):
props = tool.Debug.get_debug_props()
total_breadcrumbs = len(props.step_id_breadcrumb)
if total_breadcrumbs < 2:
return {"FINISHED"}
previous_step_id = int(props.step_id_breadcrumb[total_breadcrumbs - 2].name)
props.step_id_breadcrumb.remove(total_breadcrumbs - 1)
props.step_id_breadcrumb.remove(total_breadcrumbs - 2)
bpy.ops.bim.inspect_from_step_id(step_id=previous_step_id)
return {"FINISHED"}
class InspectFromStepId(bpy.types.Operator):
bl_idname = "bim.inspect_from_step_id"
bl_label = "Inspect From STEP ID"
bl_description = "Inspect the attributes and references by looking up the specified STEP ID"
step_id: bpy.props.IntProperty()
@classmethod
def poll(cls, context):
return tool.Ifc.get()
def execute(self, context):
self.file = tool.Ifc.get()
debug_props = tool.Debug.get_debug_props()
debug_props.active_step_id = self.step_id
crumb = debug_props.step_id_breadcrumb.add()
crumb.name = str(self.step_id)
element = self.file.by_id(self.step_id)
debug_props.attributes.clear()
debug_props.inverse_attributes.clear()
debug_props.inverse_references.clear()
for key, value in element.get_info().items():
self.add_attribute(debug_props.attributes, key, value)
for key in dir(element):
if (
not key[0].isalpha()
or key[0] != key[0].upper()
or key in element.get_info()
or not getattr(element, key)
):
continue
self.add_attribute(debug_props.inverse_attributes, key, getattr(element, key))
for inverse in self.file.get_inverse(element):
new = debug_props.inverse_references.add()
new.string_value = str(inverse)
new.int_value = inverse.id()
return {"FINISHED"}
def add_attribute(self, prop: "bpy.types.bpy_prop_collection_idprop[Attribute]", key: str, value: Any) -> None:
if isinstance(value, tuple) and len(value) < 10:
for i, item in enumerate(value):
self.add_attribute(prop, key + f"[{i}]", item)
return
elif isinstance(value, tuple) and len(value) >= 10:
key = key + "({})".format(len(value))
new = prop.add()
new.name = key
new.string_value = str(value)
if isinstance(value, ifcopenshell.entity_instance):
new.int_value = int(value.id())
class InspectFromObject(bpy.types.Operator):
bl_idname = "bim.inspect_from_object"
bl_label = "Inspect From Object"
bl_description = "Inspect the Active Object's attributes and references"
@classmethod
def get_active_object_ifc_definition(cls, context: bpy.types.Context) -> Union[int, None]:
obj = context.active_object
assert obj
if ifc_id := tool.Blender.get_ifc_definition_id(obj):
return ifc_id
if (
(data := obj.data)
and tool.Geometry.has_mesh_properties(data)
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
):
return ifc_id
@classmethod
def poll(cls, context):
if not context.active_object:
cls.poll_message_set("No Active Object")
return False
elif not cls.get_active_object_ifc_definition(context):
cls.poll_message_set("Active Object doesn't have an IFC definition")
return False
else:
return True
def execute(self, context):
bpy.ops.bim.inspect_from_step_id(step_id=InspectFromObject.get_active_object_ifc_definition(context))
return {"FINISHED"}
class PrintObjectPlacement(bpy.types.Operator):
bl_idname = "bim.print_object_placement"
bl_label = "Print Object Placement"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Print object placement to the system console.\n\n" + "ALT+CLICK create an empty object at that position"
)
step_id: bpy.props.IntProperty()
create_empty_object: bpy.props.BoolProperty(name="Create Empty Object", default=False, options={"SKIP_SAVE"})
arrow_size: bpy.props.FloatProperty(name="Arrow Size", default=0.2, subtype="DISTANCE")
def invoke(self, context, event):
# keep the viewport position on alt+click
# make sure to use SKIP_SAVE on property, otherwise it might get stuck
if event.type == "LEFTMOUSE" and event.alt:
self.create_empty_object = True
return self.execute(context)
def execute(self, context):
placement = ifcopenshell.util.placement.get_local_placement(tool.Ifc.get().by_id(self.step_id))
if self.create_empty_object:
bpy.ops.object.empty_add(type="ARROWS")
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
assert context.active_object
context.active_object.matrix_world = placement.transpose()
context.active_object.matrix_world.translation *= si_conversion
context.active_object.empty_display_size = self.arrow_size
print(placement)
return {"FINISHED"}
class ParseExpress(bpy.types.Operator):
bl_idname = "bim.parse_express"
bl_label = "Parse Express"
def execute(self, context):
props = tool.Debug.get_debug_props()
core.parse_express(tool.Debug, props.express_file)
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
class SelectExpressFile(bpy.types.Operator, ImportHelper):
bl_idname = "bim.select_express_file"
bl_label = "Select Express File"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select an IFC EXPRESS definition"
filter_glob: bpy.props.StringProperty(default="*.exp", options={"HIDDEN"})
def execute(self, context):
props = tool.Debug.get_debug_props()
if os.path.exists(self.filepath) and "exp" in os.path.splitext(self.filepath)[1]:
props.express_file = self.filepath
return {"FINISHED"}
class OverrideDisplayType(bpy.types.Operator):
bl_idname = "bim.override_display_type"
bl_label = "Override Display Type"
display: bpy.props.StringProperty()
def execute(self, context):
for obj in context.selected_objects:
obj.display_type = self.display
return {"FINISHED"}
class PrintUnusedElementStats(bpy.types.Operator):
bl_idname = "bim.print_unused_elements_stats"
bl_label = "Print Unused Elements Stats"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Print all unused elements in current IFC project in system console, not limited to the selected class"
)
ignore_contexts: bpy.props.BoolProperty(name="Ignore Contexts", default=True)
ignore_relationships: bpy.props.BoolProperty(name="Ignore Relationships", default=True)
ignore_types: bpy.props.BoolProperty(name="Ignore Types", default=True)
ignore_styled_items: bpy.props.BoolProperty(name="Ignore Styled Items", default=True)
def execute(self, context):
props = tool.Debug.get_debug_props()
# ignore some classes that could have zero 0 inverse references by their nature
ignore_classes = []
if self.ignore_contexts:
ignore_classes += ["IfcRepresentationContext"]
if self.ignore_relationships:
ignore_classes += ["IfcRelationship"]
if self.ignore_types:
ignore_classes += ["IfcTypeProduct"]
if self.ignore_styled_items:
ignore_classes += ["IfcStyledItem"]
ignore_classes += [
"IfcDocumentReference", # Document references for sheet elements (drawings, schedules, etc).
"IfcIndexedColourMap", # Only referenced by inverse attributes.
"IfcIndexedTextureMap", # Only referenced by inverse attributes.
]
unused_elements = tool.Debug.print_unused_elements_stats(props.ifc_class_purge, ignore_classes)
self.report({"INFO"}, f"{unused_elements} unused elements found, check the system console for the details.")
return {"FINISHED"}
class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator, ExportHelper):
bl_idname = "bim.purge_unused_elements_by_class"
bl_label = "Purge Unused Elements By Class"
bl_description = (
"Will find all elements of class that have no inverse references and will remove them, use very carefully.\n"
"If IFC class is provided in neighbour field, will purge only elements of the provided class. Otherwise will purge all white-listed elements.\n"
"ALT+CLICK to provide a path where to save the IFC file with the removed elements (note changes will be applied to the current IFC too)"
)
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".ifc"
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"})
filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"})
def invoke(self, context, event):
if event.type == "LEFTMOUSE" and event.alt:
return ExportHelper.invoke(self, context, event)
return self.execute(context)
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file is loaded.")
return False
return True
def _execute(self, context):
props = tool.Debug.get_debug_props()
if props.ifc_class_purge:
purged_elements = core.purge_unused_elements(tool.Ifc, tool.Debug, props.ifc_class_purge)
self.report({"INFO"}, f"{purged_elements} unused elements found and removed.")
if self.filepath:
tool.Ifc.get().write(self.filepath)
return
# A whitelisted class is a class that only contains simple data that
# has no meaning by itself (only has meaning when combined with other
# data). E.g. a colour by itself has no meaning so is whitelisted, but
# a material by itself may be part of your materials library.
# There are classes I don't know enough about to decide. Not whitelisting in case.
# IfcAlignmentParameterSegment
# IfcBoundaryCondition
# IfcStructuralConnectionCondition
# IfcStructuralLoad
# Keep list sorted alphabetically.
whitelisted_classes = [
"IfcActorRole",
"IfcAddress",
"IfcApplication",
"IfcAppliedValue",
"IfcConnectionGeometry",
"IfcCoordinateReferenceSystem",
"IfcDerivedUnit",
"IfcDerivedUnitElement",
"IfcDimensionalExponents",
"IfcExternalReference",
"IfcGridAxis",
"IfcIrregularTimeSeriesValue",
"IfcLightDistributionData",
"IfcLightIntensityDistribution",
"IfcMeasureWithUnit",
"IfcMonetaryUnit",
"IfcNamedUnit",
"IfcObjectPlacement",
"IfcOrganization", # Should be referenced as part of an actor
"IfcOwnerHistory",
"IfcPerson", # Should be referenced as part of an actor
"IfcPersonAndOrganization", # Should be referenced as part of an actor
"IfcPhysicalQuantity",
"IfcPresentationItem",
"IfcProductDefinitionShape",
"IfcPropertyAbstraction",
"IfcPropertyDefinition", # A bit of a questionable one, and the odd one out from IfcRoot.
"IfcRecurrencePattern",
"IfcReference",
"IfcRepresentation",
# "IfcRepresentationContext", # Can be present even in basic empty project.
"IfcRepresentationItem",
"IfcRepresentationMap",
"IfcSchedulingTime",
"IfcShapeAspect",
"IfcTable",
"IfcTableColumn",
"IfcTableRow",
"IfcTextureCoordinateIndices",
"IfcTimePeriod",
"IfcTimeSeries",
"IfcTimeSeriesValue",
"IfcUnitAssignment",
"IfcVirtualGridIntersection",
]
whitelist_exceptions = {
# Document references for sheet elements (drawings, schedules, etc).
"IfcExternalReference": ("IfcDocumentReference",),
}
total_purged = 0
schema = tool.Ifc.schema()
while True:
total_batches = 0
print("*" * 100)
total_batch_purged = 0
for ifc_class in whitelisted_classes:
total_class_purged = 0
# Ensure class is present in the schema.
try:
schema.declaration_by_name(ifc_class)
except RuntimeError:
continue
elements = tool.Ifc.get().by_type(ifc_class)
if ifc_class in whitelist_exceptions:
elements = [e for e in elements if not any(e.is_a(c) for c in whitelist_exceptions[ifc_class])]
to_purge = set()
for element in elements:
try:
if ifc_class == "IfcRepresentationItem" and element.is_a("IfcStyledItem") and element.Item:
continue
except:
to_purge.add(element.id()) # It's invalid, definitely purge it.
if tool.Ifc.get().get_total_inverses(element) == 0:
to_purge.add(element.id())
for element_id in to_purge:
try:
element = tool.Ifc.get().by_id(element_id)
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), element)
total_class_purged += 1
except:
continue
if total_class_purged > 0:
total_batch_purged += total_class_purged
print(f"Auto purged {total_class_purged} {ifc_class}")
if total_batch_purged > 0:
total_purged += total_batch_purged
print(f"Auto purged in batch: {total_batch_purged}")
total_batches += 1
if total_batch_purged == 0:
break
elif total_batches > 20:
print("Finished 20 batches. Manually stopping in case of infinite loop.")
self.report({"INFO"}, f"Auto purged {total_purged} orphaned elements")
if self.filepath:
tool.Ifc.get().write(self.filepath)
class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.purge_unused_objects"
bl_label = "Purge Unused Objects"
bl_options = {"REGISTER", "UNDO"}
object_type: bpy.props.EnumProperty(
name="Object Type",
items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)),
)
if TYPE_CHECKING:
object_type: tool.Debug.PurgeMergeObjectType
def _execute(self, context):
object_type = self.object_type
if object_type == "TYPE":
purged = bonsai.core.type.purge_unused_types(tool.Ifc, tool.Type, tool.Geometry)
elif object_type == "PROFILE":
purged = bonsai.core.profile.purge_unused_profiles(tool.Ifc, tool.Profile)
elif object_type == "STYLE":
purged = tool.Style.purge_unused_styles()
elif object_type == "MATERIAL":
purged = tool.Material.purge_unused_materials()
elif object_type in ("APPLICATION", "ORGANIZATION"):
purged = core.purge_unused_elements(tool.Ifc, tool.Debug, "IfcApplication")
elif object_type == "PERSON":
purged = core.purge_unused_elements(tool.Ifc, tool.Debug, "IfcPerson")
elif object_type == "PERSON_AND_ORGANIZATION":
purged = core.purge_unused_elements(tool.Ifc, tool.Debug, "IfcPersonAndOrganization")
else:
assert_never(object_type)
self.report({"INFO"}, f"{purged} unused {object_type.replace('_', ' ').lower()}s were purged.")
if purged == 0:
return
tool.Debug.refresh_ui_after_purge_merge(object_type)
class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.merge_identical_objects"
bl_label = "Merge Identical Objects"
bl_description = (
"Merge identical IFC objects (that match all attributes).\n"
"\n"
"SHIFT + CLICK to merge by name/identification attribute only.\n"
"Merges names with number suffix, as well (ex: foo, foo.001, foo.002)\n"
)
bl_options = {"REGISTER", "UNDO"}
object_type: bpy.props.EnumProperty(
name="Object Type",
items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)),
)
by_name_or_identification_only: bpy.props.BoolProperty(
name="By Name/Identification Only",
description="Merge based only on Name or Identification attribute, ignoring other properties",
default=False,
)
if TYPE_CHECKING:
object_type: tool.Debug.PurgeMergeObjectType
def invoke(self, context, event):
# Check if shift key is pressed
if event.shift:
self.by_name_or_identification_only = True
else:
self.by_name_or_identification_only = False
return self.execute(context)
def _execute(self, context):
object_type: str = self.object_type
if object_type in ("PROFILE", "TYPE"):
self.report({"ERROR"}, f"Unsupported object type {object_type}.")
return {"CANCELLED"}
merged_data = tool.Debug.merge_identical_objects(
object_type, by_name_or_identification_only=self.by_name_or_identification_only
)
plural_object_type = f"{object_type.lower().replace('_', ' ')}s"
if merged_data:
merge_mode = " by name/identification" if self.by_name_or_identification_only else ""
for element_type, element_names in merged_data.items():
print(f"- {element_type}:")
for name in element_names:
name = name or "Unnamed"
print(f" - '{name}'")
merged = sum(len(v) for v in merged_data.values())
msg = " See system console for details." if merged else ""
merge_mode = " (by name/identification)" if self.by_name_or_identification_only else ""
self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged{merge_mode}.{msg}")
if merged == 0:
return
tool.Debug.refresh_ui_after_purge_merge(object_type)
class PipInstall(bpy.types.Operator):
bl_idname = "bim.pip_install"
bl_label = "Pip Install"
bl_description = "Installs a package from PyPI"
name: bpy.props.StringProperty()
@classmethod
def description(cls, context, properties):
return f"Installs a package from PyPI: '{properties.name}'."
def execute(self, context):
blender_path = Path(bpy.app.binary_path).parent
target = next(
path for p in sys.path if (path := Path(p)).name == "site-packages" and path.is_relative_to(blender_path)
).__str__()
py_exec = str(sys.executable)
print("Detected executable:", py_exec)
print("Installing to:", target)
subprocess.call([py_exec, "-m", "ensurepip", "--user"])
subprocess.call([py_exec, "-m", "pip", "install", "--upgrade", "pip"])
# Trusted host to make things simpler due to some enterprise network restrictions
return_code = subprocess.call(
[
py_exec,
"-m",
"pip",
"install",
f"--target={target}",
"--upgrade",
self.name,
"--trusted-host",
"pypi.org",
"--trusted-host",
"files.pythonhosted.org",
]
)
if return_code == 0:
self.report({"INFO"}, f"'{self.name}' was successfully installed.")
else:
self.report(
{"ERROR"},
f"Error installing '{self.name}', see system console for details (return code '{return_code}').",
)
return {"FINISHED"}
class DebugActiveDrawing(bpy.types.Operator):
bl_idname = "bim.debug_active_drawing"
bl_label = "Search Active Drawing For Failing Elements"
bl_description = (
"Will iterate over all visible drawing's objects, trying to narrow down the list of possible failing objects"
)
def execute(self, context: bpy.types.Context):
ifc_file = tool.Ifc.get()
props = tool.Drawing.get_document_props()
drawing_item = props.drawings[props.active_drawing_index]
drawing = tool.Ifc.get().by_id(drawing_item.ifc_definition_id)
GREEN = "\033[92m"
CYAN = "\033[96m"
END = "\033[0m"
ATTEMPS = 10
# run create drawing with sync for once
# to make sure everything is actually in sync
try:
bpy.ops.bim.create_drawing(sync=True)
except:
pass
else:
self.report({"INFO"}, "No errors creating drawing, nothing to investigate.")
return {"FINISHED"}
all_elements = [e for obj in context.visible_objects if (e := tool.Ifc.get_entity(obj))]
all_elements = set(all_elements)
original_exclude = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "Exclude")
pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing")
def drawing_fails_to_load(chunk_to_include: set[ifcopenshell.entity_instance]) -> bool:
current_elements = all_elements - chunk_to_include
excluded_guids = ", ".join([e.GlobalId for e in current_elements if hasattr(e, "GlobalId")])
new_exclude = "" if not original_exclude else f"{original_exclude}, "
new_exclude += excluded_guids
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Exclude": new_exclude})
try:
bpy.ops.bim.create_drawing(sync=False)
result = False
except Exception as e:
# print(e)
result = True
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Exclude": original_exclude})
return result
def test_elements(elements: list[ifcopenshell.entity_instance], attempts: int = ATTEMPS) -> None:
print(f"{CYAN}processing {len(elements)} elements{END}")
if not elements:
print(f"Empty list of elements, will stop...")
return
n_elements = len(elements)
middle = int(n_elements / 2)
chunk1, chunk2 = elements[:middle], elements[middle:]
test_chunk_1 = drawing_fails_to_load(set(chunk1))
test_chunk_2 = drawing_fails_to_load(set(chunk2))
if (
# both chunks do not fail anymore
(not test_chunk_1 and not test_chunk_2)
# or we have 1 element chunk that is still failing
or (test_chunk_1 and not chunk2)
or (test_chunk_2 and not chunk1)
):
if attempts == 0 or n_elements in (1, 2):
print(f"{GREEN}Couldn't narrow it down any further.{END}")
print(f"It's some of the {n_elements} elements:")
print(elements)
print("Let's test excluding them...")
for element in elements:
test = drawing_fails_to_load(all_elements - {element})
if test:
print(f"{CYAN}Excluding element didn't fixed the drawing: {END}")
print(element)
else:
print(f"{GREEN}Excluding element fixed the drawing: {END}")
print(element)
else:
print(f"{CYAN}Will try to reshuffle elements and try again, attempt {ATTEMPS-attempts+1}/{ATTEMPS}")
attempts -= 1
random.shuffle(elements)
test_elements(elements, attempts)
return
# if chunk fails we need to investigate it further
if test_chunk_1:
test_elements(chunk1)
if test_chunk_2:
test_elements(chunk2)
test_elements(list(all_elements))
self.report({"INFO"}, "See system console for the results")
return {"FINISHED"}
class ToggleDetailedIOSLogs(bpy.types.Operator):
bl_idname = "bim.toggle_detailed_ios_logs"
bl_label = "Toggle Detailed IfcOpenShell Logs"
bl_options = {"REGISTER"}
bl_description = (
"Turn on detailed IfcOpenShell logs in the system console.\n"
+ "Could be useful debugging issues "
+ "loading IFC representations / serializing IFC geometry."
+ "\n\nALT+CLICK to disable the logs"
)
# NOTE: No idea if it's possible to check from Python
# whether detailed logs are currently enabled or not.
# Therefore we just distinguish between click
# and alt-click to allow enabling/disabling the logs.
turn_on_logs: bpy.props.BoolProperty(name="Turn On Logs", default=True, options={"SKIP_SAVE"})
def invoke(self, context, event):
if event.type == "LEFTMOUSE" and event.alt:
self.turn_on_logs = False
return self.execute(context)
def execute(self, context):
import ifcopenshell.ifcopenshell_wrapper as wrapper
if self.turn_on_logs:
wrapper.turn_on_detailed_logging()
self.report({"INFO"}, "Detailed IfcOpenShell logs turned on.")
else:
wrapper.turn_off_detailed_logging()
self.report({"INFO"}, "Detailed IfcOpenShell logs turned off.")
return {"FINISHED"}
LogLevelType = Literal["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
class ChangeLogLevel(bpy.types.Operator):
bl_idname = "bim.change_log_level"
bl_label = "Change Log Level "
bl_options = {"REGISTER"}
bl_description = "Change general log level across all Python code in Blender"
log_level: bpy.props.EnumProperty(
name="Log Level",
items=[(i, i, "") for i in get_args(LogLevelType)],
default="WARNING",
)
if TYPE_CHECKING:
log_level: LogLevelType
def execute(self, context):
root = logging.getLogger()
root.setLevel(self.log_level)
self.report({"INFO"}, f"Log level changed to {self.log_level}.")
return {"FINISHED"}
class RestartBlender(bpy.types.Operator):
bl_idname = "bim.restart_blender"
bl_label = "Restart Blender"
bl_description = "Blender will be immediately restarted, save your data first before running this operator"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# Save preferences manually since we're restarting Blender using .execv
# and it doens't have a chance to save them on exit.
assert context.preferences
if context.preferences.use_preferences_save:
bpy.ops.wm.save_userpref()
ms_store_app_id = tool.Blender.get_microsoft_store_app_id()
if not ms_store_app_id:
path = bpy.app.binary_path
if platform.system() == "Windows":
args = sys.argv[1:]
command_line = subprocess.list2cmdline([path] + args)
os.execv(path, [command_line])
else:
os.execv(path, sys.argv)
else:
# Microsoft apps do not allow launching blender.exe directly
# since Blender folder is kind of private.
cmd_exe = Path(os.environ["SystemRoot"]) / "system32" / "cmd.exe"
blender_app = f"shell:AppsFolder\\BlenderFoundation.Blender_{ms_store_app_id}!BLENDER"
cmd_args = ["/c", "start", blender_app] + sys.argv[1:]
os.execv(cmd_exe, cmd_args)