Files
IfcOpenShell/src/bonsai/bonsai/bim/module/misc/operator.py
T
Andrej730 594d72d7e1 Quick Favorites Manager
Blender doesn't have it's own quick favorites manager and working with them can be not very flexible - you can add them in context menu and remove them from Quick Favorites menu. But you can't reorder them, you can't rename them and you can't even add a new button to favorites if it's not added by some addon in the UI.

Have been stumbling upon this for awhile and decided to create an experimental manager UI for this. Things it can do:
- help user create a button with any operator in Blender and properties they prefer to then save it Quick Favorites. Which seems can be very useful in Bonsai, since you can create separate buttons for all kinds of selectors expressions, class assignment or other operators.

- it can import quick favorites from user's actual current quick favorites, so they can just modify them a bit, reorder, rename and then add them again.

- Since quick favorites are not exposed to Python API in Blender, we're using a very hacky way to retrieve them from Blender and don't provide our own buttons for adding and removing quick favorites, as it may be dangerous and even more hacky in implementation. So the workflow for user is to either generate some buttons and add them to quick favorites using Manager or to import it's own quick favorites, then change them how they like, then remove quick favorites using usual quick favorites menu and then add new button one by one.

Small demo - https://files.catbox.moe/vyffp6.mp4
2026-03-11 18:38:24 +05:00

