Files
IfcOpenShell/src/bonsai/bonsai/bim/module/project/operator.py
T
Gorgious56 ee63137c6c Discard previews on IFC save + harden preview-active gate
Save-path:
* SaveProject._execute (project/operator.py) now calls
  preview_base.discard_pending_previews(context.scene) right after
  tool.Parametric.commit_pending_edits(). Previews are session-
  transient — discard rather than commit. Sibling gizmo polls gate
  on each preview's is_active flag; a stuck flag persisted through
  the save would silently hide them on reload. Mirrors the pattern
  already in gizmos-8088.

Preview-active gate hardening:
* preview_base.get_preview_props tolerates contexts without a
  ``scene`` attribute. Pre-existing tests use SimpleNamespace mocks
  for the context; the previous getattr(context.scene, ...) raised
  AttributeError before the inner default kicked in.

Test update:
* test_wall_header_refresh.test_geom_generation_invalidates_wall_geom_cache
  patches tool.Wall.read_geometry instead of the now-deleted local
  wall._read_wall_geometry (commit 7e5e7b8d6 migrated the call site).

Generated with the assistance of an AI coding tool.
2026-05-31 19:10:48 +02:00

3424 lines
137 KiB
Python

# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import datetime
import json
import logging
import os
import subprocess
import tempfile
import time
import traceback
from collections import defaultdict
from math import radians
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Union, get_args
import bpy
import ifcopenshell
import ifcopenshell.api.attribute
import ifcopenshell.api.document
import ifcopenshell.api.nest
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.element
import ifcopenshell.util.file
import ifcopenshell.util.geolocation
import ifcopenshell.util.representation
import ifcopenshell.util.selector
import ifcopenshell.util.shape
import ifcopenshell.util.shape_builder
import ifcopenshell.util.unit
import numpy as np
from bpy.app.handlers import persistent
from bpy_extras.io_utils import ExportHelper, ImportHelper
from bpy_extras.view3d_utils import (
region_2d_to_origin_3d,
region_2d_to_vector_3d,
)
from mathutils import Vector
import bonsai.bim.handler
import bonsai.bim.helper
import bonsai.core.project as core
import bonsai.tool as tool
from bonsai.bim import export_ifc, import_ifc
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.model import preview_base
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
from bonsai.bim.module.project.decorator import (
ClippingPlaneDecorator,
MeasureDecorator,
ProjectDecorator,
)
from bonsai.bim.module.project.prop import BreadcrumbType
from bonsai.bim.ui import IFCFileSelector
if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums
from bonsai.bim.module.project.prop import Link
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"
preset: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(PresetType)])
if TYPE_CHECKING:
preset: PresetType
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
bpy.ops.wm.read_homefile()
pprops = tool.Project.get_project_props()
bim_props = tool.Blender.get_bim_props()
assert bpy.context.scene
if self.preset == "metric_m":
pprops.export_schema = "IFC4"
bpy.context.scene.unit_settings.system = "METRIC"
bpy.context.scene.unit_settings.length_unit = "METERS"
bim_props.area_unit = "SQUARE_METRE"
bim_props.volume_unit = "CUBIC_METRE"
pprops.template_file = "0"
elif self.preset == "metric_mm":
pprops.export_schema = "IFC4"
bpy.context.scene.unit_settings.system = "METRIC"
bpy.context.scene.unit_settings.length_unit = "MILLIMETERS"
bim_props.area_unit = "SQUARE_METRE"
bim_props.volume_unit = "CUBIC_METRE"
pprops.template_file = "0"
elif self.preset == "imperial_ft":
pprops.export_schema = "IFC4"
bpy.context.scene.unit_settings.system = "IMPERIAL"
bpy.context.scene.unit_settings.length_unit = "FEET"
bim_props.area_unit = "square foot"
bim_props.volume_unit = "cubic foot"
pprops.template_file = "0"
elif self.preset == "demo":
pprops.export_schema = "IFC4"
bpy.context.scene.unit_settings.system = "METRIC"
bpy.context.scene.unit_settings.length_unit = "MILLIMETERS"
bim_props.area_unit = "SQUARE_METRE"
bim_props.volume_unit = "CUBIC_METRE"
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"}
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):
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)
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."
)
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
append_all: bpy.props.BoolProperty(default=False)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
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()
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()
if self.append_all:
bpy.ops.bim.append_entire_library()
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"])
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"
bl_description = "Refresh the library browser"
bl_options = {"UNDO"}
def execute(self, context):
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
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"}
# 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)
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"}
element_name: bpy.props.StringProperty()
breadcrumb_type: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(BreadcrumbType)])
library_id: bpy.props.IntProperty()
if TYPE_CHECKING:
element_name: str
breadcrumb_type: BreadcrumbType
library_id: int
def execute(self, context):
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
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()
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]:
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))
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:
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"}
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"
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
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):
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"}
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)
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
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"}
class AppendEntireLibrary(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.append_entire_library"
bl_label = "Append Entire Library"
@classmethod
def poll(cls, context):
return tool.Ifc.get()
def _execute(self, context):
self.file = tool.Ifc.get()
self.library = IfcStore.library_file
query = ", ".join(tool.Project.get_appendable_asset_types())
lib_elements = ifcopenshell.util.selector.filter_elements(self.library, query)
for element in lib_elements:
bpy.ops.bim.append_library_element(definition=element.id())
return {"FINISHED"}
class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.append_library_element_by_query"
bl_label = "Append Library Element By Query"
query: bpy.props.StringProperty(name="Query")
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")
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()
assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"})
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())
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:
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
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
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)
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()
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
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()
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)
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)
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)
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)
props.is_editing_project_library = False
bpy.ops.bim.refresh_library()
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()
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):
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"}
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):
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"}
bl_description = "Cancel unsaved header information"
def execute(self, context):
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"
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,
)
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
filename_ext = ".ifc"
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
@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"
if not filepath.exists():
tooltip += "\nFile does not exist"
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}"
file_stat = filepath.stat()
tooltip += f"\nModified: {get_modified_date(file_stat.st_mtime)}"
tooltip += f"\nSize: {get_file_size(file_stat.st_size)}"
return tooltip
def execute(self, context):
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()
# First, load the IFC file temporarily to check for metadata document
temp_ifc = None
has_metadata_doc = False
try:
temp_ifc = ifcopenshell.open(str(filepath))
for doc in temp_ifc.by_type("IfcDocumentInformation"):
if getattr(doc, "Scope", None) == "BLEND_METADATA":
has_metadata_doc = True
break
except:
pass
finally:
temp_ifc = None
if has_metadata_doc:
suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix
if str(filepath).lower().endswith(".ifc"):
metadata_path = Path(str(filepath)[:-4] + suffix)
else:
metadata_path = Path(str(filepath) + suffix)
if metadata_path.exists() and metadata_path.is_file():
try:
bpy.ops.bim.load_blend_metadata_and_ifc(filepath=filepath)
self.report({"INFO"}, f"Loaded metadata file: {metadata_path.name}")
return {"FINISHED"}
except Exception as e:
self.report({"WARNING"}, f"Failed to load metadata file, using regular load: {e}")
@persistent
def load_handler(*args):
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)
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"}
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
metadata_doc = tool.Project.get_metadata_document_information()
props.should_save_metadata_for_this_file = metadata_doc is not None
tool.Blender.register_toolbar()
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
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:
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)
def draw(self, context):
if self.use_relative_path:
self.should_start_fresh_session = False
self.layout.prop(self, "is_advanced")
self.layout.prop(self, "should_start_fresh_session")
self.layout.prop(self, "import_without_ifc_data")
IFCFileSelector.draw(self, context)
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()
return {"FINISHED"}
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"
@classmethod
def poll(cls, context):
props = tool.Blender.get_bim_props()
if not props.ifc_file:
cls.poll_message_set("IFC project need to be loaded and saved on the disk.")
return False
return True
def execute(self, context):
props = tool.Blender.get_bim_props()
bpy.ops.bim.load_project(should_start_fresh_session=True, filepath=props.ifc_file)
return {"FINISHED"}
class LoadProjectElements(bpy.types.Operator):
bl_idname = "bim.load_project_elements"
bl_label = "Load Project Elements"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
self.props = tool.Project.get_project_props()
self.file = tool.Ifc.get()
start = time.time()
logger = logging.getLogger("ImportIFC")
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,
)
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))
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"}
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
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
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)
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
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
def get_whitelist_elements(self) -> set[ifcopenshell.entity_instance]:
return set(ifcopenshell.util.selector.filter_elements(self.file, self.props.filter_query))
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)
)
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)
if TYPE_CHECKING:
should_select: bool
def execute(self, context):
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):
bl_idname = "bim.link_ifc"
bl_label = "Link IFC"
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",
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"
if TYPE_CHECKING:
filepath: str
files: list[bpy.types.OperatorFileListElement]
directory: str
filter_glob: str
use_relative_path: bool
use_cache: bool
query: str
def draw(self, context):
assert self.layout
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")
def _execute(self, context):
start = time.time()
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 {}
for filename in files:
if not filename or not filename.strip():
continue
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
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)
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unlink_ifc"
bl_label = "Unlink IFC"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
def _execute(self, context):
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)
class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unload_link"
bl_label = "Unload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
link_index: bpy.props.IntProperty(name="Link Index")
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]):
bpy.data.libraries.remove(library)
link.is_loaded = False
ProjectDecorator.uninstall()
class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.load_link"
bl_label = "Load Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file"
link_index: bpy.props.IntProperty(name="Link Index")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty()
if TYPE_CHECKING:
link_index: int
use_cache: bool
query: str
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"}
self.filepath_ = filepath
if filepath.suffix.lower().endswith(".ifc"):
return self.link_ifc()
def link_blend(self, filepath: Path) -> None:
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:
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
empty.matrix_world = tool.Project.calculate_link_matrix(self.link)
tool.Project.set_link_empty_handle(self.link, empty)
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
def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5")
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)
if not blend_filepath.exists():
pprops = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
code = f"""
import bpy
import sys
def run():
import bonsai.tool as tool
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}"
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}"
# 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)
"""
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.")
if not blend_filepath.exists() or blend_filepath.stat().st_mtime < t:
return {"CANCELLED"}
self.set_model_origin_from_link()
self.set_georeferencing_indicator()
self.link_blend(blend_filepath)
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
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)
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"
class ReloadLink(bpy.types.Operator):
bl_idname = "bim.reload_link"
bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
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"}
class ToggleLinkSelectability(bpy.types.Operator):
bl_idname = "bim.toggle_link_selectability"
bl_label = "Toggle Link Selectability"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
def execute(self, context):
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"}
def get_linked_collections(self) -> list[bpy.types.Collection]:
return [
c
for c in bpy.data.collections
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"
link_index: bpy.props.IntProperty(name="Link Index")
mode: bpy.props.EnumProperty(
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):
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"}
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:
objs = filter(lambda obj: "IfcOpeningElement" not in obj.name, collection.all_objects)
for obj in objs:
obj.display_type = display_type
if handle := tool.Project.get_link_empty_handle(link):
handle.display_type = display_type
def toggle_visibility(self, link: "Link") -> None:
linked_collections = self.get_linked_collections()
link.is_hidden = (is_hidden := not link.is_hidden)
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)
def get_linked_collections(self) -> list[bpy.types.Collection]:
return [
c
for c in bpy.data.collections
if "IfcProject" in c.name and c.library and Path(c.library.filepath) == self.library_filepath
]
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
assert link
link.is_editing = True
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
assert link
link.is_editing = False
obj = tool.Project.get_link_empty_handle(link)
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
assert link
link.is_editing = False
obj = tool.Project.get_link_empty_handle(link)
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
obj.matrix_world = tool.Project.calculate_link_matrix(link)
tool.Geometry.lock_object(obj)
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"
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
def execute(self, context):
props = tool.Project.get_project_props()
link = props.links[self.link_index]
handle = tool.Project.get_link_empty_handle(link)
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"}
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")
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"
supported_filexts = (".ifc", ".ifczip", ".ifcjson")
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)
if TYPE_CHECKING:
filter_glob: str
json_version: str
json_compact: bool
should_save_as: bool
use_relative_path: bool
@classmethod
def poll(cls, context):
return tool.Ifc.get()
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")
layout.separator()
layout.label(text="Supported formats for export:")
layout.label(text=",".join(self.supported_filexts))
def invoke(self, context, event):
if not tool.Ifc.get():
bpy.ops.wm.save_mainfile("INVOKE_DEFAULT")
return {"FINISHED"}
self.use_relative_path = tool.Project.get_project_props().use_relative_project_path
props = tool.Blender.get_bim_props()
filepath = props.ifc_file
if not filepath or self.should_save_as:
return ExportHelper.invoke(self, context, event)
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
return self.execute(context)
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):
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:
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:
tool.Ifc.get().history_size = old_history_size
context.preferences.edit.undo_steps = old_undo_steps
return {"FINISHED"}
def _execute(self, context):
committed, failed_commits = tool.Parametric.commit_pending_edits()
# Previews are session-transient — discard rather than commit. Sibling
# gizmo polls gate on each preview's is_active flag, and a stuck flag
# persisted through the save would silently hide them on reload.
preview_base.discard_pending_previews(context.scene)
# Suffix is appended to the IFC save-success report below so the auto-commit
# info isn't immediately overwritten by the success message in Blender's
# status bar (only the latest self.report({"INFO"}, ...) sticks).
commit_suffix = f" (auto-committed {committed} pending parametric edit(s))" if committed else ""
if failed_commits:
names = ", ".join(o.name for o in failed_commits)
msg = f"Auto-commit failed for {len(failed_commits)} object(s): {names}"
print(f"Bonsai: {msg} (their drafts are NOT saved to the IFC file).")
self.report({"ERROR"}, msg)
start = time.time()
logger = logging.getLogger("ExportIFC")
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
if not tool.Project.get_metadata_document_information():
tool.Project.create_metadata_document_information(metadata_filename)
else:
tool.Project.update_metadata_document_information(metadata_filename)
else:
if not pprops.should_save_metadata_for_this_file:
tool.Project.remove_metadata_document_information()
ifc_exporter = export_ifc.IfcExporter(settings)
print("Starting export")
settings.logger.info("Starting export")
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))
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("//"))
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)
bim_props.is_dirty = False
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
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}{commit_suffix}',
)
except Exception as e:
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{commit_suffix}',
)
bonsai.bim.handler.refresh_ui_data()
@classmethod
def description(cls, context, properties):
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
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
import ifcpatch
start = time.time()
pprops = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
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 = {}
blender_mats: dict[tuple[float, float, float, float], bpy.types.Material] = {}
default_mat = np.array([[1, 1, 1, 1]], dtype=np.float32)
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
# 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()
assert isinstance(shape, W.TriangulationElement)
results.add(self.file.by_id(shape.id))
geometry = shape.geometry
# Elements with a lot of geometry benefit from instancing to save memory
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
ms = np.vstack([default_mat, ifcopenshell.util.shape.get_material_colors(shape.geometry)])
mi = ifcopenshell.util.shape.get_faces_material_style_ids(shape.geometry)
for geom_material_idx, geom_material in enumerate(shape.geometry.materials):
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),
)
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
vs = vs[:, :3].ravel()
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"}
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)
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
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
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
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)
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
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
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):
assert context.area
return context.area.type == "VIEW_3D"
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
LinksData.linked_data = {}
props = tool.Project.get_project_props()
props.queried_obj = None
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()
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)
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"}
if not tool.Project.Link.is_linked_element(obj):
self.report({"INFO"}, "Object is not a linked IFC element.")
return {"FINISHED"}
guid = tool.Project.Link.get_guid_by_face_index(obj, face_index)
assert guid is not None
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"}
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"}
def _execute(self, context):
from bonsai.bim.module.project.data import LinksData
props = tool.Project.get_project_props()
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"}
queried_obj = props.queried_obj
assert queried_obj
ifc_file = tool.Ifc.get()
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)
element = ifcopenshell.api.project.append_asset(
tool.Ifc.get(),
library=linked_ifc_file,
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:
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 = []
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"}
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
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
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"}
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)
self.total_planes = 0
self.camera = None
def invoke(self, context, event):
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
def modal(self, context, event):
should_refresh = False
props = tool.Project.get_project_props()
self.clean_deleted_planes(context)
for clipping_plane in props.clipping_planes:
if clipping_plane.obj and tool.Ifc.is_moved(clipping_plane.obj, ifc_only=False):
should_refresh = True
break
if (camera := context.scene.camera) and camera.visible_get() and tool.Ifc.get_entity(camera):
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
total_planes = len(props.clipping_planes)
if should_refresh or total_planes != self.total_planes:
self.camera = camera
self.refresh_clipping_planes(context)
for clipping_plane in props.clipping_planes:
if clipping_plane.obj:
tool.Geometry.record_object_position(clipping_plane.obj)
self.total_planes = total_planes
return {"PASS_THROUGH"}
def clean_deleted_planes(self, context: bpy.types.Context) -> None:
props = tool.Project.get_project_props()
while True:
for i, clipping_plane in enumerate(props.clipping_planes):
if clipping_plane.obj:
try:
clipping_plane.obj.name
except:
props.clipping_planes.remove(i)
break
else:
props.clipping_planes.remove(i)
break
else:
break
def refresh_clipping_planes(self, context):
from itertools import cycle
import bmesh
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
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):
data.use_clip_planes = False
else:
with bpy.context.temp_override(area=area, region=region):
bpy.ops.view3d.clip_border()
clip_planes = []
for clipping_plane in tool.Project.get_project_props().clipping_planes:
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)
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
props = tool.Project.get_project_props()
if len(props.clipping_planes) > 5:
self.report({"INFO"}, "Maximum of six clipping planes allowed.")
return {"FINISHED"}
tool.Blender.update_all_viewports(context)
assert context.region and context.region_data
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)
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)
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)
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
new = tool.Project.get_project_props().clipping_planes.add()
new.obj = plane_obj
tool.Blender.set_active_object(plane_obj)
ClippingPlaneDecorator.install(context)
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)
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
def execute(self, context):
obj = context.active_object
if obj in tool.Project.get_project_props().clipping_planes_objs:
obj.rotation_euler[0] += radians(180)
obj.rotation_euler[0] %= radians(360)
context.view_layer.update()
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:
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)
clipping_planes = tool.Project.get_project_props().clipping_planes
for clipping_plane in clipping_planes:
obj = clipping_plane.obj
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):
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()
obj = props.clipping_planes_objs[-1]
obj.name = name
obj.location = values["location"]
obj.rotation_euler = values["rotation"]
return {"FINISHED"}
class IFCFileHandlerOperator(bpy.types.Operator):
bl_idname = "bim.load_project_file_handler"
bl_label = "Import .ifc file"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"})
if TYPE_CHECKING:
directory: str
files: list[bpy.types.OperatorFileListElement]
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
# 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
# `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]
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())
else:
self.report(
{"INFO"},
"To link multiple IFC files hold ALT while drag'n'dropping them.",
)
return {"FINISHED"}
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"
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty()
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"}
custom_instructions = {
"Choose Axis": {"icons": True, "keys": ["EVENT_X", "EVENT_Y", "EVENT_Z"]},
"Choose Plane": {"icons": True, "keys": ["EVENT_SHIFT", "EVENT_X", "EVENT_Y", "EVENT_Z"]},
}
self.handle_instructions(context, custom_instructions)
self.handle_mouse_move(context, event)
self.choose_axis(event, z=True)
self.choose_plane(event)
self.handle_snap_selection(context, event)
single_mode = False
polyline_props = tool.Model.get_polyline_props()
if (
self.measure_type == "SINGLE"
and polyline_props.insertion_polyline
and len(polyline_props.insertion_polyline[0].polyline_points) >= 2
):
single_mode = True
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)
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)
self.handle_inserting_polyline(context, event)
# Add measurement type to the insertion polyline
polyline_data = polyline_props.insertion_polyline
if not polyline_data:
pass
else:
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)
if event.type == "E":
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()
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()
custom_instructions = {
"Select Face": {"icons": True, "keys": ["MOUSE_LMB"]},
"Deselect Face": {"icons": True, "keys": ["EVENT_SHIFT", "MOUSE_LMB"]},
}
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)
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)
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)
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"}:
polyline_props.insertion_polyline.clear()
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"}
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):
polyline_props = tool.Model.get_polyline_props()
if len(polyline_props.measurement_polyline) > 0:
return True
cls.poll_message_set("No measurement to clear.")
return False
def execute(self, context):
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"}
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"}
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)
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
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"}
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="")
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)
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"}