import hashlib
import inspect
from functools import partial
from itertools import chain
from typing import Any, Callable, Sequence, Union
import bpy
import mathutils
import numpy as np
from typing_extensions import ParamSpec
import ddg.visualization.blender.collection as bcollections
from ddg.visualization.blender import collection
P = ParamSpec("P")
def _md5(a: str) -> str:
h = hashlib.md5()
h.update(a.encode("utf-8"))
return str(int(h.hexdigest(), base=16))
def _key(*args: Any, **kwargs: Any) -> str:
return _md5(str(args + tuple(kwargs.items())))
[docs]def add_props_with_callback_from_constructors(
callback: Callable[..., Any],
positional_labels_constructors: dict[str, Callable[..., Any]],
keyword_labels_constructors: dict[str, Callable[..., Any]],
) -> None:
"""
A more advanced version of :func:`add_props_with_callback`.
Unlike :func:`add_props_with_callback`, this function doesn't infer
what kind of Blender property to add. Instead, property constructors must
be explicitly specified, which allows for control over the minimum and
maximum value, step sizes and other property types.
Parameters
----------
callback : Callable
This function is called whenever the properties' value change.
positional_labels_constructors : dict[str, Callable[..., Any]]
The keys are the labels of the properties.
The values are Blender property constructors whose values will
be passed to `callback` as positional arguments.
keyword_labels_constructors : dict[str, Callable[..., Any]]
The keys are the labels of the properties.
The values are Blender property constructors whose values will
be passed to `callback` as keyword arguments.
Raises
------
ValueError
If the labels aren't unique.
Examples
--------
See :ref:`advanced props` in the user's guide.
See also
--------
add_props_with_callback
"""
positional_labels = list(positional_labels_constructors.keys())
keyword_labels = list(keyword_labels_constructors.keys())
labels = positional_labels + keyword_labels
if len(set(labels)) != len(labels):
raise ValueError(
"The labels must be unique across positional and keyword arguments."
)
update = update_func(callback, positional_labels, keyword_labels)
# When a property is set for bpy.context.scene (NOT bpy.types.Scene) or any
# other object for the first time, it
# - is saved to the .blend file
# - shows up in the custom properties panel.
# The latter may not occur, this is an upstream Blender bug.
#
# The update function is NOT saved and is lost when the .blend file is
# loaded again. In this case, it is necessary to run the following loop
# again to reconnect the update function the properties.
for label, property_constructor in chain(
positional_labels_constructors.items(), keyword_labels_constructors.items()
):
setattr(bpy.types.Scene, label, property_constructor(update=update))
# If the property already exists, don't touch it.
# This means that if the user reloads the .blend file, sets a property
# and reruns this function, then the update function is never called
# and there are no changes. The user needs to set the property again to
# run the update function. This avoids possibly expensive update calls.
#
# Why do we loop twice over the same iterable?
# Because setting properties triggers the update callback, which
# assumes that all properties have already been defined. Therefore
# it is necessary to set the properties AFTER they have been
# defined. Otherwise Blender will raise exceptions in the callback,
# which interestingly doesn't seem to prevent the callback from
# working as expected.
for label in labels:
set_prop_if_not_set(bpy.context.scene, label)
add_panel(labels)
# Workaround for https://developer.blender.org/T37473
bpy.app.handlers.frame_change_post.append(lambda scene: update(scene, bpy.context))
# Lock resources we modify to avoid segfault when the renderer tries to access them.
# See
# https://docs.blender.org/api/current/bpy.app.handlers.html#note-on-altering-data
bpy.context.scene.render.use_lock_interface = True
def _make_property_constructor(value: Union[int, float, str]) -> Any:
if isinstance(value, int):
return bpy.props.IntProperty
elif isinstance(value, float):
return bpy.props.FloatProperty
elif isinstance(value, str):
return bpy.props.StringProperty
else:
raise ValueError("value must be of type int, float or str")
[docs]def add_props_with_callback(
callback: Callable[..., Any],
arg_labels: Sequence[str],
*default_args: Any,
**default_kwargs: Any,
) -> None:
"""
Add Blender properties with a callback.
Parameters
----------
callback : Callable
This function is called whenever the properties' value change.
arg_labels : Sequence[str]
Labels for the properties. The number of labels must equal the
number of positional arguments of callback.
*default_args
Default positional arguments for callback
*default_kwargs
Default keyword arguments for callback
Raises
------
ValueError
If the length of `arg_labels` isn't equal to the number of
positional arguments of `callback`.
Examples
--------
See :ref:`animations` in the user's guide.
"""
if len(arg_labels) != len(default_args):
raise ValueError(
f"The number of labels = {len(arg_labels)} must equal the number of"
f" positional arguments of the callback function = {len(default_args)}"
)
property_constructors = [
partial(_make_property_constructor(value), default=value)
for value in default_args
]
property_constructors_kwargs = {
label: partial(_make_property_constructor(value), default=value)
for label, value in default_kwargs.items()
}
add_props_with_callback_from_constructors(
callback,
dict(zip(arg_labels, property_constructors)),
property_constructors_kwargs,
)
[docs]def overwrite_callback(
collection_name: str, blender_objects: Callable[P, Sequence[Any]]
) -> Callable[P, None]:
"""Overwrite existing objects.
The returned function will either
- link new Blender objects if the collection doesn't exist or is
empty and otherwise
- overwrite the `.data` of existing Blender objects in the
collection
Parameters
----------
collection : str
Name of a Blender collection
blender_objects : Callable
Returns a sequence of unlinked Blender objects
Returns
-------
Callable
"""
def callback(*args: P.args, **kwargs: P.kwargs) -> None:
bcollections.collection(collection_name)
# Setup the CollectionProperty storing the key for the cached meshes.
try:
# Cache keys already set.
bpy.data.collections[collection_name].is_property_set("cache_keys")
except TypeError:
# Cache keys need to be set.
bpy.types.Collection.cache_keys = bpy.props.CollectionProperty(
type=bpy.types.PropertyGroup
)
key = _key(*args, **kwargs)
target_bobjs = bpy.data.collections[collection_name].all_objects
if not target_bobjs: # collection is empty; create the Blender objects.
for bobj in blender_objects(*args, **kwargs):
bpy.data.collections[collection_name].objects.link(bobj)
_cache_mesh(bobj, bobj, bpy.data.collections[collection_name], key)
else: # Blender objects are already created, update their meshes.
if key in bpy.data.collections[collection_name].cache_keys: # Cache hit.
for bobj in target_bobjs:
bobj.data = bpy.data.meshes[_mesh_id(bobj, key)]
else: # Cache miss.
source_bobjs = blender_objects(*args, **kwargs)
for target_bobj, source_bobj in zip(
bpy.data.collections[collection_name].all_objects, source_bobjs
):
_cache_mesh(
target_bobj,
source_bobj,
bpy.data.collections[collection_name],
key,
)
for bobj in source_bobjs:
bpy.data.objects.remove(bobj, do_unlink=True)
return callback
def _mesh_id(bobj: Any, key: str) -> str:
return bobj.name + key
def _cache_mesh(target_bobj: Any, source_bobj: Any, collection: Any, key: str) -> None:
# We only need the new mesh.
mesh = source_bobj.data
# Protect the mesh from deletion when saving to .blend file.
mesh.use_fake_user = True
# Give the mesh a unique name.
mesh.name = _mesh_id(target_bobj, key)
target_bobj.data = mesh
# Add the key of the mesh to the collection.
new_cache_key = collection.cache_keys.add()
new_cache_key.name = key
[docs]def hide_callback(
collection_name: str, blender_objects: Callable[P, Sequence[Any]]
) -> Callable[P, None]:
"""
Hide old objects before displaying new objects.
The returned function hides every object in the collection and then
displays objects computed by `blender_objects`. It creates
subcollections for each tuple of properties/parameters.
It is possible to delete the parent collection or any number of
subcollections; they are recreated when necessary.
Parameters
----------
collection : str
Name of a Blender collection
blender_objects : Callable
Returns a sequence of unlinked Blender objects
Returns
-------
Callable
"""
# Storing the previous_key in a property makes sure that it survives
# saving/loading .blend files and undo/redos.
bpy.types.Collection.previous_key = bpy.props.StringProperty()
def callback(*args: P.args, **kwargs: P.kwargs) -> None:
collection = bcollections.collection(collection_name)
previous_key = collection.previous_key
# The first condition fails if and only if callback was never called.
# The second condition may fail if the user deletes
# collection.children[previous_key].
if previous_key in collection.children:
collection.children[previous_key].hide_viewport = True
collection.children[previous_key].hide_render = True
# We use collection as a cache for objects. Parameters are hashed and
# the hash is the name of a child collection. Users can directly
# interact with the collection/cache and delete child collections to
# clear the cache. The callback fails if the parent collection is
# deleted.
#
# It would be handy to use str(args) + str(kwargs) as a key/collection
# name, but this is impractical: Blender silently truncates collection
# names beyond a certain length, which forces cache misses.
key = _md5(str(args + tuple(kwargs.items())))
if key in collection.children: # "cache hit"
collection.children[key].hide_viewport = False
collection.children[key].hide_render = False
else: # "cache miss"
bobjs = blender_objects(*args, **kwargs)
child_collection = bcollections.collection(key, collection)
for bobj in bobjs:
child_collection.objects.link(bobj)
collection.previous_key = key
return callback
[docs]def clear_callback(
collection_name: str, blender_objects: Callable[P, Sequence[Any]]
) -> Callable[P, None]:
"""
Clear collection before linking new objects.
The returned function unlinks every object in the collection and
then links new objects computed by `blender_objects`.
Parameters
----------
collection : str
Name of a Blender collection
blender_objects : Callable
Returns a sequence of unlinked Blender objects
Returns
-------
Callable
"""
def callback(*args: P.args, **kwargs: P.kwargs) -> None:
bcollections.collection(collection_name)
collection.clear([bpy.data.collections[collection_name]], deep=True)
for bobj in blender_objects(*args, **kwargs):
bpy.data.collections[collection_name].objects.link(bobj)
return callback
[docs]def add_panel(
prop_names: Sequence[str],
text: str = "",
panel_label: str = "DDG Panel",
idname: str = "VIEW3D_PT_DDG",
category: str = "DDG",
):
"""
Create a Blender panel in the 3DViewport containing the properties.
Parameters
----------
prop_names : Sequence of str
The properties’ names to be added to the panel.
text : str
Text to be added at the beginning of the panel.
panel_label : str
Text used as the visible title of the panel.
idname : str
Name used internally to identify the panel.
Must begin with `VIEW3D_PT_`.
category : str
String used as the name of the tab.
Raises
------
ValueError
If `idname` does not start with `VIEW3D_PT_`.
"""
idname_prefix = "VIEW3D_PT_"
if not idname.startswith(idname_prefix):
docs_link = "https://docs.blender.org/api/current/bpy.types.Panel.html#bpy.types.Panel.bl_idname" # noqa: E501
raise ValueError(f"idname must start with {idname_prefix}. See {docs_link}")
class Panel(bpy.types.Panel):
bl_region_type = "UI"
bl_space_type = "VIEW_3D"
bl_category = category
bl_idname = idname
bl_label = panel_label
def draw(self, context):
if text:
self.layout.label(text=text)
col = self.layout.column()
for prop_name in prop_names:
row = col.row()
row.prop(context.scene, prop_name)
bpy.utils.register_class(Panel)
[docs]def update_func(
callback: Callable[..., Any],
arg_names: Sequence[str],
kwarg_names: Sequence[str],
) -> Callable[[Any, Any], None]:
"""
Turn a function into a property update function.
Parameters
----------
callback : Callable
Callable which will be called when the properties value change.
arg_names : Sequence of str
The names of the properties corresponding to positional
arguments of callback.
arg_names : Sequence of str
The names of the properties corresponding to keyword arguments
of callback.
Returns
-------
update : Callable
A function to be used in e.g. `bpy.props.IntProperty(update=update)`.
"""
def update(self: Any, context: Any) -> None:
if all(self.is_property_set(label) for label in chain(arg_names, kwarg_names)):
args = tuple(getattr(self, label) for label in arg_names)
kwargs = {key: getattr(self, key) for key in kwarg_names}
callback(*args, **kwargs)
return update
[docs]def set_prop_if_not_set(blender_id: Any, prop: str) -> None:
"""
Set `blender_id.prop` to `value` if not `blender_id.is_property_set(prop)`.
This is a no-op when executed after loading from a .blend file that
previously set `prop`.
Parameters
----------
blender_id : Any
prop : str
"""
if not blender_id.is_property_set(prop):
setattr(blender_id, prop, getattr(blender_id, prop))
# Inspired by
# https://stackoverflow.com/questions/22119850/get-all-class-names-in-a-python-package#answer-22119966
mathutils_classes = [cls[1] for cls in inspect.getmembers(mathutils)]
def _serialize_if_mathutils(obj):
if type(obj) in mathutils_classes:
return np.array(obj)
else:
return obj
[docs]def save_props(file_name, bstruct, props):
"""Save properties of a Blender object to a file.
Parameters
----------
file_name : str
The name of the file to save the props to.
bstruct : bpy_struct
The Blender object whose properties should be saved.
props : A list of str
Each string is the name of a property.
"""
data = np.array(
{prop: _serialize_if_mathutils(getattr(bstruct, prop)) for prop in props}
)
np.save(file_name, data, allow_pickle=True)
[docs]def load_props(file_name, bstruct):
"""Load the properties from and set them to a Blender object.
Assumes that the file was generated with
`ddg.visualization.blender.props.save_props`.
Parameters
----------
file_name : str
The name of the file from which to load the props.
bstruct : bpy_struct
The Blender object whose properties should be set.
"""
data = np.load(file_name, allow_pickle=True)
for key, value in data.take(0).items():
setattr(bstruct, key, value)