520 lines
20 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/>.
from typing import TYPE_CHECKING, Literal, assert_never, cast, get_args
import bpy
import ifcopenshell.util.geolocation
import ifcopenshell.util.placement
import ifcopenshell.util.unit
import numpy as np
from mathutils import Matrix
import bonsai.core.geometry as core_geometry
import bonsai.core.misc as core
import bonsai.core.root
import bonsai.tool as tool
if TYPE_CHECKING:
from bpy.stub_internal import rna_enums
class SetOverrideColour(bpy.types.Operator):
bl_idname = "bim.set_override_colour"
bl_label = "Set Override Colour"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.selected_objects
def execute(self, context):
props = tool.Misc.get_misc_props()
for obj in context.selected_objects:
obj.color = props.override_colour
assert (space := tool.Blender.get_view3d_space())
space.shading.color_type = "OBJECT"
return {"FINISHED"}
class SnapSpacesTogether(bpy.types.Operator):
bl_idname = "bim.snap_spaces_together"
bl_label = "Snap Spaces Together"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.selected_objects
def execute(self, context):
threshold = 0.5
processed_polygons = set()
selected_mesh_objects = [o for o in context.selected_objects if o.type == "MESH"]
for obj in selected_mesh_objects:
for polygon in obj.data.polygons:
center = obj.matrix_world @ polygon.center
distance = None
for obj2 in selected_mesh_objects:
if obj2 == obj:
continue
result = obj2.ray_cast(obj2.matrix_world.inverted() @ center, polygon.normal, distance=threshold)
if not result[0]:
continue
hit = obj2.matrix_world @ result[1]
distance = (hit - center).length / 2
if distance < 0.01:
distance = None
break
if (obj2.name, result[3]) in processed_polygons:
distance *= 2
continue
offset = polygon.normal * distance * -1
processed_polygons.add((obj2.name, result[3]))
for v in obj2.data.polygons[result[3]].vertices:
obj2.data.vertices[v].co += offset
break
if distance:
offset = polygon.normal * distance
processed_polygons.add((obj.name, polygon.index))
for v in polygon.vertices:
obj.data.vertices[v].co += offset
return {"FINISHED"}
class ResizeToStorey(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.resize_to_storey"
bl_label = "Resize To Storey"
bl_description = (
"Change object's origin to the bottom, move object to it's storey elevation and scale object to storey height.\n"
"Storey height is based on the provided number of storeys above object's storey.\n"
"If object's storey is the last storey, operator will have no effect"
)
bl_options = {"REGISTER", "UNDO"}
total_storeys: bpy.props.IntProperty()
@classmethod
def poll(cls, context):
return context.selected_objects and tool.Ifc.get()
def _execute(self, context):
for obj in context.selected_objects:
if not (element := tool.Ifc.get_entity(obj)):
continue
if element.HasOpenings:
self.report({"ERROR"}, f"Object '{obj.name}', scaling is not supported.")
continue
core.resize_to_storey(tool.Misc, tool.Ifc, obj=obj, total_storeys=self.total_storeys)
SplitAlongEdgeMode = Literal["BOOLEAN", "BISECT"]
class SplitAlongEdge(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.split_along_edge"
bl_label = "Split Along Edge"
bl_description = (
"Split selected objects by the face of the cutter object.\n"
"Active object is considered to be a cutting object. "
"It can be a simple Blender object (e.g. a plane), not connected to IFC. "
"Will unassign element from a type if type has a representation."
)
bl_options = {"REGISTER", "UNDO"}
mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
default="BOOLEAN",
items=tuple((i, i, "") for i in get_args(SplitAlongEdgeMode)),
)
if TYPE_CHECKING:
mode: SplitAlongEdgeMode
@classmethod
def poll(cls, context):
if not (obj := context.active_object) or obj.type != "MESH":
cls.poll_message_set("No active mesh object is selected.")
return False
if not context.selected_objects:
cls.poll_message_set("No objects selected")
return False
return True
def _execute(self, context):
cutter = context.active_object
assert cutter
objs = [o for o in context.selected_objects if o != cutter and o.type == "MESH"]
if not objs:
self.report({"ERROR"}, "No other mesh objects selected besides the cutter object.")
return {"CANCELLED"}
if not tool.Ifc.get():
if self.mode == "BOOLEAN":
tool.Misc.boolean_objects_with_cutter(objs, cutter)
elif self.mode == "BISECT":
tool.Misc.bisect_objects_with_cutter(objs, cutter)
for obj in objs:
bpy.data.objects.remove(obj)
else:
assert_never(self.mode)
return
objs_to_cut: list[bpy.types.Object] = []
# Splitting only works on meshes
for obj in objs:
# You cannot split meshes if the representation is mapped.
element = tool.Ifc.get_entity(obj)
if not element:
continue
relating_type = tool.Root.get_element_type(element)
if relating_type and tool.Root.does_type_have_representations(relating_type):
bpy.ops.bim.unassign_type(related_object=obj.name)
# refresh representation
representation = tool.Geometry.get_active_representation(obj)
# skip empty objects that might get in the way
if not representation:
continue
core_geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
apply_openings=False,
)
if not tool.Geometry.is_meshlike(representation):
bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="IfcTessellatedFaceSet")
objs_to_cut.append(obj)
if self.mode == "BOOLEAN":
new_objs = tool.Misc.boolean_objects_with_cutter(objs_to_cut, cutter)
elif self.mode == "BISECT":
new_objs = tool.Misc.bisect_objects_with_cutter(objs_to_cut, cutter)
else:
assert_never(self.mode)
for obj in new_objs:
bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=obj)
bpy.ops.bim.update_representation(obj=obj.name)
for obj in objs_to_cut:
bpy.ops.bim.update_representation(obj=obj.name)
representation = tool.Geometry.get_active_representation(obj)
assert representation
core_geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
apply_openings=True,
)
self.report({"INFO"}, f"Splitting finished, {len(new_objs)} new objects created.")
class GetConnectedSystemElements(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.get_connected_system_elements"
bl_label = "Get Connected System Elements"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.selected_objects and tool.Ifc.get()
def _execute(self, context):
# Just dumped here for now before the system module gets properly planned
def pprint_element(e):
return "{} ({})".format(e.Name, e.GlobalId)
start = tool.Ifc.get_entity(bpy.context.active_object)
connected_elements = []
# Note: this code is for IFC2X3. IFC4 has a different approach.
print("Investigating element:", pprint_element(start))
for rel in start.HasPorts:
for rel2 in rel.RelatingPort.ConnectedTo:
print(
"{} is connected as via {} ({}) TO {} ({}), contained in {}".format(
pprint_element(start),
rel.RelatingPort.FlowDirection,
rel.RelatingPort.GlobalId,
rel2.RelatedPort.FlowDirection,
rel2.RelatedPort.GlobalId,
[pprint_element(r.RelatedElement) for r in rel2.RelatedPort.ContainedIn],
)
)
connected_elements.extend([r.RelatedElement for r in rel2.RelatedPort.ContainedIn])
for rel2 in rel.RelatingPort.ConnectedFrom:
print(
"{} is connected as via {} ({}) FROM {} ({}), contained in {}".format(
pprint_element(start),
rel.RelatingPort.FlowDirection,
rel.RelatingPort.GlobalId,
rel2.RelatingPort.FlowDirection,
rel2.RelatingPort.GlobalId,
[pprint_element(r.RelatedElement) for r in rel2.RelatingPort.ContainedIn],
)
)
connected_elements.extend([r.RelatedElement for r in rel2.RelatingPort.ContainedIn])
for element in connected_elements:
obj = tool.Ifc.get_object(element)
if obj:
obj.select_set(True)
class DrawSystemArrows(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.draw_system_arrows"
bl_label = "Draw System Arrows"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.selected_objects and tool.Ifc.get()
def _execute(self, context):
sinks = []
sources = []
for obj in bpy.context.selected_objects:
if not tool.Blender.get_ifc_definition_id(obj):
continue
element = tool.Ifc.get_entity(obj)
sources_current = []
sinks_current = []
for port in tool.System.get_ports(element):
local_placement = ifcopenshell.util.placement.get_local_placement(port.ObjectPlacement)
m = self.get_absolute_matrix(local_placement)
if port.FlowDirection == "SOURCE":
sources_current.append(m)
elif port.FlowDirection == "SINK":
sinks_current.append(m)
else:
sources_current.append(m)
sinks_current.append(m)
if sinks_current or sources_current:
sinks.append(sinks_current)
sources.append(sources_current)
if not sinks:
self.report({"INFO"}, "No sinks/sources found for selected objects.")
return {"FINISHED"}
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
curve = bpy.data.objects.new("System Arrows", bpy.data.curves.new("System Arrows", "CURVE"))
curve.data.dimensions = "3D"
curve.show_in_front = True
context.scene.collection.objects.link(curve)
for i in range(len(sinks)):
for sink in sinks[i]:
for source in sources[i]:
polyline = curve.data.splines.new("POLY")
polyline.points.add(1)
polyline.points[0].co = (Matrix(sink).translation * unit_scale).to_4d()
polyline.points[1].co = (Matrix(source).translation * unit_scale).to_4d()
tool.Blender.select_and_activate_single_object(context, curve)
def get_absolute_matrix(self, matrix):
props = tool.Georeference.get_georeference_props()
if props.has_blender_offset:
matrix = np.array(
ifcopenshell.util.geolocation.global2local(
matrix,
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
float(props.blender_x_axis_abscissa),
float(props.blender_x_axis_ordinate),
)
)
return matrix
class EnableQuickFavoriteSearch(bpy.types.Operator):
bl_idname = "bim.enable_quick_favorite_search"
bl_label = "Enable Search"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
index: int
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Misc.get_misc_props()
fav = props.quick_favorites[self.index]
name = fav.search.strip()
# TODO: don't use try / except.
try:
module, func = name.split(".", 1)
op = getattr(getattr(bpy.ops, module), func)
rna = cast(bpy.types.Struct, op.get_rna_type())
except (ValueError, AttributeError):
self.report({"ERROR"}, f"Operator '{name}' not found.")
return {"CANCELLED"}
fav.operator_id = name
fav.label = rna.name
fav.properties.clear()
has_skipped = False
for p in rna.properties:
# skip silently, e.g. `rna_type` is a PointerProperty
if isinstance(p, bpy.types.PointerProperty):
continue
if isinstance(p, (bpy.types.FloatProperty, bpy.types.BoolProperty, bpy.types.IntProperty)) and p.is_array:
print(f"Array property '{p.identifier}' is not supported, skipping.")
has_skipped = True
continue
item = fav.properties.add()
item.name = p.identifier
item.display_name = p.name
if isinstance(p, bpy.types.FloatProperty):
item.value_prop = "float_value"
item.float_value = p.default
elif isinstance(p, bpy.types.BoolProperty):
item.value_prop = "bool_value"
item.bool_value = p.default
elif isinstance(p, bpy.types.IntProperty):
item.value_prop = "int_value"
item.int_value = p.default
elif isinstance(p, (bpy.types.StringProperty, bpy.types.EnumProperty)):
# TODO: support displaying enum items in the UI for EnumProperty
item.value_prop = "string_value"
item.string_value = p.default
else:
print(f"Unhandled property type {type(p).__name__} for '{p.identifier}', skipping.")
has_skipped = True
if has_skipped:
self.report({"WARNING"}, "Some properties were skipped, see the system console for details.")
return {"FINISHED"}
class ImportQuickFavorites(bpy.types.Operator):
bl_idname = "bim.import_quick_favorites"
bl_label = "Import Quick Favorites"
bl_description = "Import operators from Blender's Quick Favorites menu, including their configured properties"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Misc.get_misc_props()
props.quick_favorites.clear()
has_missing_props = False
for i, qf in enumerate(tool.Misc.QuickFavorites.get_quick_favorites()):
fav = props.quick_favorites.add()
fav.label = qf.ui_name
fav.search = qf.op_idname_py
bpy.ops.bim.enable_quick_favorite_search(index=i)
fav.label = qf.ui_name or fav.label
for prop in fav.properties:
prop.is_active = prop.name in qf.props
for key, value in qf.props.items():
if key not in fav.properties:
print(f"Property '{key}' not found in operator '{qf.op_idname_py}'.")
has_missing_props = True
continue
item = fav.properties[key]
setattr(item, item.value_prop, value)
if has_missing_props:
self.report(
{"WARNING"}, "Some properties were not found during import, see the system console for details."
)
return {"FINISHED"}
class MoveQuickFavoritesItem(bpy.types.Operator):
bl_idname = "bim.move_quick_favorites_item"
bl_label = "Move Quick Favorites Item"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
direction: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[("UP", "Up", ""), ("DOWN", "Down", "")]
)
if TYPE_CHECKING:
index: int
direction: Literal["UP", "DOWN"]
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Misc.get_misc_props()
total = len(props.quick_favorites)
new_index = self.index - 1 if self.direction == "UP" else self.index + 1
if 0 <= new_index < total:
props.quick_favorites.move(self.index, new_index)
return {"FINISHED"}
class RemoveQuickFavoritesItem(bpy.types.Operator):
bl_idname = "bim.remove_quick_favorites_item"
bl_label = "Remove Quick Favorites Item"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
index: int
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Misc.get_misc_props()
props.quick_favorites.remove(self.index)
return {"FINISHED"}
class AddQuickFavoritesItem(bpy.types.Operator):
bl_idname = "bim.add_quick_favorites_item"
bl_label = "Add Quick Favorites Item"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Misc.get_misc_props()
props.quick_favorites.add()
return {"FINISHED"}
class IfcSverchokUseBonsaiFile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.ifcsverchok_use_bonsai_file"
bl_label = "Use Bonsai IFC File"
bl_description = "Apply current IfcSverchok tree to the active Bonsai file."
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
import sverchok.node_tree
ifc_file = tool.Ifc.get()
if ifc_file is None:
self.report({"ERROR"}, "No active IFC file.")
return {"CANCELLED"}
space_data = context.space_data
assert isinstance(space_data, bpy.types.SpaceNodeEditor)
node_tree = space_data.node_tree
assert isinstance(node_tree, sverchok.node_tree.SverchCustomTree)
tool.Model.run_ifcsverchok_graph_on_bonsai_file(node_tree)