mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-14 11:24:19 +00:00
be3c2ee770
Generated with the assistance of an AI coding tool.
1110 lines
43 KiB
Python
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)
|