Files
IfcOpenShell/src/bonsai/bonsai/tool/blender.py
T
Gorgious56 046917f75d Generalise opening gizmos + DRY toolbar plumbing
Add openings — GizmoWallAddOpening only fired when a wall was active +
co-selected with a non-host; slabs and roofs got no in-viewport handle.
GizmoHostAddOpening covers all three host types via is_supported_host,
dispatching walls to the axis-projection anchor and slabs/roofs to a
world-Z anchor lifted just above the host's top face (predictable
height regardless of the void's vertical position).

Show openings on hosts with their own parametric-edit toolbar —
GizmoRoofEdition gains an idle-row toggle_openings_gizmo parallel to
the wall's, parked at the cancel-slot X next to the pen. Visible only
when the host carries HasOpenings and the edit triad is idle. Roof
overrides get_element_height to return the mesh's world-AABB top in
object-local Z, so the WHOLE pen-row anchors visibly above sloped or
stepped roof bodies. The wall's idle-row toggle now also hides when
HasOpenings is empty.

Show openings on hosts WITHOUT a parametric-edit toolbar —
GizmoHostToggleOpenings scoped strictly to the fallback case: a single
host selected, HasOpenings non-empty, NOT a path-connectable wall, NOT
a parametric roof. Covers slabs today plus any foreign-authored IfcRoof
without BBIM_Roof. Anchored at object origin XY + world-AABB top Z.
When slab parametric-edit eventually lands, the slab predicate joins
the exclusion list and this gizmo's poll narrows automatically.

