Files
IfcOpenShell/src/bonsai/bonsai/bim/module/project/operator.py
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

3406 lines
135 KiB
Python
Raw Normal View History

# Bonsai - OpenBIM Blender Add-on
2021-08-17 08:55:29 +10:00
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
2021-08-17 08:55:29 +10:00
#
# Bonsai is free software: you can redistribute it and/or modify
2021-08-17 08:55:29 +10:00
# 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,
2021-08-17 08:55:29 +10:00
# 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/>.
2021-08-17 08:55:29 +10:00
2026-01-26 17:11:12 +05:00
import datetime
import json
import logging
2026-01-26 17:11:12 +05:00
import os
import subprocess
import tempfile
2026-01-26 17:11:12 +05:00
import time
import traceback
2026-01-26 17:11:12 +05:00
from collections import defaultdict
from math import radians
from pathlib import Path
2026-03-05 15:26:27 +05:00
from typing import TYPE_CHECKING, Literal, Union, get_args
2026-01-26 17:11:12 +05:00
import bpy
import ifcopenshell
2026-01-26 17:11:12 +05:00
import ifcopenshell.api.attribute
2026-03-03 14:34:05 +05:00
import ifcopenshell.api.document
import ifcopenshell.api.nest
import ifcopenshell.api.project
import ifcopenshell.api.root
2024-10-14 14:31:26 +05:00
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
2026-01-26 17:11:12 +05:00
import ifcopenshell.util.element
import ifcopenshell.util.file
import ifcopenshell.util.geolocation
import ifcopenshell.util.representation
2026-01-26 17:11:12 +05:00
import ifcopenshell.util.selector
import ifcopenshell.util.shape
import ifcopenshell.util.shape_builder
2024-07-02 11:40:38 +05:00
import ifcopenshell.util.unit
2026-01-26 17:11:12 +05:00
import numpy as np
from bpy.app.handlers import persistent
from bpy_extras.io_utils import ExportHelper, ImportHelper
2026-03-05 15:26:27 +05:00
from bpy_extras.view3d_utils import (
region_2d_to_origin_3d,
region_2d_to_vector_3d,
)
from mathutils import Vector
2026-01-26 17:11:12 +05:00
2024-08-14 14:10:36 +05:00
import bonsai.bim.handler
2025-02-03 17:40:46 +05:00
import bonsai.bim.helper
2024-08-14 14:10:36 +05:00
import bonsai.core.project as core
2026-01-26 17:11:12 +05:00
import bonsai.tool as tool
from bonsai.bim import export_ifc, import_ifc
2024-08-14 14:10:36 +05:00
from bonsai.bim.ifc import IfcStore
2026-01-26 17:11:12 +05:00
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
2026-01-26 17:11:12 +05:00
from bonsai.bim.module.project.decorator import (
ClippingPlaneDecorator,
MeasureDecorator,
ProjectDecorator,
)
2025-02-14 17:12:03 +05:00
from bonsai.bim.module.project.prop import BreadcrumbType
2026-01-26 17:11:12 +05:00
from bonsai.bim.ui import IFCFileSelector
2025-02-28 11:08:42 +05:00
if TYPE_CHECKING:
2025-06-25 10:19:42 +05:00
import bpy.stub_internal.rna_enums as rna_enums
2026-01-26 17:11:12 +05:00
2025-02-28 11:08:42 +05:00
from bonsai.bim.module.project.prop import Link
2025-06-25 10:19:42 +05:00
PresetType = Literal["metric_m", "metric_mm", "imperial_ft", "demo", "wizard"]
class NewProject(bpy.types.Operator):
bl_idname = "bim.new_project"
bl_label = "New Project"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Start a new IFC project in a fresh session"
2026-04-10 18:02:44 +05:00
preset: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(PresetType)])
2025-06-25 10:19:42 +05:00
if TYPE_CHECKING:
preset: PresetType
2025-06-25 10:19:42 +05:00
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
bpy.ops.wm.read_homefile()
2025-02-19 11:06:03 +05:00
pprops = tool.Project.get_project_props()
2025-02-20 14:50:55 +05:00
bim_props = tool.Blender.get_bim_props()
2025-06-25 10:19:42 +05:00
assert bpy.context.scene
if self.preset == "metric_m":
2025-02-19 11:06:03 +05:00
pprops.export_schema = "IFC4"
bpy.context.scene.unit_settings.system = "METRIC"
bpy.context.scene.unit_settings.length_unit = "METERS"
2025-02-20 14:50:55 +05:00
bim_props.area_unit = "SQUARE_METRE"
bim_props.volume_unit = "CUBIC_METRE"
2025-02-19 11:06:03 +05:00
pprops.template_file = "0"
elif self.preset == "metric_mm":
2025-02-19 11:06:03 +05:00
pprops.export_schema = "IFC4"
bpy.context.scene.unit_settings.system = "METRIC"
bpy.context.scene.unit_settings.length_unit = "MILLIMETERS"
2025-02-20 14:50:55 +05:00
bim_props.area_unit = "SQUARE_METRE"
bim_props.volume_unit = "CUBIC_METRE"
2025-02-19 11:06:03 +05:00
pprops.template_file = "0"
elif self.preset == "imperial_ft":
2025-02-19 11:06:03 +05:00
pprops.export_schema = "IFC4"
bpy.context.scene.unit_settings.system = "IMPERIAL"
bpy.context.scene.unit_settings.length_unit = "FEET"
2025-02-20 14:50:55 +05:00
bim_props.area_unit = "square foot"
bim_props.volume_unit = "cubic foot"
2025-02-19 11:06:03 +05:00
pprops.template_file = "0"
elif self.preset == "demo":
2025-02-19 11:06:03 +05:00
pprops.export_schema = "IFC4"
bpy.context.scene.unit_settings.system = "METRIC"
bpy.context.scene.unit_settings.length_unit = "MILLIMETERS"
2025-02-20 14:50:55 +05:00
bim_props.area_unit = "SQUARE_METRE"
bim_props.volume_unit = "CUBIC_METRE"
2025-02-19 11:06:03 +05:00
pprops.template_file = "IFC4 Demo Template.ifc"
if self.preset != "wizard":
bpy.ops.bim.create_project()
return {"FINISHED"}
class CreateProject(bpy.types.Operator):
bl_idname = "bim.create_project"
bl_label = "Create Project"
bl_options = {"REGISTER", "UNDO"}
2021-10-13 00:03:36 +02:00
bl_description = "Create a new IFC project"
def execute(self, context):
IfcStore.begin_transaction(self)
IfcStore.add_transaction_operation(self, rollback=self.rollback, commit=lambda data: True)
self._execute(context)
self.transaction_data = {"file": tool.Ifc.get()}
IfcStore.add_transaction_operation(self, rollback=lambda data: True, commit=self.commit)
IfcStore.end_transaction(self)
return {"FINISHED"}
def _execute(self, context):
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
template = None if props.template_file == "0" else props.template_file
if tool.Blender.is_default_scene():
for obj in bpy.data.objects:
bpy.data.objects.remove(obj)
for mesh in bpy.data.meshes:
bpy.data.meshes.remove(mesh)
for mat in bpy.data.materials:
bpy.data.materials.remove(mat)
2024-07-26 12:19:21 +10:00
core.create_project(
tool.Ifc, tool.Georeference, tool.Project, tool.Spatial, schema=props.export_schema, template=template
)
tool.Blender.register_toolbar()
tool.Loader.set_unit_scale(ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()))
def rollback(self, data):
IfcStore.file = None
def commit(self, data):
IfcStore.file = data["file"]
class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_idname = "bim.select_library_file"
bl_label = "Select Library File"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Select an IFC file that can be used as a library.\n\nALT+click to reload the current loaded library file."
)
2026-04-10 18:02:44 +05:00
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
append_all: bpy.props.BoolProperty(default=False)
2026-04-10 18:02:44 +05:00
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
filter_glob: str
append_all: bool
use_relative_path: bool
reload_previous_file = False
def invoke(self, context, event):
if event.alt:
old_filepath = IfcStore.library_path
if not old_filepath:
self.report({"ERROR"}, "No library file loaded to reload.")
return {"CANCELLED"}
self.filepath = old_filepath
self.reload_previous_file = True
return self.execute(context)
return ImportHelper.invoke(self, context, event)
def execute(self, context):
IfcStore.begin_transaction(self)
old_filepath = IfcStore.library_path
result = self._execute(context)
self.transaction_data = {"old_filepath": old_filepath, "filepath": self.get_filepath()}
IfcStore.add_transaction_operation(self)
IfcStore.end_transaction(self)
return result
def _execute(self, context):
filepath = self.get_filepath()
ifc_file = tool.Ifc.get()
2025-06-09 10:59:22 +05:00
library_file: ifcopenshell.file
library_file = ifcopenshell.open(filepath)
if library_file.schema_identifier != ifc_file.schema_identifier:
self.report(
{"ERROR"},
f"Schema of library file ({library_file.schema_identifier}) is not compatible with the current IFC file ({ifc_file.schema_identifier}).",
)
return {"CANCELLED"}
IfcStore.library_path = filepath
IfcStore.library_file = library_file
bpy.ops.bim.refresh_library()
if context.area:
context.area.tag_redraw()
2022-07-11 11:07:40 +02:00
if self.append_all:
bpy.ops.bim.append_entire_library()
2025-02-14 17:12:03 +05:00
ProjectLibraryData.load()
self.report({"INFO"}, f"Loaded library from {filepath}.")
return {"FINISHED"}
def rollback(self, data):
if data["old_filepath"]:
IfcStore.library_path = data["old_filepath"]
IfcStore.library_file = ifcopenshell.open(data["old_filepath"])
else:
IfcStore.library_path = ""
IfcStore.library_file = None
def commit(self, data):
IfcStore.library_path = data["filepath"]
IfcStore.library_file = ifcopenshell.open(data["filepath"])
2022-07-11 11:07:40 +02:00
def draw(self, context):
self.layout.prop(self, "append_all", text="Append Entire Library")
IFCFileSelector.draw(self, context)
class RefreshLibrary(bpy.types.Operator):
bl_idname = "bim.refresh_library"
bl_label = "Refresh Library"
2025-08-28 18:17:13 +05:00
bl_description = "Refresh the library browser"
2025-02-13 15:59:55 +05:00
bl_options = {"UNDO"}
def execute(self, context):
2025-01-29 12:40:03 +05:00
self.props = tool.Project.get_project_props()
self.props.library_elements.clear()
self.props.library_breadcrumb.clear()
library_file = IfcStore.library_file
assert library_file
2025-02-14 17:12:03 +05:00
if not self.props.show_library_tree:
for appendable_type in sorted(tool.Project.get_appendable_asset_types()):
elements = library_file.by_type(appendable_type)
self.props.add_library_asset_class(appendable_type, len(elements))
return {"FINISHED"}
2025-02-14 17:12:03 +05:00
# Library tree.
# Add entry for unassigned elements.
elements = set()
for importable_type in sorted(tool.Project.get_appendable_asset_types()):
elements.update(library_file.by_type(importable_type))
rels = tool.Project.get_project_library_rels(library_file)
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
2025-02-14 17:12:03 +05:00
ifc_project = library_file.by_type("IfcProject")[0]
hierarchy = tool.Project.get_project_hierarchy(library_file)
tool.Project.load_project_libraries_to_ui(ifc_project, hierarchy)
return {"FINISHED"}
class ChangeLibraryElement(bpy.types.Operator):
bl_idname = "bim.change_library_element"
bl_label = "Change Library Element"
bl_options = {"REGISTER", "UNDO"}
2025-02-14 17:12:03 +05:00
element_name: bpy.props.StringProperty()
breadcrumb_type: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(BreadcrumbType)])
library_id: bpy.props.IntProperty()
2025-01-29 12:40:03 +05:00
if TYPE_CHECKING:
element_name: str
2025-02-14 17:12:03 +05:00
breadcrumb_type: BreadcrumbType
library_id: int
2025-01-29 12:40:03 +05:00
def execute(self, context):
2025-01-29 12:40:03 +05:00
self.props = tool.Project.get_project_props()
self.file = tool.Ifc.get()
library_file = IfcStore.library_file
assert library_file
self.library_file = library_file
crumb = self.props.library_breadcrumb.add()
crumb.name = self.element_name
2025-02-14 17:12:03 +05:00
crumb.breadcrumb_type = self.breadcrumb_type
if self.breadcrumb_type == "LIBRARY":
crumb.library_id = self.library_id
active_project_library = None
library_elements = None
project_library_rels = None
# Reverse to get last library in hierarchy.
for entry in reversed(self.props.library_breadcrumb):
if entry.breadcrumb_type == "LIBRARY":
if entry.library_id == 0:
# For unassigned elements.
active_project_library = "NO_LIBRARY"
project_library_rels = tool.Project.get_project_library_rels(library_file)
else:
active_project_library = library_file.by_id(entry.library_id)
library_elements = tool.Project.get_project_library_elements(active_project_library)
break
def filter_elements(elements: list[ifcopenshell.entity_instance]) -> list[ifcopenshell.entity_instance]:
if active_project_library is None:
return elements
elif active_project_library == "NO_LIBRARY":
assert project_library_rels is not None
return [
element
for element in elements
if not tool.Project.is_element_assigned_to_project_library(element, project_library_rels)
]
else:
assert library_elements is not None
return [e for e in elements if e in library_elements]
self.props.library_elements.clear()
2025-02-14 17:12:03 +05:00
if self.breadcrumb_type == "LIBRARY":
hierarchy = tool.Project.get_project_hierarchy(library_file)
assert active_project_library is not None
if active_project_library != "NO_LIBRARY" and hierarchy[active_project_library]:
2025-02-14 17:12:03 +05:00
tool.Project.load_project_libraries_to_ui(active_project_library, hierarchy)
for appendable_type in sorted(tool.Project.get_appendable_asset_types()):
elements = library_file.by_type(appendable_type)
if elements := filter_elements(elements):
self.props.add_library_asset_class(appendable_type, len(elements))
2025-02-14 17:12:03 +05:00
else: # breadcrumb_type CLASS.
elements = self.library_file.by_type(self.element_name)
elements = list(filter_elements(elements))
ifc_classes_elements: dict[str, list[ifcopenshell.entity_instance]] = defaultdict(list)
for element in elements:
ifc_classes_elements[element.is_a()].append(element)
if len(ifc_classes_elements) == 1 and next(iter(ifc_classes_elements)) == self.element_name:
2025-02-14 17:12:03 +05:00
for name, ifc_definition_id in sorted(
[(self.get_name(e), e.id()) for e in ifc_classes_elements[self.element_name]]
):
self.add_library_asset(name, ifc_definition_id)
else:
for ifc_class in sorted(ifc_classes_elements):
if ifc_class == self.element_name:
continue
self.props.add_library_asset_class(ifc_class, len(ifc_classes_elements[ifc_class]))
elements_ = ifc_classes_elements[self.element_name]
for name, ifc_definition_id, ifc_class in sorted(
[(self.get_name(e), e.id(), e.is_a()) for e in elements_]
):
self.add_library_asset(name, ifc_definition_id)
# Could occur if all elements were assigned to a different library.
if len(self.props.library_elements) == 0:
bpy.ops.bim.rewind_library()
return {"FINISHED"}
2024-08-15 13:34:29 +05:00
def get_name(self, element: ifcopenshell.entity_instance) -> str:
attr_name = tool.Project.get_library_element_attr_name(element)
return getattr(element, attr_name) or "Unnamed"
2024-08-15 13:34:29 +05:00
def add_library_asset(self, name: str, ifc_definition_id: int) -> None:
new = self.props.library_elements.add()
new["name"] = name
new.ifc_definition_id = ifc_definition_id
element = self.library_file.by_id(ifc_definition_id)
# is_declarable.
project_libraries_exist = bool(
self.library_file.schema != "IFC2X3" and self.library_file.by_type("IfcProjectLibrary")
)
is_declarable = project_libraries_exist and element.is_a("IfcObjectDefinition")
new.is_declarable = is_declarable
selected_library = self.props.selected_project_library
# is_declared.
if not is_declarable:
new.is_declared = False
elif has_context := element.HasContext:
relating_context: ifcopenshell.entity_instance
relating_context = has_context[0].RelatingContext
2025-02-14 17:12:03 +05:00
new.is_declared = relating_context == self.library_file.by_id(int(selected_library))
# is_appended.
try:
if element.is_a("IfcMaterial"):
next(e for e in self.file.by_type("IfcMaterial") if e.Name == name)
elif element.is_a("IfcProfileDef"):
next(e for e in self.file.by_type("IfcProfileDef") if e.ProfileName == name)
elif element.is_a("IfcPresentationStyle"):
next(e for e in self.file.by_type(element.is_a()) if e.Name == name)
else:
self.file.by_guid(element.GlobalId)
new.is_appended = True
except (AttributeError, RuntimeError, StopIteration):
new.is_appended = False
class RewindLibrary(bpy.types.Operator):
bl_idname = "bim.rewind_library"
bl_label = "Rewind Library"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
2025-01-31 12:42:47 +05:00
self.props = tool.Project.get_project_props()
total_breadcrumbs = len(self.props.library_breadcrumb)
if total_breadcrumbs < 2:
bpy.ops.bim.refresh_library()
return {"FINISHED"}
2025-02-14 17:12:03 +05:00
current_element = self.props.library_breadcrumb[total_breadcrumbs - 2]
element_name = current_element.name
breadcrumb_type = current_element.breadcrumb_type
library_id = current_element.library_id
self.props.library_breadcrumb.remove(total_breadcrumbs - 1)
self.props.library_breadcrumb.remove(total_breadcrumbs - 2)
2025-02-14 17:12:03 +05:00
bpy.ops.bim.change_library_element(
element_name=element_name,
breadcrumb_type=breadcrumb_type,
library_id=library_id,
)
return {"FINISHED"}
class AssignLibraryDeclaration(bpy.types.Operator):
bl_idname = "bim.assign_library_declaration"
bl_label = "Assign Library Declaration"
bl_description = "Assign element to the active library. If no specific library selected, will assign it to the first library in the file."
bl_options = {"REGISTER", "UNDO"}
definition: bpy.props.IntProperty()
if TYPE_CHECKING:
definition: int
def execute(self, context):
IfcStore.begin_transaction(self)
IfcStore.library_file.begin_transaction()
result = self._execute(context)
IfcStore.library_file.end_transaction()
IfcStore.add_transaction_operation(self)
IfcStore.end_transaction(self)
return result
def _execute(self, context):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
2025-02-14 17:12:03 +05:00
project_library = library_file.by_id(int(props.selected_project_library))
ifcopenshell.api.project.assign_declaration(
library_file,
definitions=[library_file.by_id(self.definition)],
relating_context=project_library,
)
tool.Project.update_current_library_page()
return {"FINISHED"}
def rollback(self, data):
IfcStore.library_file.undo()
def commit(self, data):
IfcStore.library_file.redo()
class UnassignLibraryDeclaration(bpy.types.Operator):
bl_idname = "bim.unassign_library_declaration"
bl_label = "Unassign Library Declaration"
bl_options = {"REGISTER", "UNDO"}
definition: bpy.props.IntProperty()
if TYPE_CHECKING:
definition: int
def execute(self, context):
IfcStore.begin_transaction(self)
IfcStore.library_file.begin_transaction()
result = self._execute(context)
IfcStore.library_file.end_transaction()
IfcStore.add_transaction_operation(self)
IfcStore.end_transaction(self)
return result
def _execute(self, context):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
element = library_file.by_id(self.definition)
ifcopenshell.api.project.unassign_declaration(
library_file,
definitions=[library_file.by_id(self.definition)],
relating_context=element.HasContext[0].RelatingContext,
)
tool.Project.update_current_library_page()
return {"FINISHED"}
def rollback(self, data):
IfcStore.library_file.undo()
def commit(self, data):
IfcStore.library_file.redo()
class SaveLibraryFile(bpy.types.Operator):
bl_idname = "bim.save_library_file"
bl_label = "Save Library File"
def execute(self, context):
IfcStore.library_file.write(IfcStore.library_path)
self.report({"INFO"}, f"Library saved to {IfcStore.library_path}")
return {"FINISHED"}
2024-09-06 17:45:18 +05:00
class AppendEntireLibrary(bpy.types.Operator, tool.Ifc.Operator):
2022-07-11 11:07:40 +02:00
bl_idname = "bim.append_entire_library"
bl_label = "Append Entire Library"
2022-07-11 11:07:40 +02:00
@classmethod
def poll(cls, context):
return tool.Ifc.get()
2022-07-11 11:07:40 +02:00
def _execute(self, context):
self.file = tool.Ifc.get()
2022-07-11 11:07:40 +02:00
self.library = IfcStore.library_file
query = ", ".join(tool.Project.get_appendable_asset_types())
lib_elements = ifcopenshell.util.selector.filter_elements(self.library, query)
2022-07-11 11:07:40 +02:00
for element in lib_elements:
bpy.ops.bim.append_library_element(definition=element.id())
2022-07-11 11:07:40 +02:00
return {"FINISHED"}
2024-09-06 17:45:18 +05:00
class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.append_library_element_by_query"
bl_label = "Append Library Element By Query"
2026-03-16 14:07:50 +05:00
query: bpy.props.StringProperty(name="Query")
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
query: str
@classmethod
def poll(cls, context):
return tool.Ifc.get()
def _execute(self, context):
self.file = tool.Ifc.get()
self.library = IfcStore.library_file
for element in ifcopenshell.util.selector.filter_elements(self.library, self.query):
bpy.ops.bim.append_library_element(definition=element.id())
return {"FINISHED"}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
self.layout.prop(self, "query")
2024-09-06 17:45:18 +05:00
class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.append_library_element"
bl_label = "Append Library Element"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Append element to the current project.\n\n"
"ALT+CLICK to skip reusing materials, profiles, styles based on their name (may result in duplicates)"
)
definition: bpy.props.IntProperty()
prop_index: bpy.props.IntProperty()
2026-04-10 18:02:44 +05:00
assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"})
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
definition: int
prop_index: int
assume_unique_by_name: bool
file: ifcopenshell.file
@classmethod
def poll(cls, context):
poll = bool(tool.Ifc.get())
2025-01-22 12:55:16 +05:00
if not poll:
cls.poll_message_set("Please create or load a project first.")
return poll
def invoke(self, context, event):
if event.type == "LEFTMOUSE" and event.alt:
self.assume_unique_by_name = False
return self.execute(context)
def _execute(self, context):
self.file = tool.Ifc.get()
library_file = IfcStore.library_file
assert library_file
element = ifcopenshell.api.project.append_asset(
self.file,
library=library_file,
element=library_file.by_id(self.definition),
assume_asset_uniqueness_by_name=self.assume_unique_by_name,
)
if not element:
return {"FINISHED"}
if element.is_a("IfcTypeProduct"):
self.import_type_from_ifc(element, context)
elif element.is_a("IfcProduct"):
# NOTE: Non-types are not exposed in UI directly
# but the code is still used when appending products by query.
self.import_product_from_ifc(element, context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type is not None and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
elif element.is_a("IfcMaterial"):
self.import_material_from_ifc(element, context)
elif element.is_a("IfcSurfaceStyle"):
self.import_presentation_style_from_ifc(element, context)
else:
# E.g. other IfcPresentationStyles.
pass
try:
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
props.library_elements[self.prop_index].is_appended = True
except:
# TODO Remove this terrible code when I refactor this into the core
pass
2024-08-14 14:10:36 +05:00
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
2024-03-19 16:23:42 +05:00
def import_material_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None:
self.file = tool.Ifc.get()
logger = logging.getLogger("ImportIFC")
ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger)
ifc_importer = import_ifc.IfcImporter(ifc_import_settings)
ifc_importer.file = self.file
self.import_material_styles(element, ifc_importer)
def import_presentation_style_from_ifc(
self, style: ifcopenshell.entity_instance, context: bpy.types.Context
) -> None:
self.file = tool.Ifc.get()
logger = logging.getLogger("ImportIFC")
ifc_import_settings = import_ifc.IfcImportSettings.factory(context, tool.Ifc.get_path(), logger)
ifc_importer = import_ifc.IfcImporter(ifc_import_settings)
ifc_importer.file = self.file
ifc_importer.create_style(style)
2024-03-19 16:23:42 +05:00
def import_product_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None:
self.file = tool.Ifc.get()
logger = logging.getLogger("ImportIFC")
ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger)
ifc_importer = import_ifc.IfcImporter(ifc_import_settings)
ifc_importer.file = self.file
ifc_importer.process_context_filter()
ifc_importer.material_creator.load_existing_materials()
self.import_materials(element, ifc_importer)
self.import_styles(element, ifc_importer)
ifc_importer.create_generic_elements({element})
ifc_importer.place_objects_in_collections()
2024-03-19 16:23:42 +05:00
def import_type_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None:
self.file = tool.Ifc.get()
logger = logging.getLogger("ImportIFC")
ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger)
ifc_importer = import_ifc.IfcImporter(ifc_import_settings)
ifc_importer.file = self.file
2023-10-10 22:21:32 +11:00
ifc_importer.process_context_filter()
ifc_importer.material_creator.load_existing_materials()
self.import_materials(element, ifc_importer)
self.import_styles(element, ifc_importer)
ifc_importer.create_element_type(element)
ifc_importer.place_objects_in_collections()
2024-03-19 16:23:42 +05:00
def import_materials(self, element: ifcopenshell.entity_instance, ifc_importer: import_ifc.IfcImporter) -> None:
for material in ifcopenshell.util.element.get_materials(element):
if tool.Ifc.get_object_by_identifier(material.id()):
continue
self.import_material_styles(material, ifc_importer)
2024-03-19 16:23:42 +05:00
def import_styles(self, element: ifcopenshell.entity_instance, ifc_importer: import_ifc.IfcImporter) -> None:
if element.is_a("IfcTypeProduct"):
representations = element.RepresentationMaps or []
elif element.is_a("IfcProduct"):
representations = [element.Representation] if element.Representation else []
for representation in representations or []:
for element in self.file.traverse(representation):
if not element.is_a("IfcRepresentationItem") or not element.StyledByItem:
continue
for element2 in self.file.traverse(element.StyledByItem[0]):
if element2.is_a("IfcSurfaceStyle") and not tool.Ifc.get_object_by_identifier(element2.id()):
ifc_importer.create_style(element2)
2024-03-19 16:23:42 +05:00
def import_material_styles(
self,
material: ifcopenshell.entity_instance,
ifc_importer: import_ifc.IfcImporter,
) -> None:
if not material.HasRepresentation:
return
for element in self.file.traverse(material.HasRepresentation[0]):
if element.is_a("IfcSurfaceStyle") and not tool.Ifc.get_object_by_identifier(element.id()):
ifc_importer.create_style(element)
2025-01-31 13:19:42 +05:00
class EditProjectLibrary(bpy.types.Operator):
bl_idname = "bim.edit_project_library"
bl_label = "Edit Project Library"
bl_description = "Apply changes for currently edited library."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
IfcStore.begin_transaction(self)
library_file = IfcStore.library_file
assert library_file
library_file.begin_transaction()
result = self._execute(context)
library_file.end_transaction()
IfcStore.add_transaction_operation(self)
IfcStore.end_transaction(self)
return result
def _execute(self, context):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
project_library = library_file.by_id(props.editing_project_library_id)
attributes = bonsai.bim.helper.export_attributes(props.project_library_attributes)
ifcopenshell.api.attribute.edit_attributes(library_file, project_library, attributes)
# Update parent library.
previous_parent_library = tool.Project.get_parent_library(project_library)
new_parent_library = library_file.by_id(int(props.parent_library))
if previous_parent_library != new_parent_library:
if previous_parent_library.is_a("IfcProject"):
# Then new one is IfcProjectLibrary.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
else: # Previous is IfcProjectLibrary.
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
# If new one is IfcProject, then it's already assigned by default.
if new_parent_library.is_a("IfcProjectLibrary"):
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
2025-01-31 13:19:42 +05:00
props.is_editing_project_library = False
2025-02-14 17:12:03 +05:00
bpy.ops.bim.refresh_library()
2025-01-31 13:19:42 +05:00
return {"FINISHED"}
def rollback(self, data):
IfcStore.library_file.undo()
def commit(self, data):
IfcStore.library_file.redo()
class AddProjectLibrary(bpy.types.Operator):
bl_idname = "bim.add_project_library"
bl_label = "Add Project Library"
bl_description = "Add new IfcProjectLibrary to the currently selected library."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
IfcStore.begin_transaction(self)
library_file = IfcStore.library_file
assert library_file
library_file.begin_transaction()
result = self._execute(context)
library_file.end_transaction()
IfcStore.add_transaction_operation(self)
IfcStore.end_transaction(self)
return result
def _execute(self, context):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
project = library_file.by_type("IfcProject")[0]
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
ifcopenshell.api.project.assign_declaration(library_file, [project_library], project)
ProjectLibraryData.load() # Update enum.
props.selected_project_library = str(project_library.id())
props.is_editing_project_library = True
return {"FINISHED"}
def rollback(self, data):
IfcStore.library_file.undo()
def commit(self, data):
IfcStore.library_file.redo()
2025-08-28 17:04:21 +05:00
class RemoveProjectLibrary(bpy.types.Operator):
bl_idname = "bim.remove_project_library"
bl_label = "Remove Project Library"
bl_description = "Remove the currently selected IfcProjectLibrary."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
IfcStore.begin_transaction(self)
library_file = IfcStore.library_file
assert library_file
library_file.begin_transaction()
result = self._execute(context)
library_file.end_transaction()
IfcStore.add_transaction_operation(self)
IfcStore.end_transaction(self)
return result
def _execute(self, context):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
project_library = library_file.by_id(int(props.selected_project_library))
tool.Project.remove_project_library(project_library)
ProjectLibraryData.load() # Update enum.
enum_len = len(ProjectLibraryData.data["project_libraries_enum"])
if props.is_property_set("selected_project_library"):
props["selected_project_library"] = min(enum_len - 1, props["selected_project_library"])
bpy.ops.bim.refresh_library()
return {"FINISHED"}
def rollback(self, data):
IfcStore.library_file.undo()
def commit(self, data):
IfcStore.library_file.redo()
class EnableEditingHeader(bpy.types.Operator):
bl_idname = "bim.enable_editing_header"
bl_label = "Enable Editing Header"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Edit the IFC header file such as Author, Organization, ..."
@classmethod
def poll(cls, context):
return tool.Ifc.get()
def execute(self, context):
2025-02-19 11:06:03 +05:00
self.file = tool.Ifc.get()
props = tool.Project.get_project_props()
props.is_editing = True
header_data = tool.Project.get_header_data()
props.load_header_data(header_data)
return {"FINISHED"}
class EditHeader(bpy.types.Operator):
bl_idname = "bim.edit_header"
bl_label = "Edit Header"
bl_options = {"REGISTER", "UNDO"}
2022-08-27 10:36:54 -04:00
bl_description = "Save header information"
@classmethod
def poll(cls, context):
return tool.Ifc.get()
def execute(self, context):
# NOTE: Though header entities are now generic `entity_instance`
# we still have a special undo system in place for this operator
# since general undo system tracks only elements with ids != 0.
IfcStore.begin_transaction(self)
self.transaction_data = {}
self.transaction_data["old"] = self.record_state()
result = self._execute(context)
self.transaction_data["new"] = self.record_state()
IfcStore.add_transaction_operation(self)
IfcStore.end_transaction(self)
bonsai.bim.handler.refresh_ui_data()
return result
def _execute(self, context):
2025-02-19 11:06:03 +05:00
self.file = tool.Ifc.get()
props = tool.Project.get_project_props()
props.is_editing = True
self.file.header.file_description.description = (f"ViewDefinition[{props.mvd}]",)
self.file.header.file_name.author = (props.author_name, props.author_email)
self.file.header.file_name.organization = (props.organisation_name, props.organisation_email)
self.file.header.file_name.authorization = props.authorisation
bpy.ops.bim.disable_editing_header()
return {"FINISHED"}
def record_state(self):
self.file = tool.Ifc.get()
return {
"description": self.file.header.file_description.description,
"author": self.file.header.file_name.author,
"organisation": self.file.header.file_name.organization,
"authorisation": self.file.header.file_name.authorization,
}
def rollback(self, data):
file = tool.Ifc.get()
file.header.file_description.description = data["old"]["description"]
file.header.file_name.author = data["old"]["author"]
file.header.file_name.organization = data["old"]["organisation"]
file.header.file_name.authorization = data["old"]["authorisation"]
def commit(self, data):
file = tool.Ifc.get()
file.header.file_description.description = data["new"]["description"]
file.header.file_name.author = data["new"]["author"]
file.header.file_name.organization = data["new"]["organisation"]
file.header.file_name.authorization = data["new"]["authorisation"]
class DisableEditingHeader(bpy.types.Operator):
bl_idname = "bim.disable_editing_header"
bl_label = "Disable Editing Header"
bl_options = {"REGISTER", "UNDO"}
2022-08-27 10:36:54 -04:00
bl_description = "Cancel unsaved header information"
def execute(self, context):
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
props.is_editing = False
return {"FINISHED"}
class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_idname = "bim.load_project"
bl_label = "Load Project"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load an existing IFC project"
2026-04-10 18:02:44 +05:00
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"})
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"})
is_advanced: bpy.props.BoolProperty(
name="Enable Advanced Mode",
description="Load IFC file with advanced settings. Checking this option will skip loading IFC file and will open advanced load settings",
default=False,
)
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
default=False,
)
should_start_fresh_session: bpy.props.BoolProperty(
name="Should Start Fresh Session",
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
default=True,
)
import_without_ifc_data: bpy.props.BoolProperty(
name="Import Without IFC Data",
description=(
"Import IFC objects as Blender objects without any IFC metadata and authoring capabilities."
"Can be useful for work with purely IFC geometry"
),
default=False,
)
2026-04-10 18:02:44 +05:00
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
filename_ext = ".ifc"
2024-06-28 15:33:34 +05:00
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
filepath: str
filter_glob: str
is_advanced: bool
use_relative_path: bool
should_start_fresh_session: bool
import_without_ifc_data: bool
use_detailed_tooltip: bool
2024-06-28 15:33:34 +05:00
@classmethod
def description(cls, context, properties):
tooltip = cls.bl_description
if not properties.use_detailed_tooltip:
return tooltip
filepath = properties.filepath
if not filepath:
return tooltip
filepath = Path(filepath)
tooltip += ".\n"
2024-06-28 15:33:34 +05:00
if not filepath.exists():
tooltip += "\nFile does not exist"
2024-06-28 15:33:34 +05:00
return tooltip
def get_modified_date(st_mtime: float) -> str:
mod_time = datetime.datetime.fromtimestamp(st_mtime)
today = datetime.date.today()
if mod_time.date() == today:
return f"Today {mod_time.strftime('%I:%M %p')}"
elif mod_time.date() == today - datetime.timedelta(days=1):
return f"Yesterday {mod_time.strftime('%I:%M %p')}"
return mod_time.strftime("%d %b %Y")
def get_file_size(size_bytes: float) -> str:
if size_bytes < 1024 * 1024: # Less than 1 MiB
size = size_bytes / 1024
return f"{size:.0f} KiB"
else:
size = size_bytes / (1024 * 1024)
return f"{size:.1f} MiB"
extractor = ifcopenshell.util.file.IfcHeaderExtractor(str(filepath))
header_metadata = extractor.extract()
if schema := header_metadata.get("schema_name"):
tooltip += f"\nSchema: {schema}"
2024-06-28 15:33:34 +05:00
file_stat = filepath.stat()
tooltip += f"\nModified: {get_modified_date(file_stat.st_mtime)}"
2024-06-28 15:33:34 +05:00
tooltip += f"\nSize: {get_file_size(file_stat.st_size)}"
return tooltip
def execute(self, context):
2025-12-19 09:07:26 +01:00
if (
tool.Blender.get_addon_preferences().save_metadata_blend_file
and self.should_start_fresh_session
and not self.is_advanced
):
filepath = self.get_filepath()
2026-01-12 16:07:34 +05:00
# 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
2026-01-12 16:07:34 +05:00
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}")
2025-12-19 09:07:26 +01:00
@persistent
def load_handler(*args):
bpy.app.handlers.load_post.remove(load_handler)
self.finish_loading_project(context)
if self.use_relative_path:
self.should_start_fresh_session = False
if self.should_start_fresh_session:
# WARNING: wm.read_homefile clears context which could lead to some
# operators to fail:
# https://blender.stackexchange.com/a/282558/135166
# So we continue using the load_post handler thats triggered when
# context is already restored
bpy.app.handlers.load_post.append(load_handler)
bpy.ops.wm.read_homefile()
return {"FINISHED"}
else:
return self.finish_loading_project(context)
2026-03-23 19:16:51 +05:00
def finish_loading_project(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
try:
filepath = self.get_filepath()
if not self.is_existing_ifc_file():
self.report({"ERROR"}, f"Couldn't find IFC file: '{filepath}'.")
return {"FINISHED"}
if self.should_start_fresh_session and tool.Blender.is_default_scene():
for obj in bpy.data.objects:
bpy.data.objects.remove(obj)
# To be safe from any accidental IFC data in the previous session.
if not self.is_advanced and not self.should_start_fresh_session:
bpy.ops.bim.convert_to_blender()
tool.Ifc.set_path(filepath)
if not tool.Ifc.get():
self.report(
{"ERROR"},
f"Error loading IFC file from filepath '{filepath}'. See logs above in the system console for the details.",
)
return {"CANCELLED"}
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
props.is_loading = True
props.total_elements = len(tool.Ifc.get().by_type("IfcElement"))
props.use_relative_project_path = self.use_relative_path
2026-01-12 16:07:34 +05:00
metadata_doc = tool.Project.get_metadata_document_information()
props.should_save_metadata_for_this_file = metadata_doc is not None
2026-01-12 16:07:34 +05:00
tool.Blender.register_toolbar()
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
2024-06-28 15:33:34 +05:00
if self.is_advanced:
pass
elif len(tool.Ifc.get().by_type("IfcElement")) > 30000:
self.report({"WARNING"}, "Warning: large model. Please review advanced settings to continue.")
else:
bpy.ops.bim.load_project_elements()
if self.import_without_ifc_data:
bpy.ops.bim.convert_to_blender()
except:
2024-08-14 14:10:36 +05:00
bonsai.last_error = traceback.format_exc()
raise
return {"FINISHED"}
def invoke(self, context, event):
if self.filepath:
return self.execute(context)
return ImportHelper.invoke(self, context, event)
2022-01-12 00:01:49 +01:00
def draw(self, context):
if self.use_relative_path:
self.should_start_fresh_session = False
2022-01-12 00:01:49 +01:00
self.layout.prop(self, "is_advanced")
self.layout.prop(self, "should_start_fresh_session")
self.layout.prop(self, "import_without_ifc_data")
2022-01-12 00:01:49 +01:00
IFCFileSelector.draw(self, context)
2024-06-28 15:33:34 +05:00
class ClearRecentIFCProjects(bpy.types.Operator):
bl_idname = "bim.clear_recent_ifc_projects"
bl_label = "Clear Recent IFC Projects List"
bl_options = {"REGISTER"}
def execute(self, context):
tool.Project.clear_recent_ifc_projects()
2024-06-28 15:33:34 +05:00
return {"FINISHED"}
2023-10-02 15:30:32 +05:00
class RevertProject(bpy.types.Operator, IFCFileSelector):
bl_idname = "bim.revert_project"
bl_label = "Revert IFC Project"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Revert to a fresh session discarding all unsaved changes"
2023-10-02 15:30:32 +05:00
@classmethod
def poll(cls, context):
2025-02-20 14:50:55 +05:00
props = tool.Blender.get_bim_props()
if not props.ifc_file:
2023-10-02 15:30:32 +05:00
cls.poll_message_set("IFC project need to be loaded and saved on the disk.")
return False
return True
def execute(self, context):
2025-02-20 14:50:55 +05:00
props = tool.Blender.get_bim_props()
bpy.ops.bim.load_project(should_start_fresh_session=True, filepath=props.ifc_file)
2023-10-02 15:38:26 +05:00
return {"FINISHED"}
2023-10-02 15:30:32 +05:00
class LoadProjectElements(bpy.types.Operator):
bl_idname = "bim.load_project_elements"
bl_label = "Load Project Elements"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
2025-02-19 11:06:03 +05:00
self.props = tool.Project.get_project_props()
self.file = tool.Ifc.get()
start = time.time()
logger = logging.getLogger("ImportIFC")
2025-01-20 18:46:53 +05:00
path_log = tool.Blender.get_data_dir_path("process.log")
if not os.access(path_log.parent, os.W_OK):
path_log = os.path.join(
tempfile.mkdtemp(dir=tool.Blender.get_addon_preferences().tmp_dir or None), "process.log"
)
logging.basicConfig(
filename=path_log,
filemode="a",
level=logging.DEBUG,
)
2025-02-20 14:50:55 +05:00
props = tool.Blender.get_bim_props()
settings = import_ifc.IfcImportSettings.factory(context, props.ifc_file, logger)
settings.has_filter = self.props.filter_mode != "NONE"
settings.should_filter_spatial_elements = self.props.should_filter_spatial_elements
if self.props.filter_mode == "DECOMPOSITION":
settings.elements = self.get_decomposition_elements()
elif self.props.filter_mode == "IFC_CLASS":
settings.elements = self.get_ifc_class_elements()
elif self.props.filter_mode == "IFC_TYPE":
settings.elements = self.get_ifc_type_elements()
elif self.props.filter_mode == "WHITELIST":
settings.elements = self.get_whitelist_elements()
elif self.props.filter_mode == "BLACKLIST":
settings.elements = self.get_blacklist_elements()
settings.logger.info("Starting import")
ifc_importer = import_ifc.IfcImporter(settings)
ifc_importer.execute()
settings.logger.info("Import finished in {:.2f} seconds".format(time.time() - start))
print("Import finished in {:.2f} seconds".format(time.time() - start))
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
props.is_loading = False
tool.Project.load_default_thumbnails()
tool.Project.set_default_context()
tool.Project.set_default_modeling_dimensions()
tool.Root.reload_grid_decorator()
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
2025-04-28 13:27:30 +05:00
def get_decomposition_elements(self) -> set[ifcopenshell.entity_instance]:
containers: set[ifcopenshell.entity_instance] = set()
for filter_category in self.props.filter_categories:
if not filter_category.is_selected:
continue
container = self.file.by_id(filter_category.ifc_definition_id)
while container:
containers.add(container)
container = ifcopenshell.util.element.get_aggregate(container)
if self.file.schema == "IFC2X3" and container.is_a("IfcProject"):
container = None
elif self.file.schema != "IFC2X3" and container.is_a("IfcContext"):
container = None
2025-04-28 13:27:30 +05:00
elements: set[ifcopenshell.entity_instance] = set()
for container in containers:
for rel in container.ContainsElements:
elements.update(rel.RelatedElements)
self.append_decomposed_elements(elements)
return elements
2025-04-28 13:27:30 +05:00
def append_decomposed_elements(self, elements: set[ifcopenshell.entity_instance]) -> None:
decomposed_elements: set[ifcopenshell.entity_instance] = set()
for element in elements:
if element.IsDecomposedBy:
for subelement in element.IsDecomposedBy[0].RelatedObjects:
decomposed_elements.add(subelement)
if decomposed_elements:
self.append_decomposed_elements(decomposed_elements)
elements.update(decomposed_elements)
2025-04-28 13:27:30 +05:00
def get_ifc_class_elements(self) -> set[ifcopenshell.entity_instance]:
elements: set[ifcopenshell.entity_instance] = set()
for filter_category in self.props.filter_categories:
if not filter_category.is_selected:
continue
elements.update(self.file.by_type(filter_category.name, include_subtypes=False))
return elements
2025-04-28 13:27:30 +05:00
def get_ifc_type_elements(self) -> set[ifcopenshell.entity_instance]:
elements: set[ifcopenshell.entity_instance] = set()
for filter_category in self.props.filter_categories:
if not filter_category.is_selected:
continue
elements.update(ifcopenshell.util.element.get_types(self.file.by_id(filter_category.ifc_definition_id)))
return elements
2025-04-28 13:27:30 +05:00
def get_whitelist_elements(self) -> set[ifcopenshell.entity_instance]:
return set(ifcopenshell.util.selector.filter_elements(self.file, self.props.filter_query))
2025-04-28 13:27:30 +05:00
def get_blacklist_elements(self) -> set[ifcopenshell.entity_instance]:
return set(self.file.by_type("IfcElement")) - set(
ifcopenshell.util.selector.filter_elements(self.file, self.props.filter_query)
)
2021-09-13 10:00:54 +10:00
class ToggleFilterCategories(bpy.types.Operator):
bl_idname = "bim.toggle_filter_categories"
bl_label = "Toggle Filter Categories"
bl_options = {"REGISTER", "UNDO"}
should_select: bpy.props.BoolProperty(name="Should Select", default=True)
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
should_select: bool
def execute(self, context):
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
for filter_category in props.filter_categories:
filter_category.is_selected = self.should_select
return {"FINISHED"}
class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
2021-09-13 10:00:54 +10:00
bl_idname = "bim.link_ifc"
bl_label = "Link IFC"
2021-09-13 10:00:54 +10:00
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reference in a read-only IFC model in the background"
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
directory: bpy.props.StringProperty(subtype="DIR_PATH")
filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
description="Whether to store linked model path relative to the currently opened IFC file.",
default=False,
)
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty(
name="Query",
2026-03-16 14:46:34 +05:00
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
),
)
filename_ext = ".ifc"
2024-09-30 12:09:13 +05:00
if TYPE_CHECKING:
filepath: str
files: list[bpy.types.OperatorFileListElement]
directory: str
2025-04-21 12:43:08 +05:00
filter_glob: str
use_relative_path: bool
use_cache: bool
query: str
2024-09-30 12:09:13 +05:00
def draw(self, context):
assert self.layout
2025-02-19 11:06:03 +05:00
pprops = tool.Project.get_project_props()
row = self.layout.row()
row.prop(self, "use_relative_path")
row = self.layout.row()
row.prop(self, "use_cache")
row = self.layout.row()
row.label(text="False Origin Mode:")
row = self.layout.row()
row.prop(pprops, "false_origin_mode", text="")
if pprops.false_origin_mode == "MANUAL":
row = self.layout.row()
row.prop(pprops, "false_origin")
row = self.layout.row()
row.prop(pprops, "project_north")
self.layout.prop(self, "query", placeholder="IfcElement")
2021-09-13 10:00:54 +10:00
def _execute(self, context):
start = time.time()
2024-09-30 12:09:13 +05:00
files = [f.name for f in self.files] if self.files else [self.filepath]
if not files or all(not f or not f.strip() for f in files):
self.report({"ERROR"}, "No file selected")
return {"CANCELLED"}
existing_links = tool.Project.get_linked_models_documents() if tool.Ifc.get() else {}
2022-08-02 19:34:01 +10:00
for filename in files:
if not filename or not filename.strip():
continue
2024-09-30 12:09:13 +05:00
filepath = Path(self.directory) / filename
if bpy.data.filepath and filepath.samefile(bpy.data.filepath):
self.report({"INFO"}, "Can't link the current .blend file")
continue
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
new = props.links.add()
if tool.Ifc.get():
if not (document := existing_links.get(filepath)):
document = ifcopenshell.api.document.add_information(tool.Ifc.get())
document.Name = Path(filepath).name
document.Scope = "LINKED_MODEL"
reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document)
reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
reference.Location = filepath.replace("\\", "/")
new.ifc_definition_id = reference.id()
new.name = filepath
new.filepath = filepath
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
2021-09-13 10:48:38 +10:00
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
2021-09-13 10:48:38 +10:00
bl_idname = "bim.unlink_ifc"
bl_label = "Unlink IFC"
2021-09-13 10:48:38 +10:00
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
2026-03-16 14:07:50 +05:00
link_index: bpy.props.IntProperty(name="Link Index")
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
link_index: int
2021-09-13 10:48:38 +10:00
def _execute(self, context):
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
link = props.links[self.link_index]
bpy.ops.bim.unload_link(link_index=self.link_index)
if tool.Ifc.get():
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
document = tool.Document.get_reference_document(reference)
ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference)
if document and not tool.Document.get_document_references(document):
ifcopenshell.api.document.remove_information(tool.Ifc.get(), document)
props.links.remove(self.link_index)
2021-09-13 10:48:38 +10:00
class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
2021-09-13 10:48:38 +10:00
bl_idname = "bim.unload_link"
bl_label = "Unload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
2026-03-16 14:07:50 +05:00
link_index: bpy.props.IntProperty(name="Link Index")
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
link_index: int
def _execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
if obj := tool.Project.get_link_empty_handle(link):
collection = obj.instance_collection
library = collection.library
tool.Ifc.unlink(obj=obj)
bpy.data.objects.remove(obj)
if collection.users == 0:
bpy.data.collections.remove(collection)
if not len([c for c in bpy.data.collections if c.library == library]):
2025-02-28 16:19:10 +01:00
bpy.data.libraries.remove(library)
link.is_loaded = False
ProjectDecorator.uninstall()
2024-03-20 12:05:13 +05:00
class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
2021-09-13 10:48:38 +10:00
bl_idname = "bim.load_link"
bl_label = "Load Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file"
2026-03-03 14:34:05 +05:00
link_index: bpy.props.IntProperty(name="Link Index")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty()
2026-03-03 14:34:05 +05:00
if TYPE_CHECKING:
link_index: int
use_cache: bool
query: str
2021-09-13 10:48:38 +10:00
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
return {"CANCELLED"}
2024-09-30 12:09:13 +05:00
self.filepath_ = filepath
if filepath.suffix.lower().endswith(".ifc"):
return self.link_ifc()
2024-09-30 12:09:13 +05:00
def link_blend(self, filepath: Path) -> None:
2025-04-29 15:08:42 +05:00
with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to):
data_to.collections = [c for c in data_from.collections if "IfcProject" in c]
# Find the linked collection
for collection in bpy.data.collections:
if not collection.library or Path(collection.library.filepath) != filepath:
2021-09-13 10:00:54 +10:00
continue
# Create unique empty instance for this link
empty_name = collection.name
empty = bpy.data.objects.new(empty_name, None)
empty.instance_type = "COLLECTION"
empty.instance_collection = collection
2026-03-03 14:34:05 +05:00
empty.matrix_world = tool.Project.calculate_link_matrix(self.link)
tool.Project.set_link_empty_handle(self.link, empty)
2026-03-03 14:34:05 +05:00
assert bpy.context.scene
bpy.context.scene.collection.objects.link(empty)
self.link.is_loaded = True
if tool.Ifc.get(): # For non-IFC projects, locking has no meaning
tool.Geometry.lock_object(empty)
tool.Blender.select_and_activate_single_object(bpy.context, empty)
break
else:
print(f"WARNING: No IfcProject collection found in {filepath}")
self.link.is_loaded = False
2024-05-02 12:13:44 +05:00
def link_ifc(self) -> Union[set[str], None]:
2024-09-30 12:09:13 +05:00
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
def should_clear_cache() -> bool:
if not self.use_cache:
return True
if not blend_filepath.exists():
return False
data = json.loads(json_filepath.read_text())
# Empty 'query' - model loaded without custom query.
# Missing 'query' - model was loaded before custom queries were introduced in Bonsai.
query = data.get("query", "")
return query != self.query
if should_clear_cache():
os.remove(blend_filepath)
2024-09-30 12:09:13 +05:00
if not blend_filepath.exists():
2025-02-03 12:53:08 +05:00
pprops = tool.Project.get_project_props()
2025-02-19 11:06:03 +05:00
gprops = tool.Georeference.get_georeference_props()
code = f"""
import bpy
import sys
def run():
import bonsai.tool as tool
2025-02-19 11:06:03 +05:00
gprops = tool.Georeference.get_georeference_props()
# Our model origin becomes their host model origin
gprops.has_blender_offset = {gprops.has_blender_offset}
gprops.blender_offset_x = "{gprops.blender_offset_x}"
gprops.blender_offset_y = "{gprops.blender_offset_y}"
gprops.blender_offset_z = "{gprops.blender_offset_z}"
gprops.blender_x_axis_abscissa = "{gprops.blender_x_axis_abscissa}"
gprops.blender_x_axis_ordinate = "{gprops.blender_x_axis_ordinate}"
2025-02-19 11:06:03 +05:00
pprops = tool.Project.get_project_props()
pprops.distance_limit = {pprops.distance_limit}
pprops.false_origin_mode = "{pprops.false_origin_mode}"
pprops.false_origin = "{pprops.false_origin}"
pprops.project_north = "{pprops.project_north}"
2024-11-20 14:36:15 +05:00
# Use absolute path to be safe from cwd changes.
try:
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)})
except RuntimeError as e:
# Operator failed (returned CANCELLED with error report)
print(f"Failed to load linked project: {{e}}")
sys.exit(1)
# Use str instead of as_posix to avoid issues with Windows shared paths.
bpy.ops.wm.save_as_mainfile(filepath=r"{str(blend_filepath)}")
try:
run()
except Exception as e:
import traceback
traceback.print_exc()
exit(1)
"""
2024-05-02 12:13:44 +05:00
t = time.time()
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as temp_file:
temp_file.write(code)
run = subprocess.run(
[
bpy.app.binary_path,
"-b",
# Explicitly ask to enable Bonsai for this Blender instance
# as Bonsai may be just enabled and user preferences are not saved.
"--addons",
tool.Blender.get_blender_addon_package_name(),
"--python",
temp_file.name,
"--python-exit-code",
"1",
]
)
if run.returncode == 1:
print("An error occurred while processing your IFC.")
2024-09-30 12:09:13 +05:00
if not blend_filepath.exists() or blend_filepath.stat().st_mtime < t:
2024-05-02 12:13:44 +05:00
return {"CANCELLED"}
self.set_model_origin_from_link()
self.set_georeferencing_indicator()
self.link_blend(blend_filepath)
2024-09-30 12:09:13 +05:00
def set_model_origin_from_link(self) -> None:
if tool.Ifc.get():
return # The current model's coordinates always take priority.
if len(tool.Project.get_project_props().links) > 1:
return # Only the first link sets the origin
2024-09-30 12:09:13 +05:00
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
if not json_filepath.exists():
return
with open(json_filepath, "r") as f:
data = json.load(f)
2025-02-19 11:06:03 +05:00
gprops = tool.Georeference.get_georeference_props()
for prop in ("model_origin", "model_origin_si", "model_project_north"):
if (value := data.get(prop, None)) is not None:
setattr(gprops, prop, value)
def set_georeferencing_indicator(self) -> None:
if not tool.Ifc.get():
self.link.georeferenced = "NONE"
return
if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")):
self.link.georeferenced = "NONE"
return
reference = tool.Ifc.get().by_id(self.link.ifc_definition_id)
json_filepath = Path(reference.Location).with_suffix(".ifc.cache.json")
if not json_filepath.exists():
self.link.georeferenced = "NONE"
return
with open(json_filepath, "r") as f:
data = json.load(f)
if not data["model_is_georeferenced"]:
self.link.georeferenced = "NONE"
else:
self.link.georeferenced = "FULL_COMPATIBLE" if crs_name == data["model_crs"] else "NOT_COMPATIBLE"
2023-08-27 18:21:19 +01:00
class ReloadLink(bpy.types.Operator):
bl_idname = "bim.reload_link"
bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
2026-03-16 14:07:50 +05:00
link_index: bpy.props.IntProperty(name="Link Index")
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
link_index: int
2023-08-27 18:21:19 +01:00
def execute(self, context):
bpy.ops.bim.unload_link(link_index=self.link_index)
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
2023-08-27 18:21:19 +01:00
class ToggleLinkSelectability(bpy.types.Operator):
bl_idname = "bim.toggle_link_selectability"
bl_label = "Toggle Link Selectability"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
2026-03-16 14:07:50 +05:00
link_index: bpy.props.IntProperty(name="Link Index")
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
link_index: int
def execute(self, context):
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
)
link.is_selectable = (is_selectable := not link.is_selectable)
for collection in self.get_linked_collections():
collection.hide_select = not is_selectable
if handle := tool.Project.get_link_empty_handle(link):
handle.hide_select = not is_selectable
return {"FINISHED"}
2024-03-25 13:57:29 +05:00
def get_linked_collections(self) -> list[bpy.types.Collection]:
return [
c
for c in bpy.data.collections
2024-03-25 13:57:29 +05:00
if "IfcProject" in c.name and c.library and Path(c.library.filepath) == self.library_filepath
]
class ToggleLinkVisibility(bpy.types.Operator):
bl_idname = "bim.toggle_link_visibility"
bl_label = "Toggle Link Visibility"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle visibility between SOLID and WIREFRAME"
2026-03-03 14:34:05 +05:00
link_index: bpy.props.IntProperty(name="Link Index")
mode: bpy.props.EnumProperty(
2026-03-03 14:34:05 +05:00
name="Visibility Mode",
items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")),
)
if TYPE_CHECKING:
link_index: int
mode: Literal["WIREFRAME", "VISIBLE"]
def execute(self, context):
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
)
if self.mode == "WIREFRAME":
self.toggle_wireframe(link)
elif self.mode == "VISIBLE":
self.toggle_visibility(link)
return {"FINISHED"}
2025-02-28 11:08:42 +05:00
def toggle_wireframe(self, link: "Link") -> None:
linked_collections = self.get_linked_collections()
link.is_wireframe = not link.is_wireframe
display_type = "WIRE" if link.is_wireframe else "TEXTURED"
for collection in linked_collections:
2022-08-02 19:34:01 +10:00
objs = filter(lambda obj: "IfcOpeningElement" not in obj.name, collection.all_objects)
for obj in objs:
2022-08-02 19:34:01 +10:00
obj.display_type = display_type
if handle := tool.Project.get_link_empty_handle(link):
handle.display_type = display_type
2025-02-28 11:08:42 +05:00
def toggle_visibility(self, link: "Link") -> None:
2022-08-02 19:34:01 +10:00
linked_collections = self.get_linked_collections()
link.is_hidden = (is_hidden := not link.is_hidden)
2024-03-25 12:17:38 +05:00
layer_collections = tool.Blender.get_layer_collections_mapping(linked_collections)
for layer_collection in layer_collections.values():
layer_collection.exclude = is_hidden
if handle := tool.Project.get_link_empty_handle(link):
handle.hide_set(is_hidden)
2024-03-25 11:51:33 +05:00
def get_linked_collections(self) -> list[bpy.types.Collection]:
2022-09-03 15:34:52 +10:00
return [
c
for c in bpy.data.collections
2024-03-25 11:51:33 +05:00
if "IfcProject" in c.name and c.library and Path(c.library.filepath) == self.library_filepath
2022-08-02 19:34:01 +10:00
]
class EnableEditingLink(bpy.types.Operator):
bl_idname = "bim.enable_editing_link"
bl_label = "Enable Editing Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Enable editing link location"
def execute(self, context):
link = tool.Project.get_project_props().active_link
2026-03-03 14:34:05 +05:00
assert link
link.is_editing = True
2026-03-03 14:34:05 +05:00
obj = tool.Project.get_link_empty_handle(link)
assert obj
tool.Geometry.unlock_object(obj)
return {"FINISHED"}
class DisableEditingLink(bpy.types.Operator):
bl_idname = "bim.disable_editing_link"
bl_label = "Disable Editing Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Disable editing link and restore to previously saved location"
def execute(self, context):
link = tool.Project.get_project_props().active_link
2026-03-03 14:34:05 +05:00
assert link
link.is_editing = False
obj = tool.Project.get_link_empty_handle(link)
2026-03-03 14:34:05 +05:00
assert obj
obj.matrix_world = tool.Project.calculate_link_matrix(link)
tool.Geometry.lock_object(obj)
return {"FINISHED"}
class EditLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_link"
bl_label = "Edit Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Disable editing link and restore to previously saved location"
def _execute(self, context):
link = tool.Project.get_project_props().active_link
2026-03-03 14:34:05 +05:00
assert link
link.is_editing = False
obj = tool.Project.get_link_empty_handle(link)
2026-03-03 14:34:05 +05:00
assert obj
new_obj_matrix = obj.matrix_world
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
radians(-float(metadata["model_project_north"])), 4, "Z"
)
global_matrix = rot @ np.eye(4)
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
gprops = tool.Georeference.get_georeference_props()
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
# obj_matrix is typically calculated as:
# obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix
if np.allclose(np.array(new_obj_matrix), identity_blender_matrix, atol=1e-5):
link.has_transformation = False
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
else:
transformed_global_matrix = local_matrix @ np.array(new_obj_matrix)
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
link.has_transformation = True
transformation = ",".join(map(str, transformation.reshape(-1)))
if tool.Ifc.get():
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
reference[1] = transformation
else:
link.transformation = transformation
2026-03-03 14:34:05 +05:00
obj.matrix_world = tool.Project.calculate_link_matrix(link)
tool.Geometry.lock_object(obj)
2024-09-30 11:20:29 +05:00
class SelectLinkHandle(bpy.types.Operator):
bl_idname = "bim.select_link_handle"
bl_label = "Select Link Handle"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle"
2026-03-16 14:07:50 +05:00
link_index: bpy.props.IntProperty(name="Link Index")
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
link_index: int
2024-09-30 11:20:29 +05:00
def execute(self, context):
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
link = props.links[self.link_index]
handle = tool.Project.get_link_empty_handle(link)
2024-09-30 11:20:29 +05:00
if not handle:
self.report({"ERROR"}, "Link has no empty handle (probably it was deleted).")
return {"CANCELLED"}
tool.Blender.select_and_activate_single_object(context, handle)
return {"FINISHED"}
2026-03-05 15:26:27 +05:00
class SelectLinkedModelElement(bpy.types.Operator):
bl_idname = "bim.select_linked_model_element"
bl_label = "Select Linked Model Element"
bl_options = {"REGISTER"}
bl_description = "Select an element in the currently selected linked model by providing GlobalId."
guid: bpy.props.StringProperty(name="GlobalId")
2026-03-05 15:26:27 +05:00
if TYPE_CHECKING:
guid: str
def invoke(self, context, event):
assert context.window_manager
return context.window_manager.invoke_props_dialog(self)
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
guid = self.guid.strip()
if not guid:
self.report({"ERROR"}, "GlobalId is not provided.")
return {"CANCELLED"}
props = tool.Project.get_project_props()
active_link = props.active_link
assert active_link is not None
assert active_link.is_loaded
guid_obj = tool.Project.Link.get_obj_by_guid(active_link, guid)
if not guid_obj:
filepath = active_link.filepath
self.report({"INFO"}, f"Element with GlobalId '{guid}' not found in the linked model at '{filepath}'.")
return {"CANCELLED"}
tool.Project.Link.select_linked_element(context, guid_obj, guid)
self.report({"INFO"}, f"Element with GlobalId '{guid}' is selected.")
return {"FINISHED"}
class ExportIFC(bpy.types.Operator, ExportHelper):
bl_idname = "bim.save_project"
bl_label = "Save IFC"
# Prevents crash on Blender 4.4.0.
bl_description = "Save active IFC file by the provided filepath."
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".ifc"
2025-06-06 11:17:16 +05:00
supported_filexts = (".ifc", ".ifczip", ".ifcjson")
2026-04-10 18:02:44 +05:00
filter_glob: bpy.props.StringProperty(default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"})
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
filter_glob: str
json_version: str
json_compact: bool
should_save_as: bool
use_relative_path: bool
2023-06-05 18:44:47 +05:00
@classmethod
def poll(cls, context):
return tool.Ifc.get()
2023-06-05 18:44:47 +05:00
def draw(self, context):
layout = self.layout
layout.prop(self, "json_version")
layout.prop(self, "json_compact")
if bpy.data.is_saved:
layout.prop(self, "use_relative_path")
2023-04-08 10:59:59 -07:00
2024-07-30 20:45:50 +05:00
layout.separator()
layout.label(text="Supported formats for export:")
2025-06-06 11:17:16 +05:00
layout.label(text=",".join(self.supported_filexts))
2024-07-30 20:45:50 +05:00
def invoke(self, context, event):
if not tool.Ifc.get():
bpy.ops.wm.save_mainfile("INVOKE_DEFAULT")
return {"FINISHED"}
2025-02-19 11:06:03 +05:00
self.use_relative_path = tool.Project.get_project_props().use_relative_project_path
2025-02-20 14:50:55 +05:00
props = tool.Blender.get_bim_props()
if (filepath := props.ifc_file) and not self.should_save_as:
2024-09-30 12:09:13 +05:00
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
return self.execute(context)
2023-06-05 18:44:47 +05:00
return ExportHelper.invoke(self, context, event)
2025-06-06 11:17:16 +05:00
def check(self, context):
# ExportHelper is automatically adjusting suffix to `filename_ext`.
filepath = Path(self.filepath)
suffix = filepath.suffix.lower()
if suffix != self.filename_ext and suffix in self.supported_filexts:
self.filename_ext = suffix
return ExportHelper.check(self, context)
def execute(self, context):
2025-02-19 11:06:03 +05:00
project_props = tool.Project.get_project_props()
prefs = tool.Blender.get_addon_preferences()
project_props.use_relative_project_path = self.use_relative_path
if prefs.should_disable_undo_on_save:
2022-09-01 11:16:33 +10:00
old_history_size = tool.Ifc.get().history_size
old_undo_steps = context.preferences.edit.undo_steps
tool.Ifc.get().history_size = 0
context.preferences.edit.undo_steps = 0
IfcStore.execute_ifc_operator(self, context)
if prefs.should_disable_undo_on_save:
2022-09-01 11:16:33 +10:00
tool.Ifc.get().history_size = old_history_size
context.preferences.edit.undo_steps = old_undo_steps
return {"FINISHED"}
def _execute(self, context):
start = time.time()
logger = logging.getLogger("ExportIFC")
2025-01-20 18:46:53 +05:00
path_log = tool.Blender.get_data_dir_path("process.log")
if not os.access(path_log.parent, os.W_OK):
path_log = os.path.join(
tempfile.mkdtemp(dir=tool.Blender.get_addon_preferences().tmp_dir or None), "process.log"
)
logging.basicConfig(
filename=path_log,
filemode="a",
level=logging.DEBUG,
)
extension = self.filepath.split(".")[-1]
if extension == "ifczip":
output_file = bpy.path.ensure_ext(self.filepath, ".ifczip")
elif extension == "ifcjson":
output_file = bpy.path.ensure_ext(self.filepath, ".ifcjson")
else:
output_file = bpy.path.ensure_ext(self.filepath, ".ifc")
output_file = Path(output_file).as_posix().replace("\\", "/")
settings = export_ifc.IfcExportSettings.factory(context, output_file, logger)
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
2026-01-12 16:07:34 +05:00
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)
2023-08-27 18:43:33 +01:00
print("Starting export")
settings.logger.info("Starting export")
ifc_exporter.export()
settings.logger.info("Export finished in {:.2f} seconds".format(time.time() - start))
print("Export finished in {:.2f} seconds".format(time.time() - start))
# New project created in Bonsai should be in recent projects too.
tool.Project.add_recent_ifc_project(Path(output_file))
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
if props.use_relative_project_path and bpy.data.is_saved:
output_file = os.path.relpath(output_file, bpy.path.abspath("//"))
2025-02-20 14:50:55 +05:00
bim_props = tool.Blender.get_bim_props()
if bim_props.ifc_file != output_file and extension not in ("ifczip", "ifcjson"):
tool.Ifc.set_path(output_file)
2025-02-20 14:50:55 +05:00
bim_props.is_dirty = False
2025-12-19 09:07:26 +01:00
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
if output_file.lower().endswith(".ifc"):
blendmetadata_path = output_file[:-4] + suffix
else:
blendmetadata_path = output_file + suffix
2025-12-19 09:07:26 +01:00
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}',
)
except Exception as e:
2025-12-11 16:33:22 +01:00
self.report({"ERROR"}, f"Failed to save blend metadata file: {e}")
else:
save_blend_file = bool(bpy.data.is_saved and bpy.data.is_dirty and bpy.data.filepath)
if save_blend_file:
bpy.ops.wm.save_mainfile(filepath=bpy.data.filepath)
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved',
)
2025-12-19 09:07:26 +01:00
2024-08-14 14:10:36 +05:00
bonsai.bim.handler.refresh_ui_data()
@classmethod
def description(cls, context, properties):
2023-04-08 10:59:59 -07:00
if properties.should_save_as:
return "Save the IFC file under a new name, or relocate file"
return "Save the IFC file. Will save both .IFC/.BLEND files if synced together"
class LoadLinkedProject(bpy.types.Operator, ImportHelper):
bl_idname = "bim.load_linked_project"
bl_label = "Load Project For Viewing Only"
bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file."
bl_options = {"REGISTER", "UNDO"}
query: bpy.props.StringProperty()
"""See ``bim.link_ifc``."""
if TYPE_CHECKING:
query: str
2024-10-14 14:31:26 +05:00
file: ifcopenshell.file
meshes: dict[str, bpy.types.Mesh]
# Material names is derived from diffuse as in 'r-g-b-a'.
blender_mats: dict[str, bpy.types.Material]
def invoke(self, context, event):
# Invoke is for debugging purposes, users are not intended to use this method really.
return ImportHelper.invoke(self, context, event)
def execute(self, context):
import multiprocessing
2026-01-26 17:11:12 +05:00
import ifcpatch
start = time.time()
2025-02-19 11:06:03 +05:00
pprops = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
2024-09-30 12:09:13 +05:00
self.filepath = Path(self.filepath).as_posix()
print("Processing", self.filepath)
self.collection = bpy.data.collections.new("IfcProject/" + os.path.basename(self.filepath))
try:
self.file = ifcopenshell.open(self.filepath)
except Exception as e:
self.report({"ERROR"}, f"Failed to open IFC file: {str(e)}")
bpy.data.collections.remove(self.collection)
return {"CANCELLED"}
tool.Ifc.set(self.file)
print("Finished opening")
self.db_filepath = self.filepath + ".cache.sqlite"
db = ifcpatch.execute(
{"input": self.filepath, "file": self.file, "recipe": "ExtractPropertiesToSQLite", "arguments": []}
)
ifcpatch.write(db, self.db_filepath)
print("Finished writing property database")
logger = logging.getLogger("ImportIFC")
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file)
tool.Loader.set_unit_scale(self.unit_scale)
tool.Loader.set_settings(import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger))
tool.Loader.settings.contexts = ifcopenshell.util.representation.get_prioritised_contexts(self.file)
tool.Loader.settings.context_settings = tool.Loader.create_settings()
tool.Loader.settings.gross_context_settings = tool.Loader.create_settings(is_gross=True)
if self.query:
self.elements = ifcopenshell.util.selector.filter_elements(self.file, self.query)
else:
self.elements = set(self.file.by_type("IfcElement"))
if self.file.schema in ("IFC2X3", "IFC4"):
self.elements |= set(self.file.by_type("IfcProxy"))
if self.file.schema == "IFC2X3":
self.elements |= set(self.file.by_type("IfcSpatialStructureElement"))
else:
self.elements |= set(self.file.by_type("IfcSpatialElement"))
self.elements -= set(self.file.by_type("IfcFeatureElement"))
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
tool.Loader.set_manual_blender_offset(self.file)
elif tool.Loader.settings.false_origin_mode == "AUTOMATIC":
tool.Loader.guess_false_origin(self.file)
tool.Georeference.set_model_origin()
self.json_filepath = self.filepath + ".cache.json"
data = {
"model_is_georeferenced": gprops.model_is_georeferenced,
"model_crs": gprops.model_crs,
"model_origin": gprops.model_origin,
"model_origin_si": gprops.model_origin_si,
"model_project_north": gprops.model_project_north,
"has_blender_offset": gprops.has_blender_offset,
"blender_offset_x": gprops.blender_offset_x,
"blender_offset_y": gprops.blender_offset_y,
"blender_offset_z": gprops.blender_offset_z,
"blender_x_axis_abscissa": gprops.blender_x_axis_abscissa,
"blender_x_axis_ordinate": gprops.blender_x_axis_ordinate,
"distance_limit": pprops.distance_limit,
"false_origin_mode": pprops.false_origin_mode,
"false_origin": pprops.false_origin,
"project_north": pprops.project_north,
"query": self.query,
}
with open(self.json_filepath, "w") as f:
json.dump(data, f)
for settings in tool.Loader.settings.context_settings:
if not self.elements:
break
results = set()
iterator = ifcopenshell.geom.iterator(
settings, self.file, multiprocessing.cpu_count(), include=self.elements
)
self.meshes = {}
self.blender_mats = {}
2024-12-23 11:35:48 +05:00
blender_mats: dict[tuple[float, float, float, float], bpy.types.Material] = {}
default_mat = np.array([[1, 1, 1, 1]], dtype=np.float32)
2024-12-23 11:35:48 +05:00
chunked_guids: list[str] = []
chunked_guid_ids: list[int] = []
chunked_verts: list[np.ndarray] = []
chunked_faces: list[np.ndarray] = []
# List of material colors.
chunked_materials: list[np.ndarray] = []
# List of material indices for each face.
chunked_material_ids: list[np.ndarray] = []
material_offset = 0
chunk_size = 10000
2024-12-23 11:35:48 +05:00
# Vertex offset.
offset = 0
ci = 0
def process_chunk() -> None:
mats = np.concatenate(chunked_materials)
midx = np.concatenate(chunked_material_ids)
mats, mapping = np.unique(mats, axis=0, return_inverse=True)
midx = mapping[midx]
mat_results: list[bpy.types.Material] = []
for mat in mats:
mat = tuple(mat)
blender_mat = blender_mats.get(mat, None)
if not blender_mat:
blender_mat = bpy.data.materials.new("Chunk")
blender_mat.diffuse_color = mat
blender_mats[mat] = blender_mat
mat_results.append(blender_mat)
# Create object for current chunk.
self.create_object(
np.concatenate(chunked_verts),
np.concatenate(chunked_faces),
mat_results,
midx,
chunked_guids,
chunked_guid_ids,
)
if iterator.initialize():
while True: # Main loop.
shape = iterator.get()
2026-03-12 20:19:34 +05:00
assert isinstance(shape, W.TriangulationElement)
results.add(self.file.by_id(shape.id))
2024-12-23 18:32:29 +05:00
geometry = shape.geometry
# Elements with a lot of geometry benefit from instancing to save memory
2024-12-23 18:32:29 +05:00
if ifcopenshell.util.shape.get_faces(geometry).shape[0] > 333: # 333 tris
self.process_occurrence(shape)
if not iterator.next():
if not chunked_verts:
break
process_chunk()
break # Break from main loop.
continue
ci += 1
if ci % 50 == 0:
print("Doing chunk", ci)
has_processed_chunk = False
2024-10-14 17:37:18 +05:00
ms = np.vstack([default_mat, ifcopenshell.util.shape.get_material_colors(shape.geometry)])
2024-10-15 15:05:24 +05:00
mi = ifcopenshell.util.shape.get_faces_material_style_ids(shape.geometry)
for geom_material_idx, geom_material in enumerate(shape.geometry.materials):
2024-06-18 10:59:10 +10:00
if not geom_material.instance_id():
ms[geom_material_idx + 1] = (0.8, 0.8, 0.8, 1)
chunked_materials.append(ms)
chunked_material_ids.append(mi + material_offset + 1)
material_offset += len(ms)
matrix = np.frombuffer(shape.transformation_buffer).reshape((4, 4), order="F")
if gprops.has_blender_offset:
matrix = ifcopenshell.util.geolocation.global2local(
matrix,
float(gprops.blender_offset_x) * self.unit_scale,
float(gprops.blender_offset_y) * self.unit_scale,
float(gprops.blender_offset_z) * self.unit_scale,
float(gprops.blender_x_axis_abscissa),
float(gprops.blender_x_axis_ordinate),
)
2024-10-14 17:37:18 +05:00
vs = ifcopenshell.util.shape.get_vertices(shape.geometry)
vs = np.hstack((vs, np.ones((len(vs), 1))))
vs = (np.asmatrix(matrix) * np.asmatrix(vs).T).T.A
2024-12-23 18:32:29 +05:00
vs = vs[:, :3].ravel()
2024-10-14 17:46:35 +05:00
fs = ifcopenshell.util.shape.get_faces(shape.geometry).ravel()
chunked_verts.append(vs)
chunked_faces.append(fs + offset)
offset += len(vs) // 3
chunked_guids.append(shape.guid)
if chunked_guid_ids:
chunked_guid_ids.append((len(fs) // 3) + chunked_guid_ids[-1])
else:
chunked_guid_ids.append(len(fs) // 3)
if offset > chunk_size:
has_processed_chunk = True
process_chunk()
chunked_guids = []
chunked_guid_ids = []
chunked_verts = []
chunked_faces = []
chunked_materials = []
chunked_material_ids = []
material_offset = 0
offset = 0
if not iterator.next():
if not has_processed_chunk:
process_chunk()
break # Break main loop.
self.elements -= results
bpy.context.scene.collection.children.link(self.collection)
print("Finished", time.time() - start)
return {"FINISHED"}
2025-07-21 11:26:15 +05:00
def process_occurrence(self, shape: W.TriangulationElement) -> None:
element = self.file.by_id(shape.id)
mat = ifcopenshell.util.shape.get_shape_matrix(shape)
geometry = shape.geometry
mesh = self.meshes.get(geometry.id, None)
if not mesh:
verts = ifcopenshell.util.shape.get_vertices(geometry)
material_ids = geometry.material_ids
mesh = tool.Loader.create_mesh_from_shape(geometry, mesh)
2025-02-19 11:06:03 +05:00
gprops = tool.Georeference.get_georeference_props()
if gprops.has_blender_offset and verts.size and tool.Loader.is_point_far_away(verts[0], is_meters=True):
vert: np.ndarray = verts[0]
# Shift geometry close to the origin based off that first vert it found
offset = ifcopenshell.util.shape_builder.np_translation_matrix(-vert)
mat = offset @ mat
mesh["has_cartesian_point_offset"] = True
mesh["cartesian_point_offset"] = ",".join(vert.astype(str))
else:
mesh["has_cartesian_point_offset"] = False
2025-07-21 11:26:15 +05:00
material_to_slot: dict[int, int] = {}
max_slot_index = 0
for i, material in enumerate(geometry.materials):
alpha = 1.0
if material.has_transparency and material.transparency > 0:
alpha = 1.0 - material.transparency
2024-06-18 10:59:10 +10:00
if material.instance_id():
diffuse = (material.diffuse.r(), material.diffuse.g(), material.diffuse.b(), alpha)
else:
diffuse = (0.8, 0.8, 0.8, 1) # Blender's default material
material_name = f"{diffuse[0]}-{diffuse[1]}-{diffuse[2]}-{diffuse[3]}"
blender_mat = self.blender_mats.get(material_name, None)
if not blender_mat:
blender_mat = bpy.data.materials.new(material_name)
blender_mat.diffuse_color = diffuse
self.blender_mats[material_name] = blender_mat
slot_index = mesh.materials.find(material.name)
if slot_index == -1:
mesh.materials.append(blender_mat)
slot_index = max_slot_index
max_slot_index += 1
material_to_slot[i] = slot_index
2025-02-12 15:47:49 +05:00
material_index = np.array([(material_to_slot[i] if i != -1 else 0) for i in material_ids], dtype="I")
mesh.polygons.foreach_set("material_index", material_index)
mesh.update()
self.meshes[geometry.id] = mesh
obj = bpy.data.objects.new(tool.Loader.get_name(element), mesh)
obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, mat)
obj["guids"] = [shape.guid]
obj["guid_ids"] = [len(mesh.polygons)]
obj["db"] = self.db_filepath
obj["ifc_filepath"] = self.filepath
self.collection.objects.link(obj)
2024-12-23 11:35:48 +05:00
def create_object(
self,
verts: np.ndarray,
faces: np.ndarray,
materials: list[bpy.types.Material],
material_ids: np.ndarray,
guids: list[str],
guid_ids: list[int],
) -> None:
num_vertices = len(verts) // 3
if not num_vertices:
return
2025-02-12 18:07:58 +05:00
mesh = tool.Loader.create_mesh_from_shape(verts=verts.reshape(-1, 3), faces=faces.reshape(-1, 3))
for material in materials:
mesh.materials.append(material)
if material_ids.size > 0 and len(mesh.polygons) == len(material_ids):
mesh.polygons.foreach_set("material_index", material_ids)
mesh.update()
obj = bpy.data.objects.new("Chunk", mesh)
obj["guids"] = list(guids)
obj["guid_ids"] = list(guid_ids)
obj["db"] = self.db_filepath
2024-03-19 17:12:55 +05:00
obj["ifc_filepath"] = self.filepath
self.collection.objects.link(obj)
class QueryLinkedElement(bpy.types.Operator):
bl_idname = "bim.query_linked_element"
bl_label = "Query Linked Element"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
2026-03-05 15:26:27 +05:00
assert context.area
return context.area.type == "VIEW_3D"
2026-03-05 15:26:27 +05:00
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
LinksData.linked_data = {}
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
props.queried_obj = None
2026-03-03 14:34:05 +05:00
assert context.screen
for area in context.screen.areas:
if area.type == "PROPERTIES":
for region in area.regions:
if region.type == "WINDOW":
region.tag_redraw()
elif area.type == "VIEW_3D":
area.tag_redraw()
2026-03-03 14:34:05 +05:00
assert context.region and context.region_data
region = context.region
rv3d = context.region_data
coord = (self.mouse_x, self.mouse_y)
origin = region_2d_to_origin_3d(region, rv3d, coord)
direction = region_2d_to_vector_3d(region, rv3d, coord)
2026-03-04 17:23:46 +05:00
hit, location, normal, face_index, obj, instance_matrix = tool.Blender.ray_cast_scene(
context, origin, direction
)
if not hit:
self.report({"INFO"}, "No object found.")
return {"FINISHED"}
2026-03-05 15:26:27 +05:00
if not tool.Project.Link.is_linked_element(obj):
self.report({"INFO"}, "Object is not a linked IFC element.")
return {"FINISHED"}
2026-03-05 15:26:27 +05:00
guid = tool.Project.Link.get_guid_by_face_index(obj, face_index)
2026-03-03 14:34:05 +05:00
assert guid is not None
2026-03-05 15:26:27 +05:00
tool.Project.Link.select_linked_element(context, obj, guid)
self.report({"INFO"}, f"Loaded data for {guid}")
ProjectDecorator.install(bpy.context)
return {"FINISHED"}
def invoke(self, context, event):
self.mouse_x = event.mouse_region_x
self.mouse_y = event.mouse_region_y
return self.execute(context)
class HideQueriedLinkedElement(bpy.types.Operator):
bl_idname = "bim.hide_queried_linked_element"
bl_label = "Hide Queried Linked Element"
bl_description = (
"Hide geometry for currently queried linked element.\n\n"
"SHIFT+Click (or SHIFT+H in Explore Tool) to hide everything "
"in the currently selected model, but the queried element.\n"
"ALT+Click (or ALT+H in Explore Tool) to unhide all geometry for currently selected linked model.\n\n"
"Known limitation: doesn't work with UNDO."
)
bl_options = {"REGISTER", "UNDO"}
unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"})
hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
unhide_all: bool
hide_all_except: bool
def invoke(self, context, event):
self.unhide_all = event.alt
self.hide_all_except = event.shift
return self.execute(context)
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Project.get_project_props()
if self.unhide_all:
return self.run_unhide_all()
if self.hide_all_except:
return self.run_hide_all_except()
obj = props.queried_obj
if not obj:
self.report({"INFO"}, "No object is queried to hide.")
return {"FINISHED"}
guid = props.queried_guid
tool.Project.Link.hide_linked_element(obj, guid)
tool.Project.Link.deselect_queried_linked_element()
self.report({"INFO"}, "Queried object is now hidden.")
return {"FINISHED"}
def run_unhide_all(self) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Project.get_project_props()
link = props.active_link
if not link:
self.report({"INFO"}, "No linked model is currently selected.")
return {"FINISHED"}
tool.Project.Link.unhide_all_elements(link)
self.report({"INFO"}, "All linked model geometry is unhidden.")
return {"FINISHED"}
def run_hide_all_except(self) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Project.get_project_props()
obj = props.queried_obj
if not obj:
self.report({"INFO"}, "No object is queried.")
return {"FINISHED"}
link = props.active_link
if not link:
self.report({"INFO"}, "No linked model is currently selected.")
return {"FINISHED"}
guid = props.queried_guid
tool.Project.Link.hide_all_elements_except(link, obj, guid)
self.report({"INFO"}, "All other linked model geometry is now hidden.")
return {"FINISHED"}
2024-03-19 17:12:55 +05:00
class AppendInspectedLinkedElement(AppendLibraryElement):
bl_idname = "bim.append_inspected_linked_element"
bl_label = "Append Inspected Linked Element"
bl_description = "Append inspected linked element"
bl_options = {"REGISTER", "UNDO"}
2024-03-19 17:12:55 +05:00
def _execute(self, context):
2024-08-14 14:10:36 +05:00
from bonsai.bim.module.project.data import LinksData
2024-03-19 17:12:55 +05:00
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
2024-03-19 17:12:55 +05:00
if not LinksData.linked_data:
self.report({"INFO"}, "No linked element found.")
return {"CANCELLED"}
guid = LinksData.linked_data["attributes"].get("GlobalId")
if guid is None:
self.report({"INFO"}, "Cannot find Global Id for element.")
return {"CANCELLED"}
2025-02-19 11:06:03 +05:00
queried_obj = props.queried_obj
2026-03-03 14:34:05 +05:00
assert queried_obj
ifc_file = tool.Ifc.get()
2025-01-28 14:59:01 +05:00
linked_ifc_file: ifcopenshell.file
linked_ifc_file = ifcopenshell.open(queried_obj["ifc_filepath"])
if ifc_file.schema_identifier != linked_ifc_file.schema_identifier:
self.report(
{"ERROR"},
f"Schema of linked file ({linked_ifc_file.schema_identifier}) is not compatible with the current IFC file ({ifc_file.schema_identifier}).",
)
return {"CANCELLED"}
element_to_append = linked_ifc_file.by_guid(guid)
2025-06-09 10:59:22 +05:00
element = ifcopenshell.api.project.append_asset(
2024-03-19 17:12:55 +05:00
tool.Ifc.get(),
library=linked_ifc_file,
2024-03-19 17:12:55 +05:00
element=element_to_append,
)
self.import_product_from_ifc(element, context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type and tool.Ifc.get_object(element_type) is None:
2024-03-19 17:12:55 +05:00
self.import_type_from_ifc(element_type, context)
return {"FINISHED"}
class EnableCulling(bpy.types.Operator):
bl_idname = "bim.enable_culling"
bl_label = "Enable Culling"
bl_options = {"REGISTER"}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.last_view_corners = None
self.total_mousemoves = 0
self.cullable_objects = []
2026-03-16 14:07:50 +05:00
def modal(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
if not LinksData.enable_culling:
for obj in bpy.context.visible_objects:
if obj.type == "MESH" and obj.name.startswith("Ifc"):
obj.display_type = "SOLID"
self.cullable_objects = []
return {"CANCELLED"}
# Even if the view is changing, there are certain scenarios where we
# don't want to apply culling. For example, if we scroll to zoom but
# are simultaneously moving our mouse, or if we `Zoom to Selected`. A
# dumb but seemingly effective way is to count MOUSEMOVE events. If at
# least 3 consecutive events occur, you're probably not doing some
# other navigational thing.
if event.type == "MOUSEMOVE":
self.total_mousemoves += 1
else:
self.total_mousemoves = 0
if self.total_mousemoves > 2 and self.is_view_changed(context):
self.total_mousemoves = 0
camera_position = context.region_data.view_matrix.inverted().translation
for obj in self.cullable_objects:
if obj.type == "MESH" and obj.name.startswith("Ifc"):
if self.is_object_in_view(obj, context, camera_position):
obj.display_type = "SOLID"
elif obj.display_type != "BOUNDS":
obj.display_type = "BOUNDS"
return {"PASS_THROUGH"}
2026-03-16 14:07:50 +05:00
def is_view_changed(self, context: bpy.types.Context) -> bool:
view_matrix = context.region_data.view_matrix
projection_matrix = context.region_data.window_matrix
vp_matrix = projection_matrix @ view_matrix
# Get NDC coordinates of the viewport corners
viewport_corners = [Vector((-1, -1, 0)), Vector((1, -1, 0)), Vector((1, 1, 0)), Vector((-1, 1, 0))]
ndc_corners = [vp_matrix @ Vector((corner.x, corner.y, -1, 1)) for corner in viewport_corners]
ndc_corners = [(corner / corner.w).xy for corner in ndc_corners]
if self.last_view_corners != ndc_corners:
self.last_view_corners = ndc_corners
return True
return False
2026-03-16 14:07:50 +05:00
def is_object_in_view(self, obj: bpy.types.Object, context: bpy.types.Context, camera_position: Vector) -> bool:
# Get the view matrix and the projection matrix from the active viewport
view_matrix = context.region_data.view_matrix
projection_matrix = context.region_data.window_matrix
# Calculate the combined view projection matrix
vp_matrix = projection_matrix @ view_matrix
obj_matrix_world = obj.matrix_world
# Transform each corner of the bounding box using the view projection matrix
# and check if it's inside the normalized device coordinates (NDC) space
for corner in [obj_matrix_world @ Vector(corner) for corner in obj.bound_box]:
ndc = vp_matrix @ corner.to_4d()
ndc /= ndc.w
if -1 <= ndc.x <= 1 and -1 <= ndc.y <= 1 and 0 <= ndc.z <= 1:
# At least one corner is inside the view, so the object is visible
break
else:
return False
# Check if the object is too far away from the camera
object_center = obj.matrix_world.translation
distance_threshold = 900 # 30m squared
if (camera_position - object_center).length_squared > distance_threshold:
# The object is too far away, so consider it not visible
return False
return True
2026-03-16 14:07:50 +05:00
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set["rna_enums.OperatorReturnItems"]:
LinksData.enable_culling = True
self.cullable_objects = []
for obj in bpy.context.visible_objects:
if obj.type == "MESH" and obj.name.startswith("Ifc") and max(obj.dimensions) < 0.6:
self.cullable_objects.append(obj)
camera_position = context.region_data.view_matrix.inverted().translation
if not self.is_object_in_view(obj, context, camera_position):
obj.display_type = "BOUNDS"
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
class DisableCulling(bpy.types.Operator):
bl_idname = "bim.disable_culling"
bl_label = "Disable Culling"
bl_options = {"REGISTER"}
def execute(self, context):
LinksData.enable_culling = False
return {"FINISHED"}
2024-02-29 16:49:23 +11:00
class RefreshClippingPlanes(bpy.types.Operator):
bl_idname = "bim.refresh_clipping_planes"
bl_label = "Refresh Clipping Planes"
bl_options = {"REGISTER"}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
2024-02-29 16:49:23 +11:00
self.total_planes = 0
self.camera = None
2024-02-29 16:49:23 +11:00
def invoke(self, context, event):
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
def modal(self, context, event):
should_refresh = False
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
2024-02-29 16:49:23 +11:00
self.clean_deleted_planes(context)
2025-02-19 11:06:03 +05:00
for clipping_plane in props.clipping_planes:
if clipping_plane.obj and tool.Ifc.is_moved(clipping_plane.obj, ifc_only=False):
2024-02-29 16:49:23 +11:00
should_refresh = True
break
if (camera := context.scene.camera) and camera.visible_get() and tool.Ifc.get_entity(camera):
2025-03-16 11:53:35 +11:00
camera = context.scene.camera
else:
camera = None
if self.camera != camera:
should_refresh = True
elif self.camera and tool.Ifc.is_moved(self.camera, ifc_only=False):
should_refresh = True
2025-02-19 11:06:03 +05:00
total_planes = len(props.clipping_planes)
2024-02-29 16:49:23 +11:00
if should_refresh or total_planes != self.total_planes:
2025-03-16 11:53:35 +11:00
self.camera = camera
2024-02-29 16:49:23 +11:00
self.refresh_clipping_planes(context)
2025-02-19 11:06:03 +05:00
for clipping_plane in props.clipping_planes:
2024-02-29 16:49:23 +11:00
if clipping_plane.obj:
tool.Geometry.record_object_position(clipping_plane.obj)
self.total_planes = total_planes
return {"PASS_THROUGH"}
2025-02-19 11:06:03 +05:00
def clean_deleted_planes(self, context: bpy.types.Context) -> None:
props = tool.Project.get_project_props()
2024-02-29 16:49:23 +11:00
while True:
2025-02-19 11:06:03 +05:00
for i, clipping_plane in enumerate(props.clipping_planes):
2024-02-29 16:49:23 +11:00
if clipping_plane.obj:
try:
clipping_plane.obj.name
except:
2025-02-19 11:06:03 +05:00
props.clipping_planes.remove(i)
2024-02-29 16:49:23 +11:00
break
else:
2025-02-19 11:06:03 +05:00
props.clipping_planes.remove(i)
2024-02-29 16:49:23 +11:00
break
else:
break
def refresh_clipping_planes(self, context):
from itertools import cycle
2026-01-26 17:11:12 +05:00
import bmesh
2024-02-29 16:49:23 +11:00
area = next(a for a in bpy.context.screen.areas if a.type == "VIEW_3D")
region = next(r for r in area.regions if r.type == "WINDOW")
data = region.data
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
# See 6452 and 6478.
# if not len(props.clipping_planes) and not self.camera:
if not len(props.clipping_planes):
2024-02-29 16:49:23 +11:00
data.use_clip_planes = False
else:
with bpy.context.temp_override(area=area, region=region):
bpy.ops.view3d.clip_border()
clip_planes = []
2025-02-19 11:06:03 +05:00
for clipping_plane in tool.Project.get_project_props().clipping_planes:
2024-02-29 16:49:23 +11:00
obj = clipping_plane.obj
if not obj:
continue
bm = bmesh.new()
bm.from_mesh(obj.data)
world_matrix = obj.matrix_world
bm.faces.ensure_lookup_table()
face = bm.faces[0]
center = world_matrix @ face.calc_center_median()
normal = world_matrix.to_3x3() @ face.normal * -1
center += normal * -0.01
normal.normalize()
distance = -center.dot(normal)
clip_plane = (normal.x, normal.y, normal.z, distance)
clip_planes.append(clip_plane)
bm.free()
# See 6452 and 6478.
# if self.camera:
# normal = self.camera.matrix_world.col[2].to_3d()
# normal *= -1
# center = self.camera.matrix_world.translation
# distance = -center.dot(normal)
# clip_plane = (normal.x, normal.y, normal.z, distance)
# clip_planes.append(clip_plane)
2024-02-29 16:49:23 +11:00
clip_planes = cycle(clip_planes)
data.clip_planes = [tuple(next(clip_planes)) for i in range(0, 6)]
data.update()
region.tag_redraw()
[a.tag_redraw() for a in bpy.context.screen.areas]
return {"FINISHED"}
class CreateClippingPlane(bpy.types.Operator):
bl_idname = "bim.create_clipping_plane"
bl_label = "Create Clipping Plane"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# Clean up deleted planes
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
if len(props.clipping_planes) > 5:
2024-02-29 16:49:23 +11:00
self.report({"INFO"}, "Maximum of six clipping planes allowed.")
return {"FINISHED"}
2026-03-10 11:32:57 +05:00
tool.Blender.update_all_viewports(context)
2024-02-29 16:49:23 +11:00
2026-03-03 14:34:05 +05:00
assert context.region and context.region_data
2024-02-29 16:49:23 +11:00
region = context.region
rv3d = context.region_data
if rv3d: # Called from a 3D viewport
coord = (self.mouse_x, self.mouse_y)
origin = region_2d_to_origin_3d(region, rv3d, coord)
direction = region_2d_to_vector_3d(region, rv3d, coord)
2026-03-04 17:23:46 +05:00
hit, location, normal, face_index, obj, matrix = tool.Blender.ray_cast_scene(context, origin, direction)
if not hit:
self.report({"INFO"}, "No object found.")
return {"FINISHED"}
else: # Not Called from a 3D viewport
location = (0, 0, 1)
normal = (0, 0, 1)
2024-02-29 16:49:23 +11:00
vertices = [(-0.5, -0.5, 0), (0.5, -0.5, 0), (0.5, 0.5, 0), (-0.5, 0.5, 0)]
faces = [(0, 1, 2, 3)]
mesh = bpy.data.meshes.new(name="ClippingPlane")
mesh.from_pydata(vertices, [], faces)
mesh.update()
plane_obj = bpy.data.objects.new("ClippingPlane", mesh)
plane_obj.show_in_front = True
context.collection.objects.link(plane_obj)
2024-02-29 16:49:23 +11:00
z_axis = Vector((0, 0, 1))
rotation_matrix = z_axis.rotation_difference(normal).to_matrix().to_4x4()
plane_obj.matrix_world = rotation_matrix
plane_obj.matrix_world.translation = location
context.scene.cursor.location = location
2024-02-29 16:49:23 +11:00
2025-02-19 11:06:03 +05:00
new = tool.Project.get_project_props().clipping_planes.add()
2024-02-29 16:49:23 +11:00
new.obj = plane_obj
tool.Blender.set_active_object(plane_obj)
ClippingPlaneDecorator.install(context)
2024-02-29 16:49:23 +11:00
bpy.ops.bim.refresh_clipping_planes("INVOKE_DEFAULT")
return {"FINISHED"}
def invoke(self, context, event):
self.mouse_x = event.mouse_region_x
self.mouse_y = event.mouse_region_y
return self.execute(context)
2024-02-29 20:39:29 +11:00
class FlipClippingPlane(bpy.types.Operator):
bl_idname = "bim.flip_clipping_plane"
bl_label = "Flip Clipping Plane"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.active_object
2024-02-29 20:39:29 +11:00
def execute(self, context):
obj = context.active_object
2025-02-19 11:06:03 +05:00
if obj in tool.Project.get_project_props().clipping_planes_objs:
2024-02-29 20:39:29 +11:00
obj.rotation_euler[0] += radians(180)
obj.rotation_euler[0] %= radians(360)
context.view_layer.update()
2024-02-29 20:39:29 +11:00
return {"FINISHED"}
CLIPPING_PLANES_FILE_NAME = "ClippingPlanes.json" # TODO un-hardcode :=
class BIM_OT_save_clipping_planes(bpy.types.Operator):
bl_idname = "bim.save_clipping_planes"
bl_label = "Save Clipping Planes"
bl_description = "Save Clipping Planes to Disk"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if IfcStore.path:
2025-02-19 11:06:03 +05:00
return tool.Project.get_project_props().clipping_planes
cls.poll_message_set("Please Save The IFC File")
def execute(self, context):
clipping_planes_to_serialize = defaultdict(dict)
2025-02-19 11:06:03 +05:00
clipping_planes = tool.Project.get_project_props().clipping_planes
for clipping_plane in clipping_planes:
obj = clipping_plane.obj
2025-02-19 11:06:03 +05:00
assert obj
name = obj.name
clipping_planes_to_serialize[name]["location"] = obj.location[0:3]
clipping_planes_to_serialize[name]["rotation"] = obj.rotation_euler[0:3]
with open(Path(IfcStore.path).with_name(CLIPPING_PLANES_FILE_NAME), "w") as file:
json.dump(clipping_planes_to_serialize, file, indent=4)
return {"FINISHED"}
class BIM_OT_load_clipping_planes(bpy.types.Operator):
bl_idname = "bim.load_clipping_planes"
bl_label = "Load Clipping Planes"
bl_description = "Load Clipping Planes from Disk"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if filepath := IfcStore.path:
if Path(filepath).with_name(CLIPPING_PLANES_FILE_NAME).exists():
return True
else:
cls.poll_message_set(f"No Clipping Planes File in Folder {filepath}")
else:
cls.poll_message_set("Please Save The IFC File")
def execute(self, context):
2025-02-19 11:06:03 +05:00
props = tool.Project.get_project_props()
bpy.data.batch_remove(props.clipping_planes_objs)
props.clipping_planes.clear()
with open(Path(IfcStore.path).with_name(CLIPPING_PLANES_FILE_NAME), "r") as file:
clipping_planes_dict = json.load(file)
for name, values in clipping_planes_dict.items():
bpy.ops.bim.create_clipping_plane()
2025-02-19 11:06:03 +05:00
obj = props.clipping_planes_objs[-1]
obj.name = name
obj.location = values["location"]
obj.rotation_euler = values["rotation"]
return {"FINISHED"}
2024-05-27 16:16:57 +05:00
2025-01-22 12:55:16 +05:00
class IFCFileHandlerOperator(bpy.types.Operator):
bl_idname = "bim.load_project_file_handler"
bl_label = "Import .ifc file"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
2024-05-27 16:16:57 +05:00
2026-04-10 18:02:44 +05:00
directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"})
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
directory: str
files: list[bpy.types.OperatorFileListElement]
2024-05-27 16:16:57 +05:00
2025-01-22 12:55:16 +05:00
def invoke(self, context, event):
# Keeping code in .invoke() as we'll probably add some
# popup windows later.
def clean_up_path(path: str) -> str:
# In Blender 4.5.6 there was a bug producing unncesseary double slash prefix
2026-03-03 14:29:40 +05:00
# breaking the paths. Issue is not present in 5.0+ and is fixed in 4.5.7.
# https://projects.blender.org/blender/blender/issues/153822
if bpy.app.version == (4, 5, 6):
blender_prefix = "//"
if path.startswith(blender_prefix):
return path.removeprefix(blender_prefix)
return path
2025-01-22 12:55:16 +05:00
# `files` contain only .ifc files.
filepath = Path(self.directory)
# If user is just drag'n'dropping a single file -> load it as a new project,
# if they're holding ALT -> link the file/files to the current project.
if event.alt:
# Passing self.files directly results in TypeError.
serialized_files = [{"name": clean_up_path(f.name)} for f in self.files]
2025-01-22 12:55:16 +05:00
return bpy.ops.bim.link_ifc(directory=self.directory, files=serialized_files)
else:
if len(self.files) == 1:
return bpy.ops.bim.load_project(filepath=(filepath / clean_up_path(self.files[0].name)).as_posix())
2025-01-22 12:55:16 +05:00
else:
self.report(
{"INFO"},
"To link multiple IFC files hold ALT while drag'n'dropping them.",
)
return {"FINISHED"}
2024-05-27 16:16:57 +05:00
2025-01-22 12:55:16 +05:00
class BIM_FH_import_ifc(bpy.types.FileHandler):
bl_label = "IFC File Handler"
bl_import_operator = IFCFileHandlerOperator.bl_idname
bl_file_extensions = ".ifc"
# FileHandler won't work without poll_drop defined.
@classmethod
def poll_drop(cls, context):
return True
class MeasureTool(bpy.types.Operator, PolylineOperator):
bl_idname = "bim.measure_tool"
bl_label = "Measure Tool"
2024-09-13 12:35:50 -03:00
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty()
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
measure_type: str
@classmethod
def poll(cls, context):
return context.space_data.type == "VIEW_3D"
def __init__(self, *args, **kwargs):
bpy.types.Operator.__init__(self, *args, **kwargs)
PolylineOperator.__init__(self)
self.input_options = ["D", "A", "X", "Y", "Z"]
self.input_ui = tool.Polyline.create_input_ui(input_options=self.input_options)
def modal(self, context, event):
PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
tool.Blender.update_viewport()
self.handle_lock_axis(context, event)
if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
self.handle_mouse_move(context, event)
return {"PASS_THROUGH"}
2025-01-23 09:27:14 -03:00
custom_instructions = {
2025-01-24 12:25:10 +05:00
"Choose Axis": {"icons": True, "keys": ["EVENT_X", "EVENT_Y", "EVENT_Z"]},
"Choose Plane": {"icons": True, "keys": ["EVENT_SHIFT", "EVENT_X", "EVENT_Y", "EVENT_Z"]},
2025-01-23 09:27:14 -03:00
}
self.handle_instructions(context, custom_instructions)
self.handle_mouse_move(context, event)
2024-09-06 14:43:01 -03:00
self.choose_axis(event, z=True)
2024-08-17 20:29:50 -03:00
self.choose_plane(event)
2024-09-06 14:43:01 -03:00
self.handle_snap_selection(context, event)
2024-08-17 20:29:50 -03:00
single_mode = False
2025-07-24 19:14:26 +05:00
polyline_props = tool.Model.get_polyline_props()
2024-10-05 11:12:03 -03:00
if (
self.measure_type == "SINGLE"
2025-07-24 19:14:26 +05:00
and polyline_props.insertion_polyline
and len(polyline_props.insertion_polyline[0].polyline_points) >= 2
2024-10-05 11:12:03 -03:00
):
single_mode = True
2024-10-05 11:12:03 -03:00
2024-09-11 10:08:03 +05:00
if (
not self.tool_state.is_input_on
and event.value == "RELEASE"
and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}
) or single_mode:
context.workspace.status_text_set(text=None)
2025-05-20 15:52:37 +02:00
self.tool_state.plane_method = None
PolylineDecorator.uninstall()
tool.Polyline.move_polyline_to_measure(context, self.input_ui)
tool.Polyline.clear_polyline()
MeasureDecorator.install(context)
tool.Blender.update_viewport()
return {"FINISHED"}
self.handle_keyboard_input(context, event)
2024-08-27 18:22:43 -03:00
self.handle_inserting_polyline(context, event)
# Add measurement type to the insertion polyline
2025-07-24 19:14:26 +05:00
polyline_data = polyline_props.insertion_polyline
if not polyline_data:
pass
else:
2025-07-24 19:14:26 +05:00
polyline_data = polyline_props.insertion_polyline[0]
measurement_type = tool.Project.get_measure_tool_settings().measurement_type
if not polyline_data.measurement_type:
polyline_data.measurement_type = measurement_type
tool.Polyline.calculate_area(context, self.input_ui)
2024-08-15 18:34:10 -03:00
if event.type == "E":
2025-07-24 19:14:26 +05:00
polyline_props.measurement_polyline.clear()
MeasureDecorator.uninstall()
tool.Blender.update_viewport()
result = self.handle_cancelation(context, event)
if result is not None:
return result
return {"RUNNING_MODAL"}
def invoke(self, context, event):
super().invoke(context, event)
return {"RUNNING_MODAL"}
class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator):
bl_idname = "bim.measure_face_area_tool"
bl_label = "Measure Face Area Tool"
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty()
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
measure_type: str
@classmethod
def poll(cls, context):
return context.space_data.type == "VIEW_3D"
def __init__(self, *args, **kwargs):
bpy.types.Operator.__init__(self, *args, **kwargs)
PolylineOperator.__init__(self)
self.input_options = ["AREA"]
self.input_ui = tool.Polyline.create_input_ui(input_options=self.input_options)
self.clicked_faces = []
self.total_area = 0
if tool.Ifc.get():
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
else:
self.unit_scale = tool.Blender.get_unit_scale()
def modal(self, context, event):
def select_face(mouse_pos):
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, self.objs_2d_bbox)
obj, _, face_index = tool.Raycast.cast_rays_and_get_best_object(
context, event, objs_to_raycast, include_wireframes=False
)
if face_index is not None:
return obj, face_index
return None, None
PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
tool.Blender.update_viewport()
2025-03-13 21:32:13 -03:00
custom_instructions = {
"Select Face": {"icons": True, "keys": ["MOUSE_LMB"]},
2025-03-14 16:33:28 +05:00
"Deselect Face": {"icons": True, "keys": ["EVENT_SHIFT", "MOUSE_LMB"]},
2025-03-13 21:32:13 -03:00
}
custom_info = []
self.handle_instructions(context, custom_instructions, custom_info, overwrite=True)
if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
self.handle_mouse_move(context, event)
return {"PASS_THROUGH"}
self.handle_mouse_move(context, event)
2025-07-24 19:14:26 +05:00
polyline_props = tool.Model.get_polyline_props()
if event.value == "PRESS" and event.type == "LEFTMOUSE":
tool.Blender.update_viewport()
mouse_pos = event.mouse_region_x, event.mouse_region_y
obj, face_index = select_face(mouse_pos)
if face_index is not None:
if obj.data.polygons[face_index] not in self.clicked_faces:
self.clicked_faces.append(obj.data.polygons[face_index])
self.total_area += obj.data.polygons[face_index].area
self.input_ui.set_value("AREA", self.total_area)
2025-07-24 19:14:26 +05:00
polyline_data = polyline_props.insertion_polyline.add()
polyline_data.id = obj.name + str(face_index)
for v_id in obj.data.polygons[face_index].vertices:
vertex = obj.matrix_world @ obj.data.vertices[v_id].co
polyline_point = polyline_data.polyline_points.add()
polyline_point.x = vertex.x
polyline_point.y = vertex.y
polyline_point.z = vertex.z
tool.Blender.update_viewport()
if event.shift and (event.value == "PRESS" and event.type == "LEFTMOUSE"):
mouse_pos = event.mouse_region_x, event.mouse_region_y
obj, face_index = select_face(mouse_pos)
if face_index:
if obj.data.polygons[face_index] in self.clicked_faces:
self.clicked_faces.remove(obj.data.polygons[face_index])
self.total_area -= obj.data.polygons[face_index].area
self.input_ui.set_value("AREA", self.total_area)
2025-07-24 19:14:26 +05:00
polyline_data = polyline_props.insertion_polyline
for i, polyline in enumerate(polyline_data):
if polyline.id == obj.name + str(face_index):
polyline_data.remove(i)
tool.Blender.update_viewport()
if event.value == "RELEASE" and event.type in {"ESC", "RIGHTMOUSE"}:
2025-07-24 19:14:26 +05:00
polyline_props.insertion_polyline.clear()
2025-03-13 21:32:13 -03:00
context.workspace.status_text_set(text=None)
PolylineDecorator.uninstall()
FaceAreaDecorator.uninstall()
tool.Blender.update_viewport()
return {"CANCELLED"}
return {"RUNNING_MODAL"}
def invoke(self, context, event):
super().invoke(context, event)
PolylineDecorator.uninstall()
PolylineDecorator.install(context, ui_only=True)
FaceAreaDecorator.install(context)
return {"RUNNING_MODAL"}
2025-05-20 15:57:03 +02:00
class ClearMeasurement(bpy.types.Operator):
bl_idname = "bim.clear_measurement"
bl_label = "Clear measurement from the screen"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
2025-07-24 19:14:26 +05:00
polyline_props = tool.Model.get_polyline_props()
2026-03-16 15:27:10 +05:00
if len(polyline_props.measurement_polyline) > 0:
return True
cls.poll_message_set("No measurement to clear.")
return False
def execute(self, context):
2025-07-24 19:14:26 +05:00
polyline_props = tool.Model.get_polyline_props()
polyline_props.measurement_polyline.clear()
MeasureDecorator.uninstall()
tool.Blender.update_viewport()
return {"FINISHED"}
class ImageScalingTool(bpy.types.Operator, PolylineOperator):
bl_idname = "bim.image_scaling_tool"
bl_label = "Image Scaling Tool"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.space_data.type == "VIEW_3D"
def __init__(self, *args, **kwargs):
bpy.types.Operator.__init__(self, *args, **kwargs)
PolylineOperator.__init__(self)
self.input_options = ["DISTANCE"]
self.input_ui = tool.Polyline.create_input_ui(input_options=self.input_options)
self.selected_points = []
self.target_object = None
self.current_distance_value = ""
self.is_typing_distance = False
self.calculated_distance = 0.0
if tool.Ifc.get():
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
else:
self.unit_scale = tool.Blender.get_unit_scale()
def modal(self, context, event):
if not self.target_object or not context.active_object or context.active_object != self.target_object:
self.report({"ERROR"}, "Image annotation was deselected. Tool cancelled.")
return self.cancel_tool(context)
PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
tool.Blender.update_viewport()
self.handle_lock_axis(context, event)
if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
self.handle_mouse_move(context, event)
return {"PASS_THROUGH"}
self.handle_custom_instructions(context)
self.handle_mouse_move(context, event)
self.choose_axis(event, z=True)
self.choose_plane(event)
self.handle_snap_selection(context, event)
if event.type == "LEFTMOUSE" and event.value == "PRESS":
if len(self.selected_points) < 2:
snapped_point = self.snapping_points[0]
point_3d = snapped_point["point"].copy()
self.selected_points.append(point_3d)
if len(self.selected_points) == 2:
self.calculate_distance()
self.current_distance_value = f"{self.calculated_distance:.3f}"
self.is_typing_distance = False
self.input_ui.set_value("DISTANCE", self.calculated_distance)
elif len(self.selected_points) == 2:
if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS":
return self.apply_scaling(context)
if event.unicode and event.unicode.isprintable() and event.value == "PRESS":
if event.unicode.isdigit() or event.unicode == ".":
if not self.is_typing_distance:
self.current_distance_value = event.unicode
self.is_typing_distance = True
else:
self.current_distance_value += event.unicode
distance_value = float(self.current_distance_value)
self.input_ui.set_value("DISTANCE", distance_value)
elif event.type in {"BACK_SPACE", "DEL"} and event.value == "PRESS":
if len(self.current_distance_value) > 0:
self.current_distance_value = self.current_distance_value[:-1]
distance_value = (
float(self.current_distance_value) if self.current_distance_value else self.calculated_distance
)
self.input_ui.set_value("DISTANCE", distance_value)
self.handle_keyboard_input(context, event)
result = self.handle_cancelation(context, event)
if result is not None:
return result
return {"RUNNING_MODAL"}
def invoke(self, context, event):
active_obj = context.active_object
self.target_object = active_obj
super().invoke(context, event)
return {"RUNNING_MODAL"}
2026-03-16 14:07:50 +05:00
def cancel_tool(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
context.workspace.status_text_set(text=None)
if hasattr(self, "tool_state"):
self.tool_state.plane_method = None
PolylineDecorator.uninstall()
tool.Blender.update_viewport()
return {"CANCELLED"}
2026-03-16 14:07:50 +05:00
def handle_custom_instructions(self, context: bpy.types.Context) -> None:
if len(self.selected_points) == 0:
instruction_text = "Click First Point on Image"
elif len(self.selected_points) == 1:
instruction_text = "Click Second Point on Image"
elif len(self.selected_points) == 2:
if self.is_typing_distance:
instruction_text = f"Distance: {self.current_distance_value} - Press Enter to Apply"
else:
instruction_text = f"Measured: {self.calculated_distance:.3f} - Type New Distance or Press Enter"
else:
instruction_text = "Image Scaling Tool"
context.workspace.status_text_set(text=instruction_text)
2026-03-16 14:07:50 +05:00
def calculate_distance(self) -> None:
if len(self.selected_points) == 2:
point1 = self.selected_points[0]
point2 = self.selected_points[1]
distance_3d = (point2 - point1).length
self.calculated_distance = distance_3d / self.unit_scale
2026-03-16 14:07:50 +05:00
def apply_scaling(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
if len(self.selected_points) != 2:
self.report({"ERROR"}, "Two points must be selected")
return {"CANCELLED"}
target_distance = float(self.current_distance_value)
if target_distance <= 0:
self.report({"ERROR"}, "Distance must be positive")
return {"CANCELLED"}
if self.calculated_distance <= 0:
self.report({"ERROR"}, "Selected points are too close together")
return {"CANCELLED"}
scale_factor = target_distance / self.calculated_distance
if self.target_object:
import bmesh
mesh = self.target_object.data
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.scale(bm, vec=(scale_factor, scale_factor, 1.0), verts=bm.verts)
bm.to_mesh(mesh)
bm.free()
tool.Loader.load_generated_uv_map(mesh)
mesh.update()
element = tool.Ifc.get_entity(self.target_object)
if element and element.Representation:
for representation in element.Representation.Representations:
for item in representation.Items:
if item.is_a("IfcPolygonalFaceSet") and item.Coordinates:
new_coords = []
for vertex in mesh.vertices:
co = self.target_object.matrix_world @ vertex.co
new_coords.append([co.x, co.y, co.z])
item.Coordinates.CoordList = new_coords
self.report({"INFO"}, f"Applied scale factor: {scale_factor:.4f}")
context.workspace.status_text_set(text=None)
self.tool_state.plane_method = None
PolylineDecorator.uninstall()
tool.Blender.update_viewport()
return {"FINISHED"}
2026-01-12 16:07:34 +05:00
class LoadBlendMetadataAndIFC(bpy.types.Operator):
bl_idname = "bim.load_blend_metadata_and_ifc"
bl_label = "Load Blend Metadata and IFC"
bl_options = {"REGISTER", "UNDO"}
filepath: bpy.props.StringProperty(name="IFC File Path", default="")
2026-03-16 14:07:50 +05:00
if TYPE_CHECKING:
filepath: str
def execute(self, context):
ifc_file = self.filepath
if not ifc_file:
props = tool.Blender.get_bim_props()
ifc_file = getattr(props, "ifc_file", None)
if not ifc_file:
self.report({"WARNING"}, "No IFC file path set.")
return {"CANCELLED"}
suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix
if ifc_file.lower().endswith(".ifc"):
metadata_path = ifc_file[:-4] + suffix
else:
metadata_path = ifc_file + suffix
# Define a handler to load the IFC project after the blend file is loaded and context is restored
@persistent
def load_handler(*args):
bpy.app.handlers.load_post.remove(load_handler)
# After loading metadata, clear blend warning (no geometry loaded yet)
props = tool.Blender.get_bim_props()
props.has_blend_warning = False
# Load the IFC file into the current session (preserve layout)
bpy.ops.bim.load_project(filepath=ifc_file, should_start_fresh_session=False)
# Disable editing styles
bpy.ops.bim.disable_editing_styles()
self.report({"INFO"}, f"Loaded metadata and IFC: {metadata_path}, {ifc_file}")
bpy.app.handlers.load_post.append(load_handler)
bpy.ops.wm.open_mainfile(filepath=metadata_path)
2026-01-12 16:07:34 +05:00
return {"FINISHED"}
class GenerateUVMap(bpy.types.Operator):
bl_idname = "bim.generate_uv_map"
bl_label = "Generate UV Map"
bl_description = "Generate UV map for selected mesh."
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
obj = context.active_object
if not obj or not isinstance(obj.data, bpy.types.Mesh):
self.report({"ERROR"}, "No valid mesh selected.")
return {"CANCELLED"}
tool.Loader.load_generated_uv_map(obj.data)
self.report({"INFO"}, "Generated UV map for selected mesh.")
return {"FINISHED"}