Operator move — ToggleWallOpenings was already host-agnostic; renamed
to ToggleHostOpenings in opening.py (bl_idname bim.toggle_host_openings).
Three callers (the wall idle-row binding, GizmoWallFilletToggleOpenings,
and workspace.py's hotkey_A_O for Alt+O) now route through the renamed
operator. The Alt+O binding is surfaced in the operator's
bl_description so it appears in F3 search and hover tooltips.

DRY refactors —
* GizmoWallAddOpening deleted (subsumed by GizmoHostAddOpening)
* tool.Blender.get_object_world_bounding_box added as the world-AABB
  sibling of the existing local helper; 3 inline call sites in
  tool/misc.py (set_object_origin_to_bottom, scale_object_to_height)
  and gizmos.py adopt it (2 other sites in drawing/operator.py and
  project/operator.py inherently need raw transformed corners for
  per-corner plane / NDC tests — not AABB candidates)
* BaseParametricGizmoGroup gains setup_pen_row_toggle_openings_icon +
  update_pen_row_toggle_openings_icon; wall + roof + any future host
  gizmo wire up the idle-row toggle with two one-line calls
* _resolve_active_host shared poll prologue between the two host
  gizmos (gate + selection count + active-in-selected + entity lookup
  + supported-host check)
* HasOpenings non-empty checks at 3 sites route through
  tool.Geometry.has_openings
* hotkey_A_O body collapsed to bpy.ops.bim.toggle_host_openings()

The forward-compat AST guard pinning "must accept fillet-corner walls"
retargets from GizmoWallAddOpening.poll to is_supported_host.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:44 +02:00

2404 lines
100 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 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.
from __future__ import annotations
import contextlib
import importlib
import os
import platform
import subprocess
import sys
import tempfile
import traceback
import types
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
from datetime import datetime
from functools import cache, lru_cache
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Literal,
NamedTuple,
Optional,
TypeVar,
Union,
assert_never,
)
import bmesh
import bpy
import ifcopenshell.util.element
import numpy as np
import numpy.typing as npt
from ifcopenshell import entity_instance
from mathutils import Matrix, Vector
import bonsai.bim
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums
from sun_position.properties import SunPosProperties
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
from bonsai.bim.module.attribute.prop import BIMAttributeProperties
from bonsai.bim.module.constraint.prop import (
BIMConstraintProperties,
BIMObjectConstraintProperties,
)
from bonsai.bim.module.covetool.prop import CoveToolProperties
from bonsai.bim.module.csv.prop import CsvProperties
from bonsai.bim.module.diff.prop import DiffProperties
from bonsai.bim.module.fm.prop import BIMFMProperties
from bonsai.bim.module.light.prop import (
BIMSolarProperties,
RadianceExporterProperties,
)
from bonsai.bim.prop import (
BIMAreaProperties,
BIMCollectionProperties,
BIMObjectProperties,
BIMProperties,
BIMTabProperties,
)
T = TypeVar("T")
VIEWPORT_ATTRIBUTES = [
"view_matrix",
"view_distance",
"view_perspective",
"use_box_clip",
"use_clip_planes",
"is_perspective",
"show_sync_view",
"clip_planes",
]
OBJECT_DATA_TYPE = Union[bpy.types.Mesh, bpy.types.Curve, bpy.types.Camera]
_RAILING_MODIFIER_IFC_CLASSES = ("IfcRailing", "IfcRailingType")
_STAIR_MODIFIER_IFC_CLASSES = (
"IfcStairFlight",
"IfcStairFlightType",
"IfcMember",
"IfcMemberType",
"IfcStair",
"IfcStairType",
)
_WINDOW_MODIFIER_IFC_CLASSES = ("IfcWindow", "IfcWindowType", "IfcWindowStyle")
_DOOR_MODIFIER_IFC_CLASSES = ("IfcDoor", "IfcDoorType", "IfcDoorStyle")
_ROOF_MODIFIER_IFC_CLASSES = ("IfcRoof", "IfcRoofType")
class Blender(bonsai.core.tool.Blender):
OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE = ("MESH", "CURVE", "SURFACE", "META", "FONT", "LATTICE", "ARMATURE")
OBJECT_TYPES_THAT_SUPPORT_EDIT_GPENCIL_MODE = ("GPENCIL",)
TYPE_MANAGER_ICON = "LIGHTPROBE_VOLUME"
SEQUENCE_COLOR_SCHEME_ICON: Literal["STRIP_COLOR_03"] = ( # pyright: ignore[reportAssignmentType]
"STRIP_COLOR_03" if bpy.app.version >= (4, 4, 0) else "SEQUENCE_COLOR_04"
)
BLENDER_ENUM_ITEM = Union[tuple[str, str, str], tuple[str, str, str, int], tuple[str, str, str, str, int], None]
"""
Options:
- (identifier, name, description)
- (identifier, name, description, number)
- (identifier, name, description, icon, number)
"""
BLENDER_ENUM_ITEMS = Iterable[BLENDER_ENUM_ITEM]
BLENDER_5 = bpy.app.version >= (5, 0, 0)
@classmethod
def activate_camera(cls, obj: bpy.types.Object) -> None:
area = cls.get_view3d_area()
assert area
assert isinstance((space := area.spaces[0]), bpy.types.SpaceView3D)
is_local_view = space.local_view is not None
assert bpy.context.screen and bpy.context.scene
if is_local_view:
# Turn off local view before activating drawing, and then turn it on again.
for a in bpy.context.screen.areas:
if a.type == "VIEW_3D":
override = {"area": a, "region": a.regions[-1], "space": a.spaces[0], "scene": bpy.context.scene}
with bpy.context.temp_override(**override):
bpy.ops.view3d.localview()
bpy.context.scene.camera = obj
else:
bpy.context.scene.camera = obj
assert space.region_3d
space.region_3d.view_perspective = "CAMERA"
@classmethod
def get_active_area_props(cls, context: bpy.types.Context) -> BIMAreaProperties | BIMTabProperties:
FULLSCREEN_SUFFIX = "-nonnormal" # Ctrl-space temporary fullscreen
assert (screen := context.screen)
try:
if screen.name.endswith(FULLSCREEN_SUFFIX):
screen = bpy.data.screens[screen.name.removesuffix(FULLSCREEN_SUFFIX)]
# The original area object has its type changed to "EMPTY" apparently
index = [a.type for a in screen.areas].index("EMPTY")
return cls.get_area_props(screen)[index]
assert (area := context.area)
return cls.get_area_props(screen)[screen.areas[:].index(area)]
except IndexError:
# Fallback in case areas aren't setup yet.
return cls.get_tab_props(screen)
@classmethod
def set_active_object(cls, obj: bpy.types.Object) -> None:
"""Set active object and select it."""
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
@classmethod
def clear_active_object(cls) -> None:
"""Clear active object, object is not unselected."""
bpy.context.view_layer.objects.active = None
@classmethod
def setup_tabs(cls) -> None:
# https://blender.stackexchange.com/questions/140644/how-can-make-the-state-of-a-boolean-property-relative-to-the-3d-view-area
for screen in bpy.data.screens:
area_props = cls.get_area_props(screen)
if len(area_props) == 20:
continue
area_props.clear()
for i in range(20): # 20 is an arbitrary value of split areas
area_props.add()
@classmethod
def should_show_panel(cls, context: bpy.types.Context, tab: str, panel: str) -> bool:
aprops = cls.get_active_area_props(context)
if aprops.path_from_id() == "BIMAreaProperties" and context.area.spaces.active.search_filter:
return True
if (is_bookmark_tab := aprops.tab == "BOOKMARK") or aprops.tab == tab:
bprops = tool.Blender.get_bim_props()
if not (panel_visibility := bprops.panel_visibilities.get(panel)):
return not is_bookmark_tab
if is_bookmark_tab:
if panel_visibility.is_bookmarked:
return True
elif panel_visibility.is_visible:
return True
return False
@classmethod
def is_default_scene(cls) -> bool:
if len(bpy.context.scene.objects) != 3:
return False
if {obj.type for obj in bpy.context.scene.objects} == {"MESH", "LIGHT", "CAMERA"}:
return True
return False
@classmethod
def get_name(cls, ifc_class: str, name: str) -> str:
if not bpy.data.objects.get(f"{ifc_class}/{name}"):
return name
i = 2
while bpy.data.objects.get(f"{ifc_class}/{name} {i}"):
i += 1
return f"{name} {i}"
@classmethod
def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]:
"""Gets the active object
:param is_selected: If true, the active object also needs to be selected.
"""
if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active):
if not is_selected:
return obj
if obj.select_get():
return obj
@classmethod
def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]:
"""Get selected objects
:param include_active: If true, the active object is included regardless if it is also selected.
"""
if selected_objects := getattr(bpy.context, "selected_objects", None):
if include_active and (active_obj := cls.get_active_object()):
return set(selected_objects + [active_obj])
return set(selected_objects)
if include_active and (active_obj := cls.get_active_object()):
return {active_obj}
return set()
@classmethod
def create_ifc_object(
cls, ifc_class: str, name: Optional[str] = None, data: Optional[OBJECT_DATA_TYPE] = None
) -> bpy.types.Object:
name = name or "My " + ifc_class
name = cls.get_name(ifc_class, name)
obj = bpy.data.objects.new(name, data)
bpy.ops.bim.assign_class(obj=obj.name, ifc_class=ifc_class)
return obj
@classmethod
def get_obj_ifc_definition_id(
cls,
obj: Optional[str] = None,
obj_type: Optional[tool.Ifc.OBJECT_TYPE] = None,
context: Optional[bpy.types.Context] = None,
) -> Union[int, None]:
# TODO: is it ever used as None?
if obj_type is None:
return None
if context is None:
context = bpy.context
if obj_type == "Object":
props = cls.get_object_bim_props(bpy.data.objects[obj])
return props.ifc_definition_id
elif obj_type == "Material":
props = tool.Material.get_material_props()
return props.materials[props.active_material_index].ifc_definition_id
elif obj_type == "MaterialSetItem":
obj_ = bpy.data.objects[obj]
omprops = tool.Material.get_object_material_props(obj_)
return omprops.active_material_set_item_id
elif obj_type == "Task":
tprops = tool.Sequence.get_task_tree_props()
wsprops = tool.Sequence.get_work_schedule_props()
return tprops.tasks[wsprops.active_task_index].ifc_definition_id
elif obj_type == "Cost":
cost_props = tool.Cost.get_cost_props()
return cost_props.cost_items[cost_props.active_cost_item_index].ifc_definition_id
elif obj_type == "Resource":
active_resource = tool.Resource.get_resource_props().active_resource
assert active_resource
return active_resource.ifc_definition_id
elif obj_type == "Profile":
props = tool.Profile.get_profile_props()
return props.profiles[props.active_profile_index].ifc_definition_id
elif obj_type == "WorkSchedule":
wsprops = tool.Sequence.get_work_schedule_props()
return wsprops.active_work_schedule_id
elif obj_type == "Group":
props = tool.Group.get_group_props()
assert (active_group := props.active_group)
return active_group.ifc_definition_id
elif obj_type == "Zone":
props = tool.System.get_zone_props()
assert (active_zone := props.active_zone)
return active_zone.ifc_definition_id
assert_never(obj_type)
@classmethod
def is_ifc_object(cls, obj: bpy.types.Object) -> bool:
props = cls.get_object_bim_props(obj)
return bool(props.ifc_definition_id)
@classmethod
def is_ifc_class_active(cls, ifc_class: str) -> bool:
obj = bpy.context.active_object
if obj:
if cls.is_ifc_object(obj):
return tool.Ifc.get_entity(obj).is_a(ifc_class)
return False
return False
@classmethod
def is_valid_data_block(cls, data_block: bpy.types.ID) -> bool:
"""Check if Blender data-block is still valid.
If Blender data-block (e.g. an Object) is removed then it's
python object gets invalidated and accessing any of it's attributes
leads to ReferenceError: StructRNA of type Object has been removed.
This method helps avoiding try / except ReferenceError constructions.
"""
try:
data_block.bl_rna
return True
except ReferenceError:
return False
@classmethod
def show_info_message(cls, text: str, message_type: Literal["INFO", "ERROR"] = "INFO") -> None:
"""useful for showing error messages outside blender operators
Possible `message_type`: `INFO` / `ERROR`"""
def message_ui(self, context):
self.layout.label(text=text)
bpy.context.window_manager.popup_menu(message_ui, title=message_type.capitalize(), icon=message_type)
@classmethod
def get_view3d_area(cls) -> Union[bpy.types.Area, None]:
assert (wm := bpy.context.window_manager)
for window in wm.windows:
for area in window.screen.areas:
if area.type == "VIEW_3D":
return area
@classmethod
def operator_idname_to_py(cls, idname: str) -> str:
"""Convert a Blender internal operator idname to its Python equivalent.
Example: ``MESH_OT_primitive_cube_add`` -> ``mesh.primitive_cube_add``
"""
module, func = idname.split("_OT_", 1)
return f"{module.lower()}.{func}"
@classmethod
def get_view3d_space(cls) -> Union[bpy.types.SpaceView3D, None]:
if area := cls.get_view3d_area():
space = area.spaces.active
assert isinstance(space, bpy.types.SpaceView3D)
return space
@classmethod
def get_blender_prop_default_value(cls, props: bpy.types.bpy_struct, prop_name: str) -> Any:
prop_bl_rna = props.bl_rna.properties[prop_name]
if getattr(prop_bl_rna, "array_length", 0) > 0:
prop_value = prop_bl_rna.default_array
else:
prop_value = prop_bl_rna.default
return prop_value
@classmethod
def get_viewport_context(cls) -> dict:
"""Get viewport area context for context overriding.
Useful for calling operators outside viewport context.
It's a bit naive since it's just taking the first available `VIEW_3D` area
when in real life you can have a couple of those but should work for the most cases.
"""
area = cls.get_view3d_area()
assert area
region = next(region for region in area.regions if region.type == "WINDOW")
space = next(space for space in area.spaces if space.type == "VIEW_3D")
context_override = {"area": area, "region": region, "space_data": space}
# Need to override screen and window if area is from a different window.
screen: bpy.types.Scene = area.id_data
context = bpy.context
assert context
if context.screen != screen:
context_override["screen"] = screen
window = next(window for window in context.window_manager.windows if window.screen == screen)
context_override["window"] = window
return context_override
@classmethod
def get_viewport_position(cls) -> dict:
region_3d = cls.get_viewport_context()["area"].spaces[0].region_3d
copy_if_possible = lambda x: x.copy() if hasattr(x, "copy") else x
viewport_data = {attr: copy_if_possible(getattr(region_3d, attr)) for attr in VIEWPORT_ATTRIBUTES}
return viewport_data
@classmethod
def set_viewport_position(cls, data: dict) -> None:
region_3d = cls.get_viewport_context()["area"].spaces[0].region_3d
for attr in VIEWPORT_ATTRIBUTES:
setattr(region_3d, attr, data[attr])
@classmethod
def set_viewport_tool(cls, tool_name: str) -> None:
with bpy.context.temp_override(**cls.get_viewport_context()):
bpy.ops.wm.tool_set_by_id(name=tool_name)
@classmethod
def are_viewport_gizmos_enabled(cls) -> bool:
"""Central gate every Bonsai gizmo poll / decorator draw checks before
rendering. Centralises the read of
``gizmos.draw_gizmos_in_3d_viewport`` from addon preferences."""
return cls.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport
class DecoratorColors(NamedTuple):
selected: tuple
unselected: tuple
special: tuple
error: tuple
background: tuple
@classmethod
def get_decorator_colors(cls) -> Blender.DecoratorColors:
"""The five ``decorator_color_*`` fields read together so each viewport
decorator's draw callback resolves them in one call instead of five."""
prefs = cls.get_addon_preferences()
return cls.DecoratorColors(
selected=prefs.decorator_color_selected,
unselected=prefs.decorator_color_unselected,
special=prefs.decorator_color_special,
error=prefs.decorator_color_error,
background=prefs.decorator_color_background,
)
class ViewportDecorator:
"""Shared ``SpaceView3D.draw_handler_add`` lifecycle for feature decorators.
Single-handler subclasses set ``draw_method`` (default ``"draw"``); the
handler binds at ``POST_VIEW``. Multi-handler subclasses set
``draw_methods`` to a tuple of ``(method_name, phase)`` pairs; when it
is non-``None`` it supersedes ``draw_method``.
Decorators whose ``install`` must accept extra arguments (e.g. a callback
or a precomputed bmesh) override ``install`` themselves."""
draw_method: str = "draw"
draw_methods: tuple[tuple[str, str], ...] | None = None
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls.handlers = []
cls.is_installed = False
# Fail loudly at class-definition time if draw_method / draw_methods
# names an attribute the class doesn't expose. Without this, a typo
# only surfaces on the first redraw — as a silent missing-attribute
# handler — which may be far from the offending declaration.
method_names = (
tuple(name for name, _phase in cls.draw_methods) if cls.draw_methods is not None else (cls.draw_method,)
)
for name in method_names:
if getattr(cls, name, None) is None:
raise TypeError(f"{cls.__name__}: draw method {name!r} is declared but not defined on the class")
@classmethod
def install(cls, context: bpy.types.Context) -> None:
if cls.is_installed:
cls.uninstall()
handler = cls()
bindings = cls.draw_methods if cls.draw_methods is not None else ((cls.draw_method, "POST_VIEW"),)
# Rollback partial registrations on any draw_handler_add failure, so
# cls.handlers never ends up holding a half-installed set.
added: list = []
try:
for method_name, phase in bindings:
added.append(
bpy.types.SpaceView3D.draw_handler_add(
getattr(handler, method_name), (context,), "WINDOW", phase
)
)
except Exception:
for h in added:
try:
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
except ValueError:
pass
raise
cls.handlers = added
cls.is_installed = True
@classmethod
def uninstall(cls) -> None:
for h in cls.handlers:
try:
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
except ValueError:
pass
cls.handlers.clear()
cls.is_installed = False
@staticmethod
def _lookup_active_instance(gizmo_cls: type, context: bpy.types.Context) -> Optional[Any]:
"""Return the live ``GizmoGroup`` instance registered under
``context.region``, or ``None`` if there isn't one. The per-region
weakref dict on the gizmo class is populated by ``setup()``; multi-
viewport setups put one entry per region in it so each region's
decorator sees only its own region's hover state."""
instances = getattr(gizmo_cls, "_active_instances", None)
if not instances:
return None
region = getattr(context, "region", None)
if region is None:
return None
ref = instances.get(region.as_pointer())
if ref is None:
return None
return ref()
def _cursor_icon_hovered(self, gizmo_cls: type, attr_name: str, context: bpy.types.Context) -> bool:
"""True iff the gizmo group instance in the current region exposes a gizmo
under ``attr_name`` that reports as highlighted. Any access exception is
swallowed so a transient bpy-state hiccup never breaks the draw loop."""
inst = self._lookup_active_instance(gizmo_cls, context)
if inst is None:
return False
try:
return bool(getattr(inst, attr_name).is_highlight)
except (AttributeError, ReferenceError):
return False
@classmethod
def sync_all(
cls,
context: bpy.types.Context,
enabled: Mapping[type[Blender.ViewportDecorator], bool],
) -> None:
"""Drive each listed decorator to its desired install state in one call.
Each entry whose value is ``True`` ends up installed; each entry whose
value is ``False`` ends up uninstalled. Pass ``True`` for always-on
overlays so they survive subsequent file loads."""
for decorator_cls, should_install in enabled.items():
if should_install:
decorator_cls.install(context)
else:
decorator_cls.uninstall()
@classmethod
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.
Default threshold of 0.9659 = cos(15°) — a 15° tilt cone around ±world Z.
Above the threshold the world-Z axis projects to a small fraction of its
true length on screen, so callers that lay icons or markers out along
world Z should switch to a screen-space offset and any gizmo whose intent
is specifically "vertical" loses its visual cue. The cone is kept narrow
so vertical-intent gizmos stay visible across the typical orbit range of
3D viewport work and drop out only near genuine plan view."""
rv3d = context.region_data
if rv3d is None:
return False
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
return abs(view_forward.z) > threshold
@classmethod
def top_down_factor(cls, context: bpy.types.Context, threshold: float = 0.9659) -> float:
"""Continuous 01 ramp matching ``is_view_top_down``'s cone: 0 outside the
cone, ramping linearly to 1 at strict alignment with world Z. Callers that
want a proportional effect (an icon-stack lift growing as the view
approaches plan) use this in place of the boolean to avoid a one-frame
visual jump as the camera crosses the threshold."""
rv3d = context.region_data
if rv3d is None:
return 0.0
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
alignment = abs(view_forward.z)
if alignment <= threshold:
return 0.0
return (alignment - threshold) / (1.0 - threshold)
@classmethod
def get_screen_up_world(cls, context: bpy.types.Context) -> Vector:
"""World-space direction corresponding to the camera's up axis (screen-vertical).
Returns ``+Y`` when region data is unavailable so callers can compute an
offset without a guard branch."""
rv3d = context.region_data
if rv3d is None:
return Vector((0.0, 1.0, 0.0))
return Vector(rv3d.view_matrix.inverted().col[1][:3]).normalized()
@classmethod
def get_shader_editor_context(cls) -> Union[dict[str, Any], None]:
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == "NODE_EDITOR":
space = area.spaces.active
assert isinstance(space, bpy.types.SpaceNodeEditor)
if space.tree_type == "ShaderNodeTree":
context_override = {"area": area, "space": space, "screen": screen}
# Add window if screen differs from current context
context = bpy.context
if context and context.screen != screen:
window = next((w for w in context.window_manager.windows if w.screen == screen), None)
if window:
context_override["window"] = window
return context_override
@classmethod
def copy_node_graph(cls, material_to: bpy.types.Material, material_from: bpy.types.Material) -> None:
temp_override = cls.get_shader_editor_context()
shader_editor = temp_override["space"]
# remove all nodes from the current material
for n in material_to.node_tree.nodes[:]:
material_to.node_tree.nodes.remove(n)
previous_pin_setting = shader_editor.pin
# required to be able to change material to something else
shader_editor.pin = True
shader_editor.node_tree = material_from.node_tree
# select all nodes and copy them to clipboard
for node in material_from.node_tree.nodes:
node.select = True
with bpy.context.temp_override(**temp_override):
bpy.ops.node.clipboard_copy()
# back to original material
shader_editor.node_tree = material_to.node_tree
with bpy.context.temp_override(**temp_override):
bpy.ops.node.clipboard_paste(offset=(0, 0))
# restore shader editor settings
shader_editor.pin = previous_pin_setting
@classmethod
def get_material_node(
cls, blender_material: bpy.types.Material, node_type: str, kwargs: Optional[dict] = {}
) -> Union[bpy.types.ShaderNode, None]:
"""returns first node from the `blender_material` shader graph with type `node_type`"""
if not tool.Style.get_use_nodes(blender_material):
return
nodes = blender_material.node_tree.nodes
for node in nodes:
if node.type == node_type and all(getattr(node, a) == kwargs[a] for a in kwargs):
return node
@classmethod
def update_screen(cls) -> None:
bpy.ops.wm.redraw_timer(type="DRAW_WIN_SWAP", iterations=1)
@classmethod
def update_viewport(cls) -> None:
cls.get_viewport_context()["area"].tag_redraw()
@classmethod
def update_all_viewports(cls, context: bpy.types.Context | None = None) -> None:
"""Tag every visible 3D viewport for redraw. Silent no-op when no
screen attached (background mode, plug-out, mid-load_post)."""
context = context or bpy.context
screen = getattr(context, "screen", None)
if screen is None:
return
for area in screen.areas:
if area.type == "VIEW_3D":
area.tag_redraw()
@classmethod
def force_depsgraph_update(cls) -> None:
"""useful if you need to trigger callbacks like `depsgraph_update_pre`"""
# blender is requiring some ID to be changed
# to trigger depsgraph update
scene = bpy.context.scene
scene.show_subframe = scene.show_subframe
bpy.context.view_layer.update()
@classmethod
def ensure_unique_name(cls, name: str, objects: Iterable[str], iteration=0) -> str:
"""returns a unique name for the given name and dictionary of objects
blender style name with .001, .002, etc. suffix.
objects can be `bpy.data.objects`.
"""
current_iteration = name if not iteration else f"{name}.{iteration:03d}"
if current_iteration not in objects:
return current_iteration
return cls.ensure_unique_name(name, objects, iteration + 1)
@classmethod
def blender_path_to_posix(cls, blender_path: str) -> str:
"""Process blender path to be saved as posix.
If path is relative the method will keep it relative to .ifc file
"""
if blender_path.startswith("//"): # detect relative blender path
ifc_path = Path(tool.Ifc.get_path())
abs_path = Path(bpy.path.abspath(blender_path))
path = abs_path.relative_to(ifc_path.parent)
else:
path = Path(blender_path)
return path.as_posix()
@classmethod
def ensure_blender_path_is_abs(cls, blender_path: Path) -> Path:
if blender_path.is_absolute():
return blender_path
return bpy.path.abspath("//") / blender_path
@classmethod
def ensure_bin_in_path(cls) -> None:
"""Check 'bin' folder is in PATH, if not add for this session"""
bin_dir = str(Path(__file__).parent.parent.resolve() / "libs" / "bin")
if not os.path.isdir(bin_dir):
return # Maybe the user is using a system-wide Python package. See #7157.
current_path = os.environ["PATH"]
if bin_dir not in current_path:
os.environ["PATH"] = current_path + os.pathsep + bin_dir
# files need to be executable
if platform.system() != "Windows":
for filename in os.listdir(bin_dir):
file_path = os.path.join(bin_dir, filename)
if os.path.isfile(file_path):
current_permissions = os.stat(file_path).st_mode
try:
os.chmod(file_path, current_permissions | 0o100)
except PermissionError:
pass
@classmethod
def get_default_selection_keypmap(cls) -> tuple:
"""keymap to replicate default blender selection behaviour with click and box selection"""
# code below comes from blender_default.py which is part of default blender scripts licensed under GPL v2
# https://github.com/blender/blender/blob/master/release/scripts/presets/keyconfig/keymap_data/blender_default.py
# the code is the data from evaluating km_3d_view_tool_select() and km_3d_view_tool_select_box()
#
# You can run the snippet below in Blender console
# to regenerate those keybindings in case of errors in the future
# ```
# import os
# version = ".".join(bpy.app.version_string.split(".")[:2])
# fl = os.path.join(os.getcwd(), version, "scripts/presets/keyconfig/keymap_data/blender_default.py")
# def_keymap = bpy.utils.execfile(fl)
# params = def_keymap.Params
# box_keymap = def_keymap.km_3d_view_tool_select_box(def_keymap.Params(), fallback=None)[2]["items"]
# click_keymap = def_keymap.km_3d_view_tool_select(def_keymap.Params(select_mouse="LEFTMOUSE"), fallback=None)[2]["items"]
# ```
# https://docs.blender.org/api/current/bpy.types.KeyMapItems.html
keymap = (
# box selection keymap
("view3d.select_box", {"type": "LEFTMOUSE", "value": "CLICK_DRAG"}, None),
(
"view3d.select_box",
{"type": "LEFTMOUSE", "value": "CLICK_DRAG", "shift": True},
{"properties": [("mode", "ADD")]},
),
(
"view3d.select_box",
{"type": "LEFTMOUSE", "value": "CLICK_DRAG", "ctrl": True},
{"properties": [("mode", "SUB")]},
),
(
"view3d.select_box",
{"type": "LEFTMOUSE", "value": "CLICK_DRAG", "shift": True, "ctrl": True},
{"properties": [("mode", "AND")]},
),
# left-click selection keymap
("view3d.select", {"type": "LEFTMOUSE", "value": "PRESS"}, {"properties": [("deselect_all", True)]}),
(
"view3d.select",
{"type": "LEFTMOUSE", "value": "PRESS", "shift": True},
{"properties": [("toggle", True)]},
),
)
return keymap
KEY_MODIFIERS = {
"A": ("EVENT_ALT", "OPTION" if sys.platform == "Darwin" else "ALT"),
"C": ("EVENT_CTRL", "CTRL"),
"S": ("EVENT_SHIFT", ""),
"E": ("EVENT_PADENTER", "ENTER" if sys.platform == "Darwin" else "RETURN"),
}
@classmethod
def add_layout_hotkey_operator(
cls,
layout: bpy.types.UILayout,
text: str,
hotkey: str,
description: str,
ui_context: str = "",
*,
tool_name: str,
module_name: str,
operator: Optional[str] = None,
) -> tuple[bpy.types.OperatorProperties, bpy.types.UILayout]:
"""
:param module_name: Provide `__name__` of the current module,
so method could pick up icon previews based on the module's `custom_icon_previews` attribute.
:param operator: Operator to display in UI. Displaying the specific operator in UI can be useful
to provide poll error messages.
"""
if tool_name == "bim":
hotkey_operator = "bim.hotkey"
else:
hotkey_operator = f"bim.{tool_name}_hotkey"
operator_to_use = operator or hotkey_operator
modifier, key = hotkey.split("_")
op_text = "" if ui_context == "TOOL_HEADER" else text
modifier_icon, modifier_str = cls.KEY_MODIFIERS.get(modifier, ("NONE", ""))
module = sys.modules[module_name]
icon_previews: Union[bpy.utils.previews.ImagePreviewCollection, None]
icon_previews = getattr(module, "custom_icon_previews", None)
row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True)
if icon_previews:
custom_icon = icon_previews.get(text.upper().replace(" ", "_"), icon_previews["IFC"]).icon_id
op = row.operator(operator_to_use, text=op_text, icon_value=custom_icon)
else:
op = row.operator(operator_to_use, text=op_text)
if ui_context != "TOOL_HEADER":
row.label(text="", icon=modifier_icon)
row.separator(factor=1)
row.label(text="", icon=f"EVENT_{key}")
if operator_to_use == hotkey_operator:
hotkey_description = f"Hotkey: {modifier_str} {key}".strip()
description = "\n\n".join(filter(None, [description, hotkey_description]))
op.hotkey = hotkey
if ui_context == "TOOL_HEADER":
op.description = text + "\n" + description
else:
op.description = description
return op, row
@classmethod
def get_object_bounding_box(cls, obj: bpy.types.Object) -> dict[str, Union[tuple[float, float, float], Vector]]:
"""Returns dict with local min and max x, y, z values for the object.
Careful with using this method for objects in EDIT mode because
it requires all EDIT mode changes to be applied.
"""
# Example bounding box points for a cube:
# [
# (-1.0, -1.0, -1.0), # 0, min.
# (-1.0, -1.0, 1.0), # 1
# (-1.0, 1.0, 1.0), # 2
# (-1.0, 1.0, -1.0), # 3
# ( 1.0, -1.0, -1.0), # 4
# ( 1.0, -1.0, 1.0), # 5
# ( 1.0, 1.0, 1.0), # 6, max.
# ( 1.0, 1.0, -1.0), # 7
# ]
bound_box = obj.bound_box
bbox_dict = {
"min_x": bound_box[0][0],
"max_x": bound_box[6][0],
"min_y": bound_box[0][1],
"max_y": bound_box[6][1],
"min_z": bound_box[0][2],
"max_z": bound_box[6][2],
"min_point": Vector(bound_box[0]),
"max_point": Vector(bound_box[6]),
"center": (Vector(bound_box[6]) + Vector(bound_box[0])) / 2,
}
return bbox_dict
@classmethod
def get_object_world_bounding_box(cls, obj: bpy.types.Object) -> dict[str, Union[float, Vector]]:
"""Same shape as ``get_object_bounding_box`` but with ``matrix_world``
applied — extents are computed across the 8 transformed corners, so
a rotated or scaled object reports its actual world-axis AABB rather
than the misleading transform of the local-space corners.
``bound_box[0]`` / ``bound_box[6]`` are the local min/max corners but
do NOT correspond to the world AABB extremes once the object is
rotated, so min/max must be taken per-axis across all 8 corners."""
corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
xs = [c.x for c in corners]
ys = [c.y for c in corners]
zs = [c.z for c in corners]
min_point = Vector((min(xs), min(ys), min(zs)))
max_point = Vector((max(xs), max(ys), max(zs)))
return {
"min_x": min_point.x,
"max_x": max_point.x,
"min_y": min_point.y,
"max_y": max_point.y,
"min_z": min_point.z,
"max_z": max_point.z,
"min_point": min_point,
"max_point": max_point,
"center": (min_point + max_point) / 2,
}
@classmethod
def select_and_activate_single_object(cls, context: bpy.types.Context, active_object: bpy.types.Object) -> None:
for obj in context.selected_objects:
obj.select_set(False)
context.view_layer.objects.active = active_object
active_object.select_set(True)
@classmethod
def set_object_selection(cls, obj: bpy.types.Object, state: bool = True):
"""Run ``Object.select_set`` but ignore errors if the object is hidden.
Therefore, doesn't guarantee that the object is actually selected.
"""
try:
obj.select_set(state)
except RuntimeError: # Trying to select a hidden object throws an error
pass
@classmethod
def select_object(cls, obj: bpy.types.Object):
"""Shortcut for ``set_object_selection(obj, True)``."""
cls.set_object_selection(obj, True)
@classmethod
def deselect_object(cls, obj: bpy.types.Object, ensure_active_object: bool = True):
"""Deselect object (using ``set_object_selection``) and optionally ensure that active
object is not the deselected object (last selected object used to replace it as active).
"""
cls.set_object_selection(obj, False)
if ensure_active_object and bpy.context.view_layer.objects.active == obj:
if bpy.context.selected_objects:
cls.set_active_object(bpy.context.selected_objects[-1])
else:
cls.clear_active_object()
@classmethod
def get_objects_selection(
cls, context: bpy.types.Context
) -> tuple[bpy.types.Context, Union[bpy.types.Object, None], list[bpy.types.Object]]:
"""Get objects selection to later pass to `set_objects_selection`."""
return context, context.view_layer.objects.active, context.selected_objects
@classmethod
def set_objects_selection(
cls,
context: bpy.types.Context,
active_object: Optional[bpy.types.Object] = None,
selected_objects: Sequence[bpy.types.Object] = (),
clear_previous_selection=True,
) -> None:
if clear_previous_selection:
for obj in context.selected_objects:
obj.select_set(False)
for obj in selected_objects:
obj.select_set(True)
context.view_layer.objects.active = active_object
if active_object:
active_object.select_set(True)
class ObjectsSelectionArgs(NamedTuple):
context: bpy.types.Context
active_object: bpy.types.Object | None
selected_objects: list[bpy.types.Object]
@classmethod
def validate_object_selection(
cls,
context: bpy.types.Context,
active_object: Union[bpy.types.Object, None] = None,
selected_objects: Sequence[bpy.types.Object] = (),
) -> ObjectsSelectionArgs:
"""Validate object selection and return only valid objects.
Can be used before ``set_objects_selection`` to avoid errors
trying to select or set as active already removed objects
or objects that are not in the current view layer (their collection is unchecked).
"""
assert context.view_layer
view_layer_objects = set(context.view_layer.objects)
def is_selectable(obj: bpy.types.Object) -> bool:
return cls.is_valid_data_block(obj) and obj in view_layer_objects
new_selected_objects = [o for o in selected_objects if is_selectable(o)]
if active_object and not is_selectable(active_object):
active_object = None
return cls.ObjectsSelectionArgs(context, active_object, new_selected_objects)
@classmethod
def clear_objects_selection(cls) -> None:
"""Clear objects selection and active object."""
bpy.ops.object.select_all(action="DESELECT")
cls.clear_active_object()
@classmethod
def get_enum_safe(cls, props: bpy.types.PropertyGroup, prop_name: str) -> Union[str, None]:
"""method created for readibility and to avoid console warnings like
`pyrna_enum_to_py: current value '17' matches no enum in 'BIMModelProperties', '', 'relating_type_id'`
:return: Enum property value as a string or None if current enum value is invalid.
"""
# Yes, accessing items through annotations is a bit hacky
# but it's the only way to get the dynamic enum items
# besides providing them to get_enum_safe explicitly.
try:
annotations = props.__annotations__
except AttributeError:
annotations = type(props).__annotations__
prop_keywords = annotations[prop_name].keywords
items = prop_keywords.get("items")
if items is None:
return None
if not isinstance(items, (list, tuple)):
# items are retrieved through a callback, not a static list / tuple :
items = items(props, bpy.context)
items_amount = len(items)
# If enum has no items it seems to always produce a warning.
# E.g. if you try to get it's value directly: `BIMModelProperties.relating_type_id`.
if items_amount == 0:
return None
index = props.get(prop_name)
# If value was never changed (still default), we can just retrieve it from the enum.
if index is None:
default_value = prop_keywords.get("default", 0)
if isinstance(default_value, int):
index = default_value
else:
# If default value is a string then it's a static enum
# and we can just return it.
return default_value
# Ensure index is valid.
if items_amount > index >= 0:
return items[index][0]
return None
@classmethod
def ensure_enum_is_valid(cls, props: bpy.types.PropertyGroup, prop_name: str) -> bool:
"""Ensure that enum is valid after current enum item was deleted.
:return: True if enum is valid and update callback was triggered,
False if enum is still invalid (as there no enum items)
and update callback was not triggered (may need to trigger it manually).
"""
current_value = cls.get_enum_safe(props, prop_name)
if current_value is not None:
# Value is valid, just trigger the update callback.
setattr(props, prop_name, current_value)
return True
# If enum was never changed prop_name won't be present in props
# and implicit 0 index is assumed.
current_index = props.get(prop_name, 0)
# Index is still invalid and triggering update callback directly
# will cause an error, so we just stop here.
if current_index == 0:
return False
props[prop_name] = current_index - 1
# Trigger update callback.
setattr(props, prop_name, getattr(props, prop_name))
return True
@classmethod
def append_data_block(cls, filepath: str, data_block_type: str, name: str, link=False, relative=False) -> dict:
if Path(filepath) == Path(bpy.data.filepath):
data_block = getattr(bpy.data, data_block_type).get(name, None)
if not data_block:
return {"data_block": None, "msg": f"Data-block {data_block_type}/{name} not found in {filepath}"}
return {"data_block": data_block.copy(), "msg": ""}
with bpy.data.libraries.load(filepath, link=link, relative=relative) as (data_from, data_to):
if name not in getattr(data_from, data_block_type):
return {"data_block": None, "msg": f"Data-block {data_block_type}/{name} not found in {filepath}"}
getattr(data_to, data_block_type).append(name)
return {"data_block": getattr(data_to, data_block_type)[0], "msg": ""}
@classmethod
def remove_object(cls, obj: bpy.types.Object) -> None:
bpy.data.objects.remove(obj)
@classmethod
def remove_data_block(cls, data_block: bpy.types.ID, do_unlink=True) -> None:
"""Removes a datablock (such as a mesh)
See https://projects.blender.org/blender/blender/issues/118787 for more
details about do_unlink.
:param data_block: The bpy.data datablock to delete.
:param do_unlink: Whether or not to unlink the datablock first. This
defaults to true, which is Blender's default behaviour. If you are
sure that the data block has zero users, then you can set this
to False, which will make datablock deletion significantly faster
by avoiding unnecessary Blender data checks.
:return: None
"""
collection_name = repr(data_block).split(".", 2)[-1].split("[", 1)[0]
getattr(bpy.data, collection_name).remove(
data_block, do_unlink=do_unlink, do_id_user=do_unlink, do_ui_user=do_unlink
)
@classmethod
def remove_data_blocks(cls, data_blocks: list[bpy.types.ID], remove_unused_data: bool = False) -> None:
"""Removes several data blocks at once
:param data_blocks: iterable of data blocks to remove
:param remove_unused_data: set to True to purge data that would be orphaned by the operation
:return: None
"""
data_blocks = list(data_blocks)
if remove_unused_data:
data_blocks.extend([o.data for o in data_blocks if hasattr(o, "data") and o.data and o.data.users <= 1])
bpy.data.batch_remove(data_blocks)
## BMESH UTILS ##
@classmethod
def apply_bmesh(cls, mesh: bpy.types.Mesh, bm: bmesh.types.BMesh, obj: Optional[bpy.types.Object] = None) -> None:
"""`obj` argument is not optional if you plan to update mesh in EDIT mode
and it's possible that that mesh object won't be currenly active."""
import bmesh
if mesh.is_editmode:
# better to be safe because otherwise mesh won't be updated
# and you won't get any errors
if not bm.is_wrapped or hash(bmesh.from_edit_mesh(mesh)) != hash(bm):
raise Exception(
f"{bm} is not edit mesh for {mesh}. "
"For applying bmesh in edit mode bmesh should be acquired with `bmesh.from_edit_mesh(me)`."
)
bmesh.update_edit_mesh(mesh)
if not obj:
if not bpy.context.active_object or bpy.context.active_object.data != mesh:
raise Exception(
"Error applying bmesh in EDIT object - object is "
"not provided and can't be acquired from the context. "
)
obj = bpy.context.active_object
obj.update_from_editmode()
else:
bm.to_mesh(mesh)
# only freeing bmesh if object is in OBJECT mode
# because if it's in EDIT mode
# freeing mesh will result in dead bmeshes from `bmesh.from_edit_mesh(mesh)`
# until you restart EDIT mode
# which may result in errors when some other scripts will try to get bmesh
bm.free()
mesh.update()
@classmethod
def get_bmesh_for_mesh(cls, mesh: bpy.types.Mesh, clean=False) -> bmesh.types.BMesh:
import bmesh
if mesh.is_editmode:
bm = bmesh.from_edit_mesh(mesh)
if clean:
bm.clear()
else:
bm = bmesh.new()
if not clean:
bm.from_mesh(mesh)
return bm
@classmethod
def bmesh_join(
cls,
bm_a: bmesh.types.BMesh,
bm_b: bmesh.types.BMesh,
callback: Optional[
Callable[
[bmesh.types.BMesh, list[bmesh.types.BMVert], list[bmesh.types.BMEdge], list[bmesh.types.BMFace]], None
]
] = None,
):
"""Join two meshes into single one, store it in `bm_a`"""
import bmesh
new_verts = [bm_a.verts.new(v.co) for v in bm_b.verts]
new_edges = [bm_a.edges.new([new_verts[v.index] for v in edge.verts]) for edge in bm_b.edges]
new_faces = [bm_a.faces.new([new_verts[v.index] for v in face.verts]) for face in bm_b.faces]
bmesh.ops.recalc_face_normals(bm_a, faces=bm_a.faces[:])
if callback:
callback(bm_a, new_verts, new_edges, new_faces)
return bm_a
@classmethod
def bmesh_check_vertex_in_groups(
cls, vertex: bmesh.types.BMVert, deform_layer: bmesh.types.BMLayerItem, groups: list[int]
) -> Union[tuple[Literal[True], int], tuple[Literal[False], None]]:
"""returns tuple boolean (whether vertex is in any of the groups) and related group index"""
for group_index in vertex[deform_layer].keys():
# ignore vertex groups assignments produced by edge subdivision near arcs
# they usually have weight = 0.5
if group_index in groups and vertex[deform_layer][group_index] == 1.0:
return True, group_index
return False, None
@classmethod
def bmesh_get_vertex_groups(cls, vertex: bmesh.types.BMVert, deform_layer: bmesh.types.BMLayerItem) -> list[int]:
results = []
for group_index in vertex[deform_layer].keys():
# Ignore vertex groups assignments produced by edge subdivision near arcs
# They usually have weight = 0.5
if vertex[deform_layer][group_index] == 1.0:
results.append(group_index)
return results
@classmethod
def toggle_edit_mode(cls, context: bpy.types.Context) -> set[rna_enums.OperatorReturnItems]:
"""Run ``object.mode_set(EDIT)``."""
ao = context.active_object
if not ao:
return {"CANCELLED"}
if ao.library:
return {"CANCELLED"}
if ao.type in cls.OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE:
return bpy.ops.object.mode_set(mode="EDIT", toggle=True)
elif ao.type in cls.OBJECT_TYPES_THAT_SUPPORT_EDIT_GPENCIL_MODE:
return bpy.ops.object.mode_set(mode="EDIT_GPENCIL", toggle=True)
return {"CANCELLED"}
@classmethod
def is_object_an_ifc_class(cls, obj: bpy.types.Object, classes: Iterable[str]) -> bool:
if not tool.Ifc.get():
return False
element = tool.Ifc.get_entity(obj)
return bool(element) and element.is_a() in classes
@classmethod
def get_object_from_guid(cls, guid: str) -> Union[bpy.types.Object, None]:
element = tool.Ifc.get().by_guid(guid)
obj = tool.Ifc.get_object(element)
if obj:
return obj
@classmethod
def lock_transform(cls, obj: bpy.types.Object, lock_state=True) -> None:
for prop in ("lock_location", "lock_rotation", "lock_scale"):
attr = getattr(obj, prop)
for axis_idx in range(3):
attr[axis_idx] = lock_state
operator_invoke_filepath_hotkeys_description = "Hold Shift to open the file, Alt to browse containing directory"
@classmethod
def open_file_or_folder(cls, path: str) -> None:
if platform.system() == "Windows":
os.startfile(path)
elif platform.system() == "Darwin":
subprocess.Popen(["open", path])
else:
subprocess.Popen(["xdg-open", path])
@classmethod
def operator_invoke_filepath_hotkeys(
cls, operator: bpy.types.Operator, context: bpy.types.Context, event: bpy.types.Event, filepath: Path
) -> Union[set, None]:
if not event.alt and not event.shift:
return
# resolve relative filepaths with .blend path by default
if not filepath.is_absolute():
if bpy.data.filepath:
filepath = Path(bpy.data.filepath).parent / filepath
else:
operator.report({"ERROR"}, f'Couldn\'t resolve relative filepath "{filepath.as_posix()}"')
return {"CANCELLED"}
# holding ALT - open file directory
if event.alt == True:
# open directory
filepath = filepath.parent
if not filepath.exists():
operator.report({"ERROR"}, f'Cannot open non-existing directory: "{filepath.as_posix()}"')
return {"CANCELLED"}
cls.open_file_or_folder(filepath.as_posix())
return {"PASS_THROUGH"}
# holding SHIFT - open file
if not filepath.exists():
operator.report({"ERROR"}, f'Cannot open non-existing file: "{filepath.as_posix()}"')
return {"CANCELLED"}
cls.open_file_or_folder(filepath.as_posix())
return {"PASS_THROUGH"}
@classmethod
def get_layer_collection(cls, collection: bpy.types.Collection) -> Union[bpy.types.LayerCollection, None]:
project = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0])
project_collection = cls.get_object_bim_props(project).collection
for layer_collection in bpy.context.view_layer.layer_collection.children:
if layer_collection.collection == project_collection:
for layer_collection2 in layer_collection.children:
if layer_collection2.collection == collection:
return layer_collection2
@classmethod
def get_layer_collections_mapping(
cls, collections: list[bpy.types.Collection], view_layer: Optional[bpy.types.ViewLayer] = None
) -> dict[bpy.types.Collection, bpy.types.LayerCollection]:
if view_layer is None:
view_layer = bpy.context.view_layer
collections = list(collections) # copy to prevent mutation
collections_mapping = dict()
queue = [view_layer.layer_collection]
while queue:
layer = queue.pop()
collection = layer.collection
if collection in collections:
collections_mapping[collection] = layer
collections.remove(collection)
if not collections:
break
queue.extend(list(layer.children))
return collections_mapping
@classmethod
def is_editable(cls, obj: bpy.types.Object) -> bool:
if obj.type not in cls.OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE:
return False
if not (element := tool.Ifc.get_entity(obj)):
return True
if obj in tool.Project.get_project_props().clipping_planes_objs:
return False
usage_type = tool.Model.get_usage_type(element)
if usage_type in ("LAYER1", "LAYER2"):
# At the moment, these type types of parametric elements (walls,
# and "blocks") cannot be edited as a mesh-like object.
return False
return True
class Modifier:
@classmethod
def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool:
"""Tries to validate the current BIM modifier parameters for the active object
Goes into path editing mode if the modifier supports it
:return: True if an action was taken, False otherwise
"""
# roof and railing both finalize then drop into path-edit mode — handle
# them before the generic finish dispatch so the path transition runs.
if tool.Parametric.is_roof(element):
if tool.Parametric.ROOF.is_editing(obj):
tool.Parametric.run_bim_op(tool.Parametric.ROOF.finish_op)
bpy.ops.bim.enable_editing_roof_path()
elif tool.Parametric.is_railing(element):
if tool.Parametric.RAILING.is_editing(obj):
tool.Parametric.run_bim_op(tool.Parametric.RAILING.finish_op)
bpy.ops.bim.enable_editing_railing_path()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.finish_op)
else:
return False
return True
@classmethod
def try_canceling_editing_modifier_parameters_or_path(cls, obj: bpy.types.Object) -> bool:
"""Tries to cancel the current BIM modifier parameters or path edition for the active object
:return: True if an action was taken, False otherwise
"""
# Path-edit modes are distinct from parametric draft modes; handle them first.
if cls.is_editing_railing_path(obj):
bpy.ops.bim.cancel_editing_railing_path()
elif cls.is_editing_roof_path(obj):
bpy.ops.bim.cancel_editing_roof_path()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.cancel_op)
else:
return False
return True
@classmethod
def is_eligible_for_railing_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, _RAILING_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, _STAIR_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_window_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, _WINDOW_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_door_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, _DOOR_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_roof_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, _ROOF_MODIFIER_IFC_CLASSES)
@classmethod
def is_array_child(cls, element: entity_instance) -> bool:
"""True if element is a CHILD of a Bonsai parametric array.
Children are managed replicas regenerated from the parent's pset —
their parametric attributes (door dimensions, wall lengths, …) are
overwritten on the next ``regenerate_array``. Parametric gizmo
groups skip children via this predicate in ``poll``.
This sits on a different axis from ``tool.Parametric.is_array``:
cardinality (parent vs child) is orthogonal to feature kind, and
an arrayed wall fires both ``is_wall`` and ``is_array`` on the
same element."""
if element is None:
return False
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return False
parent_guid = pset.get("Parent")
return parent_guid is not None and parent_guid != element.GlobalId
@classmethod
def is_slab(cls, element: entity_instance) -> bool:
"""A slab is host-eligible for the parametric add-opening gizmo if
it is an IfcSlab with LAYER3 usage.
Slabs carry no proprietary BBIM_Slab pset — their parametric state
lives in standard IFC (extrusion depth, IfcMaterialLayerSetUsage
with LayerSetDirection AXIS3). Any LAYER3 slab qualifies."""
if element is None or not element.is_a("IfcSlab"):
return False
return tool.Model.get_usage_type(element) == "LAYER3"
@classmethod
def is_pipe_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcPipeSegment")
@classmethod
def is_duct_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcDuctSegment")
@classmethod
def is_editing_railing_path(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_railing_props(obj)
return props.is_editing_path
@classmethod
def is_editing_roof_path(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_roof_props(obj)
return props.is_editing_path
@classmethod
def is_modifier_with_non_editable_path(cls, element: entity_instance) -> bool:
feature = tool.Parametric.find_for_element(element)
return bool(feature and feature.has_non_editable_path)
class Attribute:
@classmethod
def fill_attribute(cls, data: bpy.types.ID, attribute_name: str, domain: str, data_type: str, values):
attribute = cls.ensure_attribute(data, attribute_name, domain, data_type)
attribute.data.foreach_set(cls.get_data_name(data_type), values)
@classmethod
def ensure_attribute(cls, data: bpy.types.ID, attribute_name: str, domain: str, data_type: str):
attribute = data.attributes.get(attribute_name)
if not attribute:
attribute = data.attributes.new(attribute_name, domain=domain, type=data_type)
return attribute
@classmethod
def get_data_name(cls, data_type: str):
if data_type in ("FLOAT", "INT", "BOOLEAN", "STRING"):
return "value"
if data_type.endswith("VECTOR"):
return "vector"
elif data_type.endswith("COLOR"):
return "color"
else:
raise NotImplementedError(f"Attribute data type `{data_type}` not implemented yet")
@classmethod
def get_verts_coordinates(cls, verts: bpy.types.MeshVertices) -> npt.NDArray[np.float32]:
# It's faster to get them as f and then convert to d
# with .astype("d"), if precision is needed.
coords = np.empty(len(verts) * 3, dtype="f")
verts.foreach_get("co", coords)
coords = coords.reshape(-1, 3)
return coords
@classmethod
def get_last_commit_hash(cls) -> Union[str, None]:
return bonsai.get_last_commit_hash()
@classmethod
@cache
def get_bonsai_version(cls) -> str:
"""E.g. `0.8.3-alpha250617-15453a9`"""
version = None
# Try to retrieve actual version for live-dev environment.
with contextlib.suppress(Exception):
import git
path = Path(__file__).resolve().parent
repo = git.Repo(str(path), search_parent_directories=True)
repo_path = repo.working_tree_dir
assert repo_path
version_ = (Path(repo_path) / "VERSION").read_text().strip()
commit_date = bonsai.get_last_commit_date()
assert commit_date
commit_date = datetime.fromisoformat(commit_date)
version = f"{version_}-alpha{commit_date.strftime('%y%m%d')}"
if version is None:
bbim = cls.get_bbim_extension_package()
version = bbim.bbim_semver["version"]
if commit_hash := cls.get_last_commit_hash():
version += f"-{commit_hash}"
return version
@classmethod
def register_toolbar(cls):
import bonsai.bim.module.covering.workspace as ws_covering
import bonsai.bim.module.drawing.workspace as ws_drawing
import bonsai.bim.module.model.workspace as ws_model
import bonsai.bim.module.spatial.workspace as ws_spatial
import bonsai.bim.module.structural.workspace as ws_structural
if bpy.app.background:
return
try:
bpy.utils.register_tool(ws_model.WallTool, after={"builtin.transform"}, separator=True, group=False)
bpy.utils.register_tool(ws_model.SlabTool, after={"bim.wall_tool"}, separator=False, group=False)
bpy.utils.register_tool(ws_model.DoorTool, after={"bim.slab_tool"}, separator=False, group=False)
bpy.utils.register_tool(ws_model.WindowTool, after={"bim.door_tool"}, separator=False, group=False)
bpy.utils.register_tool(ws_model.ColumnTool, after={"bim.window_tool"}, separator=False, group=False)
bpy.utils.register_tool(ws_model.BeamTool, after={"bim.column_tool"}, separator=False, group=False)
bpy.utils.register_tool(ws_model.DuctTool, after={"bim.beam_tool"}, separator=False, group=False)
bpy.utils.register_tool(ws_model.PipeTool, after={"bim.duct_tool"}, separator=False, group=False)
bpy.utils.register_tool(ws_model.BimTool, after={"bim.pipe_tool"}, separator=False, group=False)
bpy.utils.register_tool(ws_drawing.AnnotationTool, after={"bim.bim_tool"}, separator=True, group=False)
bpy.utils.register_tool(ws_spatial.SpatialTool, after={"bim.annotation_tool"}, separator=False, group=False)
bpy.utils.register_tool(
ws_structural.StructuralTool, after={"bim.spatial_tool"}, separator=False, group=False
)
bpy.utils.register_tool(
ws_covering.CoveringTool, after={"bim.structural_tool"}, separator=False, group=False
)
except:
pass
@classmethod
def unregister_toolbar(cls):
import bonsai.bim.module.covering.workspace as ws_covering
import bonsai.bim.module.drawing.workspace as ws_drawing
import bonsai.bim.module.model.workspace as ws_model
import bonsai.bim.module.spatial.workspace as ws_spatial
import bonsai.bim.module.structural.workspace as ws_structural
if bpy.app.background:
return
try:
bpy.utils.unregister_tool(ws_model.WallTool)
bpy.utils.unregister_tool(ws_model.SlabTool)
bpy.utils.unregister_tool(ws_model.DoorTool)
bpy.utils.unregister_tool(ws_model.WindowTool)
bpy.utils.unregister_tool(ws_model.ColumnTool)
bpy.utils.unregister_tool(ws_model.BeamTool)
bpy.utils.unregister_tool(ws_model.DuctTool)
bpy.utils.unregister_tool(ws_model.PipeTool)
bpy.utils.unregister_tool(ws_model.BimTool)
bpy.utils.unregister_tool(ws_drawing.AnnotationTool)
bpy.utils.unregister_tool(ws_spatial.SpatialTool)
bpy.utils.unregister_tool(ws_structural.StructuralTool)
bpy.utils.unregister_tool(ws_covering.CoveringTool)
except:
pass
@classmethod
def get_scene_panels_list(cls) -> tuple[bpy.types.Panel, ...]:
# example default blender scene panels can be found in
# https://projects.blender.org/blender/blender/src/branch/main/scripts/startup/bl_ui/properties_scene.py#L421
scene_panels: list[str] = []
panels_to_parents: dict[str, str] = dict()
for item_name in dir(bpy.types):
item = getattr(bpy.types, item_name)
# filter only panels
if not hasattr(item, "bl_rna") or not isinstance(item.bl_rna.base, bpy.types.Panel):
continue
# ignore bbim panels
if item.__module__.startswith("bonsai"):
continue
# filter scene panels
if getattr(item, "bl_context", None) != "scene":
continue
scene_panels.append(item_name)
parent_panel = getattr(item, "bl_parent_id", None)
if parent_panel is not None:
panels_to_parents[item_name] = parent_panel
scene_panels = cls.sort_panels_for_register(scene_panels, panels_to_parents)
final_panels = [getattr(bpy.types, p) for p in scene_panels]
return tuple(final_panels)
@classmethod
def sort_panels_for_register(cls, items: list[str], items_to_parents: dict[str, str]) -> list[str]:
"""sort panels ensuring parents panels will be registered first
as otherwise we'll get errors unregistering them all and registering child panel"""
final_items = []
unsorted = items.copy()
# first, add items without parents
for item in unsorted[:]:
if item not in items_to_parents:
final_items.append(item)
unsorted.remove(item)
# store children for each parent
children: dict[str, list[str]] = dict()
for item in items_to_parents:
children.setdefault(items_to_parents[item], []).append(item)
# add children recursively, ensuring parents are added first
keep_looking = True
while keep_looking:
keep_looking = False
for item in list(children.keys()):
# check if parent panel was already added
if item not in final_items:
continue
final_items.extend(children[item])
del children[item]
keep_looking = True
assert set(items) == set(final_items), "Sorted list doesn't match original"
return final_items
@classmethod
def override_scene_panel(cls, original_panel: bpy.types.Panel) -> None:
@classmethod
def poll_check_blender_tab(cls, context):
aprops = tool.Blender.get_active_area_props(context)
if aprops.path_from_id() == "BIMAreaProperties" and context.area.spaces.active.search_filter:
return True
return aprops.tab == "BLENDER"
polls = bonsai.bim.original_scene_panels_polls
# override poll method
if not hasattr(original_panel, "poll"):
polls[original_panel] = None
original_panel.poll = poll_check_blender_tab
else:
polls[original_panel] = original_panel.poll
@classmethod
def wrapped_poll(cls, context):
return polls[cls](context) and poll_check_blender_tab.__func__(cls, context)
original_panel.poll = wrapped_poll
# reregister to activate new poll
bpy.utils.unregister_class(original_panel)
bpy.utils.register_class(original_panel)
@classmethod
def remove_scene_panel_override(cls, panel: bpy.types.Panel) -> None:
polls = bonsai.bim.original_scene_panels_polls
poll = polls[panel]
if poll is None:
del panel.poll
else:
panel.poll = poll
# panel might be already unregistered during blender exit
# or if it's addon was disabled
if panel.is_registered:
# reregister to activate new poll
bpy.utils.unregister_class(panel)
bpy.utils.register_class(panel)
del polls[panel]
@classmethod
def get_blender_addon_package_name(cls) -> str:
return bonsai.REGISTERED_BBIM_PACKAGE
@classmethod
def get_bbim_extension_package(cls) -> types.ModuleType:
name = cls.get_blender_addon_package_name()
return importlib.import_module(name)
@classmethod
def is_addon_enabled(cls) -> bool:
return cls.get_blender_addon_package_name() in bpy.context.preferences.addons
@classmethod
def get_addon_preferences(cls) -> bonsai.bim.ui.BIM_ADDON_preferences:
blender_package_name = cls.get_blender_addon_package_name()
return bpy.context.preferences.addons[blender_package_name].preferences
@classmethod
def get_addon(cls, name: str) -> Union[types.ModuleType, None]:
import importlib
try:
return importlib.import_module(name) # Legacy Blender addon
except ImportError:
pass
for package_name in bpy.context.preferences.addons.keys():
if package_name.endswith(f".{name}"):
try:
return importlib.import_module(package_name)
except ModuleNotFoundError:
pass
@classmethod
def get_sun_props(cls) -> Union[SunPosProperties, None]:
assert (scene := bpy.context.scene)
return getattr(scene, "sun_pos_properties", None)
@classmethod
def scale_font_size(cls, size=None):
default_dpi = 72
default_pixel_size = 1.0
ui_style = bpy.context.preferences.ui_styles[0]
base_size = ui_style.widget.points if size is None else size
platform_scale = 0.5 if sys.platform == "darwin" else 1
default_scale = default_dpi * default_pixel_size
system = bpy.context.preferences.system
system_scale = system.dpi * system.pixel_size
return (
(system_scale / default_scale)
* base_size
* platform_scale
* tool.Blender.get_addon_preferences().decorator_font_scale
)
@classmethod
def apply_transform_as_local(cls, obj: bpy.types.Object) -> bool:
"""Apply object transforms as local matrix, if possible.
Clear parent and constraints.
:return: `True` if transform was applied and `False`
if transform wasn't applied it's not possible due to a shear.
"""
if not obj.parent and not obj.constraints:
return True
matrix = obj.matrix_world.copy()
# Matrix has a shear, it cannot be represented as a local matrix
# based on rotation+translation+scale.
if not matrix.to_3x3().is_orthogonal_axis_vectors:
return False
obj.parent = None
obj.constraints.clear()
obj.matrix_world = matrix
return True
@classmethod
def get_full_data_path(cls, bpy_struct: bpy.types.bpy_struct, path: str = "") -> str:
"""Get full data path to Blender entity or it's attributes.
:param bpy_struct: Blender entity.
:param path: Additional path to add to entity.
:return: Path in a format
``bpy.data.scenes['Scene'].BIMExplorerProperties.entity_attributes[4].enum_value``
"""
if path:
bpy_prop: bpy.types.bpy_prop # pyright: ignore[reportAttributeAccessIssue]
bpy_prop = bpy_struct.path_resolve(path, False)
return repr(bpy_prop)
return repr(bpy_struct)
@classmethod
def get_props_attribute_name(cls, props: bpy.types.PropertyGroup) -> str:
"""E.g. `bpy.data.objects['IfcAnnotation/TEXT'].BIMTextProperties` -> `BIMTextProperties`"""
return repr(props).rpartition(".")[-1]
@classmethod
def resolve_data_path_to_data_attr(cls, data_path: str) -> tuple[bpy.types.bpy_struct, str]:
"""
:param data_path: Non-full data path to attribute.
Examples:
- `preferences.prop_group.string_prop` (`preferences` would mean addon preferences)
- `scene.string_prop` (`scene` can be any member of `Context`)
:return: Resolved tuple of Blender Struct and property name.
Examples:
- `(preferences.prop_group, "string_prop")`
- `(scene, "string_prop")`
"""
# Get data to modify.
base_path, _, data_path_ = data_path.partition(".")
if base_path == "preferences":
data = tool.Blender.get_addon_preferences()
data_path = data_path_
else:
data = bpy.context
# Get property group if available.
base_path, _, attr = data_path.rpartition(".")
if base_path:
data = data.path_resolve(base_path)
return data, attr
@classmethod
@contextlib.contextmanager
def preserve_prop_value(cls, bpy_object: bpy.types.bpy_struct, prop_name: str):
if bpy_object.is_property_set(prop_name):
prop_value = getattr(bpy_object, prop_name)
else:
prop_value = ...
try:
yield
except:
raise
finally:
if prop_value is ...:
bpy_object.property_unset(prop_name)
return
setattr(bpy_object, prop_name, prop_value)
@classmethod
def set_prop_from_path(cls, bpy_object: bpy.types.bpy_struct, prop_path: str, value: Any) -> None:
"""Set `data_block` property value using path from `path_from_id`."""
T_ = TypeVar("T_", bound=bpy.types.bpy_struct)
def path_resolve(obj: T_, prop_path: str) -> tuple[T_, str]:
if "." in prop_path:
extra_path, prop_path = prop_path.rsplit(".", 1)
obj = obj.path_resolve(extra_path)
return obj, prop_path
obj, path = path_resolve(bpy_object, prop_path)
setattr(obj, path, value)
@classmethod
def get_microsoft_store_app_id(cls) -> Union[str, None]:
"""Get Microsoft Store app ID for current Blender instance.
:return: `None` if Blender is installed not from Microsoft Store (possibly using non-Windows platform).
Otherwise return app ID string (e.g. 'ppwjx1n5r4v9t').
"""
if os.name != "nt":
return None
blender_binary_path = Path(bpy.app.binary_path)
if len(blender_binary_path.parents) > 3 and blender_binary_path.parents[2].name == "WindowsApps":
return blender_binary_path.parents[1].name.rsplit("__", 1)[-1]
return None
@classmethod
def V_(cls, *args: float) -> Vector:
"""Just a shortcut for creating mathutils Vector."""
return Vector(args)
@classmethod
def detect_icon_color_mode(cls, color_path="user_interface.wcol_regular.text", threshold=1.671):
"""
Uses the text color of a given Blender UI property to determine if custom icons should be dark mode (dm) or light mode (lm).
Common Blender UI text color paths:
- "user_interface.wcol_regular.text" (Regular Text)
- "user_interface.wcol_tool.text" (Tool Text)
- "user_interface.wcol_menu_back.text" (Menu Background Text)
- "user_interface.wcol_menu.text" (Menu Text)
- "user_interface.wcol_menu.text_sel" (Menu Text Selected)
:param color_path: The attribute path relative to bpy.context.preferences.themes[0].
:param threshold: The RGB sum threshold for determining dark mode. Default is 1.671.
:return: 'dm' (dark mode) if the RGB sum is > threshold, otherwise 'lm' (light mode).
"""
full_path = f"bpy.context.preferences.themes[0].{color_path}"
try:
color = eval(full_path)[:3] # Dynamically evaluate and extract RGB values
rgb_sum = sum(color)
return "dm" if rgb_sum > threshold else "lm"
except Exception:
return "dm" # Default to dark mode if an error occurs
@classmethod
def get_internal_data_dir(cls) -> Path:
return Path(__file__).parent.parent / "bim" / "data"
@classmethod
def get_user_data_dir(cls) -> Path:
props = cls.get_addon_preferences()
return Path(props.data_dir)
@classmethod
def get_data_dir_path(cls, relative_path: Union[str, Path]) -> Path:
"""Get specified data path in data folder.
If this path exists in user folder, it takes the precedence."""
custom_path = cls.get_user_data_dir() / relative_path
if custom_path.exists():
return custom_path
return cls.get_internal_data_dir() / relative_path
@classmethod
def get_data_dir_paths(cls, relative_dir_path: Union[str, Path], glob_pattern: str) -> Generator[Path, None, None]:
"""Return paths based on glob pattern from the provided path in data folder.
Return paths from internal data folder first and then paths from the user data folder (if it exists)."""
custom_path = cls.get_user_data_dir() / relative_dir_path
if custom_path.is_dir():
for filepath in custom_path.glob(glob_pattern):
yield filepath
default_data_dir = cls.get_internal_data_dir()
if default_data_dir == custom_path:
return
for filepath in (default_data_dir / relative_dir_path).glob(glob_pattern):
yield filepath
@classmethod
def setup_user_data_dir(cls) -> None:
"""Setup empty folders in user data directory to make them more discoverable."""
custom_data_dir = cls.get_user_data_dir()
# Not all paths from internal data dir are listed here,
# only the ones that intended to be used by user.
paths_to_create = (
custom_data_dir,
custom_data_dir / "assets",
custom_data_dir / "libraries",
custom_data_dir / "pset", # pset templates.
custom_data_dir / "templates" / "projects",
custom_data_dir / "templates" / "titleblocks",
)
for path in paths_to_create:
path.mkdir(parents=True, exist_ok=True)
@classmethod
@lru_cache
def get_list_of_tools(cls) -> tuple[str, ...]:
from bonsai.bim.module.drawing.workspace import AnnotationTool
from bonsai.bim.module.model.workspace import BimTool
return tuple(cls.bl_idname for cls in (BimTool.__subclasses__() + [BimTool, AnnotationTool]))
@classmethod
@lru_cache
def get_tools_to_classes_map(cls) -> types.MappingProxyType[str, str]:
from bonsai.bim.module.model.workspace import BimTool
dct = {cls.bl_idname: cls.ifc_element_type for cls in (BimTool.__subclasses__())}
return types.MappingProxyType(dct)
@classmethod
def get_object_constraint_props(cls, obj: bpy.types.Object) -> BIMObjectConstraintProperties:
return obj.BIMObjectConstraintProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_constraint_props(cls) -> BIMConstraintProperties:
assert (scene := bpy.context.scene)
return scene.BIMConstraintProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_csv_props(cls) -> CsvProperties:
assert (scene := bpy.context.scene)
return scene.CsvProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_diff_props(cls) -> DiffProperties:
assert (scene := bpy.context.scene)
return scene.DiffProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_bim_props(cls, scene: Optional[bpy.types.Scene] = None) -> BIMProperties:
if scene is None:
assert (scene := bpy.context.scene)
return scene.BIMProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_area_props(cls, screen: bpy.types.Screen) -> bpy.types.bpy_prop_collection_idprop[BIMAreaProperties]:
return screen.BIMAreaProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_tab_props(cls, screen: bpy.types.Screen) -> BIMTabProperties:
return screen.BIMTabProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_collection_props(cls, collection: bpy.types.Collection) -> BIMCollectionProperties:
return collection.BIMCollectionProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_object_bim_props(cls, obj: bpy.types.Object) -> BIMObjectProperties:
return obj.BIMObjectProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_object_attribute_props(cls, obj: bpy.types.Object) -> BIMAttributeProperties:
return obj.BIMAttributeProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_solar_props(cls) -> BIMSolarProperties:
assert (scene := bpy.context.scene)
return scene.BIMSolarProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_radiance_exporter_props(cls) -> RadianceExporterProperties:
assert (scene := bpy.context.scene)
return scene.BIMRadianceExporeterProperies # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_fm_props(cls) -> BIMFMProperties:
assert (scene := bpy.context.scene)
return scene.BIMFMProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_covetool_props(cls) -> CoveToolProperties:
assert (scene := bpy.context.scene)
return scene.CoveToolProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_ifc_definition_id(cls, obj: IFC_CONNECTED_TYPE) -> int:
if isinstance(obj, bpy.types.Object):
return cls.get_object_bim_props(obj).ifc_definition_id
return tool.Style.get_material_style_props(obj).ifc_definition_id
@classmethod
def get_active_uilist_element(
cls, collection: bpy.types.bpy_prop_collection_idprop[T], index: int
) -> Union[T, None]:
if 0 <= index < len(collection):
return collection[index]
return None
@classmethod
def get_valid_uilist_index(cls, current_index: int, items: Sized) -> int:
"""
Method to help maintaining item selection after some uilist item was removed
and items were reloaded.
"""
return max(0, min(current_index, len(items) - 1))
@classmethod
def clear_undo_history(cls) -> None:
"""Clears the Blender history, Bonsai history, and IfcOpenShell history"""
assert (preferences := bpy.context.preferences)
old_undo_steps = preferences.edit.undo_steps
preferences.edit.undo_steps = 2
for i in range(3):
bpy.ops.ed.undo_push(message="Undo history cleared")
preferences.edit.undo_steps = old_undo_steps
tool.Ifc.clear_history()
old_history_size = tool.Ifc.get().history_size
tool.Ifc.get().set_history_size(0)
tool.Ifc.get().set_history_size(old_history_size)
@classmethod
def get_unit_scale(cls) -> float:
assert (scene := bpy.context.scene)
unit_length = scene.unit_settings.length_unit
unit_scale = 1.0
if unit_length == "CENTIMETERS":
unit_scale = 0.01
if unit_length == "MILLIMETERS":
unit_scale = 0.001
if unit_length == "FEET":
unit_scale = 0.3048
return unit_scale
@classmethod
def reset_object_visibility(cls):
override = cls.get_viewport_context()
with bpy.context.temp_override(**override):
bpy.ops.object.hide_view_clear(select=False)
@classmethod
def isolate_objects(cls, objs):
previously_selected = {o.name for o in bpy.context.selected_objects}
previously_active = bpy.context.view_layer.objects.active
override = cls.get_viewport_context()
with bpy.context.temp_override(**override):
bpy.ops.object.hide_view_clear(select=False)
bpy.ops.object.select_all(action="DESELECT")
for obj in objs:
obj.select_set(True)
with bpy.context.temp_override(**override):
bpy.ops.object.hide_view_set(unselected=True)
bpy.ops.object.select_all(action="DESELECT")
for name in previously_selected:
obj = bpy.data.objects.get(name)
if obj:
obj.select_set(True)
bpy.context.view_layer.objects.active = previously_active
@classmethod
def sync_render_visibility(cls):
# Doing bpy.ops.object.hide_render_clear_all() or
# bpy.ops.object.isolate_type_render() is extremely slow.
# Hopefully this doesn't crash on Windows, it doesn't crash on Linux.
should_hides = [0 if obj.visible_get() else 1 for obj in bpy.data.objects]
should_hides = np.fromiter(should_hides, dtype=np.uint8, count=len(should_hides))
bpy.data.objects.foreach_set("hide_render", should_hides)
return # Otherwise...
# for obj in bpy.data.objects:
# if not obj.data:
# continue
# # For speed, check equality prior to change to prevent needless updates
# if (is_visible := obj.visible_get()) and obj.hide_render is True:
# obj.hide_render = False
# elif not is_visible and obj.hide_render is False:
# obj.hide_render = True
@classmethod
def hide_objects(cls, objs):
previously_selected = {o.name for o in bpy.context.selected_objects}
previously_active = bpy.context.view_layer.objects.active
override = cls.get_viewport_context()
bpy.ops.object.select_all(action="DESELECT")
for obj in objs:
obj.select_set(True)
with bpy.context.temp_override(**override):
bpy.ops.object.hide_view_set(unselected=False)
for name in previously_selected:
obj = bpy.data.objects.get(name)
if obj:
obj.select_set(True)
bpy.context.view_layer.objects.active = previously_active
@classmethod
def show_objects(cls, objs):
previously_selected = {o.name for o in bpy.context.selected_objects}
previously_active = bpy.context.view_layer.objects.active
bpy.ops.object.select_all(action="DESELECT")
override = cls.get_viewport_context()
with bpy.context.temp_override(**override):
bpy.ops.object.hide_view_clear(select=True)
for obj in bpy.context.selected_objects:
if obj in objs:
obj.select_set(False)
with bpy.context.temp_override(**override):
bpy.ops.object.hide_view_set(unselected=False)
bpy.ops.object.select_all(action="DESELECT")
for name in previously_selected:
obj = bpy.data.objects.get(name)
if obj:
obj.select_set(True)
bpy.context.view_layer.objects.active = previously_active
@classmethod
def validate_shader_batch_data(cls, pos: Any, indices: Optional[Any]) -> bool:
"""Validate shader batch data.
If method returns ``False``, then drawing for this batch should be skipped.
Should be used always before running ``batch.draw(shader)``
Important because in Blender 4.4.0 on Mac passing an empty list
as ``indices`` is causing a crash.
See https://projects.blender.org/blender/blender/issues/136831
"""
# Checking `pos` is not critical but we keep it
# to ensure batch data is always validated to avoid crashes.
if len(pos) == 0 or (indices is not None and len(indices) == 0):
return False
return True
@classmethod
def extract_error_reports(cls, exception: RuntimeError) -> list[str]:
"""Extracts error report lines from a runtime exception during operator execution.
If operator had any `ERROR` reports, it will always raise a `RuntimeError`,
no matter what status is returned.
And sometimes it's useful to pass those reports to another operator
that called it. That way user won't get to see a scary traceback.
If empty list is returned, then exception should be reraised,
as it is an actual unhandled runtime error.
"""
error_message = str(exception)
extracted: list[str] = []
# If operator was cancelled and had error message,
# it will always start with this (warnings and info msgs are ignored).
if not error_message.startswith("Error: "):
return extracted
# Ignore actual runtime errors, as they has to be handled separately
# and not just rereported.
if error_message.startswith("Error: Python: Traceback (most recent call last):"):
return extracted
for report in error_message.strip().split("Error: "):
report = report.strip()
if not report:
continue
extracted.append(report)
return extracted
@classmethod
def report_operator_errors(cls, operator: bpy.types.Operator, error_reports: list[str]) -> None:
for report in error_reports:
operator.report({"ERROR"}, report)
@classmethod
@contextlib.contextmanager
def bonsai_crash_txt(cls, s: str = "") -> Generator[Path, Any, None]:
"""Create a temporary bonsai.crash.txt file the with current traceback.
Useful in case Blender crash might occur too unexpectedly (e.g. #6686),
and at least we'll have a slightest clue on what happened.
Intended to be used via `with` block.
`atexit` wouldn't work for this as crash breaks everything
and no callbacks are called.
:param s: Optional string to add at the top of the txt file.
"""
# TODO: Indicate that crash occurred after Blender restart?
# Create a temp file with the traceback.
temp_dir = tempfile.gettempdir()
path = Path(temp_dir) / "bonsai.crash.txt"
traceback_ = "\n".join(traceback.format_stack())
output = ""
if s:
output += f"{s}\n\n"
time = datetime.now().isoformat()
output += f"Created at: {time} (local time).\n"
output += f"Traceback (most recent called last):\n{traceback_}"
path.write_text(output)
yield path
# Remove file if crash didn't happened.
path.unlink()
@classmethod
def sync_old_preferences(cls) -> None:
# Added on 25.07.15.
# TODO: deprecate later.
settings_remap = {
"scene.BIMBSDDProperties.load_preview_dictionaries": "preferences.bsdd_load_preview_dictionaries",
"scene.BIMBSDDProperties.load_inactive_dictionaries": "preferences.bsdd_load_inactive_dictionaries",
"scene.BIMBSDDProperties.load_test_dictionaries": "preferences.bsdd_load_test_dictionaries",
"scene.BIMProjectProperties.should_disable_undo_on_save": "preferences.should_disable_undo_on_save",
"scene.BIMProjectProperties.should_stream": "preferences.should_stream",
"scene.BIMModelProperties.occurrence_name_style": "preferences.occurrence_name_style",
"scene.BIMModelProperties.occurrence_name_function": "preferences.occurrence_name_function",
"scene.BIMProperties.pset_dir": "preferences.pset_dir",
"scene.BIMProperties.data_dir": "preferences.data_dir",
"scene.BIMProperties.cache_dir": "preferences.cache_dir",
"scene.DocProperties.sheets_dir": "preferences.doc.sheets_dir",
"scene.DocProperties.layouts_dir": "preferences.doc.layouts_dir",
"scene.DocProperties.titleblocks_dir": "preferences.doc.titleblocks_dir",
"scene.DocProperties.drawings_dir": "preferences.doc.drawings_dir",
"scene.DocProperties.stylesheet_path": "preferences.doc.stylesheet_path",
"scene.DocProperties.schedules_stylesheet_path": "preferences.doc.schedules_stylesheet_path",
"scene.DocProperties.markers_path": "preferences.doc.markers_path",
"scene.DocProperties.symbols_path": "preferences.doc.symbols_path",
"scene.DocProperties.patterns_path": "preferences.doc.patterns_path",
"scene.DocProperties.shadingstyles_path": "preferences.doc.shadingstyles_path",
"scene.DocProperties.shadingstyle_default": "preferences.doc.shadingstyle_default",
"scene.DocProperties.drawing_font": "preferences.doc.drawing_font",
"scene.DocProperties.magic_font_scale": "preferences.doc.magic_font_scale",
"scene.DocProperties.imperial_precision": "preferences.doc.imperial_precision",
"scene.DocProperties.tolerance": "preferences.doc.tolerance",
"scene.DocProperties.classes_to_wireframe": "preferences.doc.classes_to_wireframe",
"scene.DocProperties.classes_no_cut": "preferences.doc.classes_no_cut",
}
props_updated = False
for old_path, path in settings_remap.items():
data, attr = cls.resolve_data_path_to_data_attr(path)
# User already overridden the value.
if data.is_property_set(attr):
continue
data_old, attr_old = cls.resolve_data_path_to_data_attr(old_path)
# User was only using default value previously.
if attr_old not in data_old:
continue
old_value = data_old[attr_old]
print(f"Updating {path} based on previous value from {old_path} - '{old_value}'.")
setattr(data, attr, old_value)
props_updated = True
# Doesn't seem to save on exit if edited from Python API, so we do it manually.
assert bpy.context.preferences
if props_updated and bpy.context.preferences.use_preferences_save:
bpy.ops.wm.save_userpref()
@classmethod
def get_eevee_name(cls) -> Literal["BLENDER_EEVEE"] | Literal["BLENDER_EEVEE_NEXT"]:
"""Convenience method to get correct eevee render engine name in multiple Blender versions.
In Blender 4.2 eevee was renamed to "eevee next".
In Blender 5 eevee next is now just "eevee" again.
"""
if cls.BLENDER_5:
return "BLENDER_EEVEE"
return "BLENDER_EEVEE_NEXT"
@classmethod
def np_frombuffer_legacy(cls, bytedata: bytes, n: int) -> npt.NDArray[np.float32]:
"""
Read ``n`` float values from ``bytedata``, regardless if they are stored as ``float32`` or ``float64``.
Needed to support .blend files saved in Blender <5.0.0.
Also allows to work with .blend files from 5.0.0+ in older Blender versions.
In ``bpy.app.version >= 5.0.0`` ``mathutils`` transitioned to use ``float32`` buffer type,
while in previous version they were using ``float64``.
In some cases we are storing raw bytes (e.g. object transforms cheksums), so old .blend files
might still have ``float64`` data stored.
See https://projects.blender.org/blender/blender/issues/149283
"""
if len(bytedata) == (n * 2):
return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32)
return np.frombuffer(bytedata, dtype=np.float32)
@classmethod
def np_array_legacy(cls, mathutils_type: Union[Vector, Matrix]) -> npt.NDArray[np.float32]:
"""
Converts ``mathutils`` types to ``np.float32`` arrays, regardless of Blender version.
See ``np_frombuffer_legacy`` for more details.
"""
if cls.BLENDER_5:
return np.array(mathutils_type)
return np.array(mathutils_type, dtype=np.float32)
@classmethod
def get_selected_files(
cls, directory: str, files: bpy.types.OperatorFileListElement, use_relative_path=False
) -> list[Path]:
return [
tool.Ifc.get_uri(Path(directory) / f.name, use_relative_path=use_relative_path)
for f in files
if (Path(directory) / f.name).is_file()
]
@classmethod
def ray_cast_scene(
cls,
context: bpy.types.Context,
origin: Vector,
direction: Vector,
) -> tuple[bool, Vector, Vector, int, bpy.types.Object, Matrix]:
"""
The returned matrix is just ``obj.matrix_world``.
The returned object is not evaluated by the current depsgraph,
e.g. if object is modified by the depsgraph (e.g. by modifiers)
object has to be evaluated first (`obj.evaluated_get(depsgraph)`).
"""
depsgraph = context.evaluated_depsgraph_get()
assert context.scene
result = context.scene.ray_cast(
depsgraph,
origin,
direction,
)
return result
@classmethod
def depsgraph_evaluate(cls, obj: bpy.types.Object) -> bpy.types.Object:
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated_obj = obj.evaluated_get(depsgraph)
return evaluated_obj