import functools
import inspect
import traceback
from functools import partial
from itertools import chain
from typing import Any, Callable, ParamSpec, Sequence, Union
import bpy
import mathutils
import numpy as np
import ddg
import ddg._error_messages as em
P = ParamSpec("P")
def _append_to_name(bobj_or_collection, suffix, /) -> None:
if isinstance(bobj_or_collection, bpy.types.Object):
bobj = bobj_or_collection
bobj.name = f"{bobj.name} " + suffix
bobj.data.name = f"{bobj.data.name} " + suffix
elif isinstance(bobj_or_collection, bpy.types.Collection):
collection = bobj_or_collection
collection.name = f"{collection.name} " + suffix
for bobj in collection.objects:
_append_to_name(bobj, suffix)
for child in collection.children:
_append_to_name(child, suffix)
else:
raise Exception(em.should_never_happen)
[docs]def hide_previous(
func: Callable[P, Union[bpy.types.Collection, bpy.types.Object]], /
) -> Callable[P, None]:
"""Show and hide collections which depend on parameters.
Parameters
----------
func : Callable
A function that returns either a Blender object or collection.
Every Blender object and collection returned by `func` must be
created within `func`.
Every parameter must be hashable.
Returns
-------
Callable
This function has the same signature as `func`. It caches the
output and selectively hides Blender objects and collections
returned by `func` such that only the last call is visible.
Examples
--------
>>> import bpy
>>> import ddg
>>>
>>> @ddg.blender.props.hide_previous
... def f(x):
... # The collection is created within f and cannot be factored out.
... collection = ddg.blender.collection.collection("Collection")
... p = ddg.arrays.Points((x, 0, 0))
... # The object is created within f and cannot be factored out.
... ddg.blender.convert(p, "Point", collection=collection)
... return collection
...
>>> set(bpy.data.collections)
set()
>>> set(bpy.data.objects)
set()
Calling `f` creates a new collection.
>>> collection_0 = f(-5.3)
Note that the name of the collection and the Blender object it contains
enumerate the number of calls to `f`.
>>> collection_0
bpy.data.collections['Collection 0']
>>> set(bpy.data.collections)
{bpy.data.collections['Collection 0']}
>>> set(collection_0.objects)
{bpy.data.objects['Point 0']}
The results of the last (in this case also the first) call to `f` are both visible.
>>> collection_0.hide_render
False
>>> collection_0.hide_viewport
False
Calling `f` again creates another collection.
>>> collection_1 = f(2.7)
>>> collection_1
bpy.data.collections['Collection 1']
>>> set(bpy.data.collections)
{bpy.data.collections['Collection 0'], bpy.data.collections['Collection 1']}
>>> set(collection_1.objects)
{bpy.data.objects['Point 1']}
The results of the previous calls to `f` are now hidden. In this example,
only one collection, namely collection_0, is hidden. The results of the last
call to `f` are visible.
>>> collection_0.hide_render
True
>>> collection_0.hide_viewport
True
>>> collection_1.hide_render
False
>>> collection_1.hide_viewport
False
If we pass -5.3 to `f` a second time, we will get the cached result.
>>> collection_0_again = f(-5.3)
>>> collection_0 == collection_0_again
True
>>> collection_0.hide_render
False
>>> collection_0.hide_viewport
False
>>> collection_1.hide_render
True
>>> collection_1.hide_viewport
True
"""
previous = None
g = functools.cache(func)
def f(*args: P.args, **kwargs: P.kwargs):
nonlocal previous
misses = g.cache_info().misses
bobj_or_collection = g(*args, **kwargs)
# It's possible that _append_to_name will try to set names that exceed
# the maximum length for Blender names. Blender will silently truncate
# the names in this case.
# Fortunately, this is unlikely to happen because the length of
# `suffix` is in O(log(g.cache_info().currsize)). Even if it does
# happen, the cache stores a direct reference to bobj_or_collection -
# we don't use the names anywhere - so `f` will continue to work even
# if the names get truncated.
if g.cache_info().misses == misses + 1:
# Subtracting 1 means that the first call's suffix is 0
# instead of 1 and so on.
suffix = str(g.cache_info().currsize - 1)
_append_to_name(bobj_or_collection, suffix)
if not isinstance(bobj_or_collection, (bpy.types.Object, bpy.types.Collection)):
raise TypeError(
f"The `func` argument of {ddg.blender.props.hide_previous} must return\n" # noqa: E501
"a bpy.types.Object or a bpy.types.Collection."
)
if previous is not None:
previous.hide_render = True
previous.hide_viewport = True
bobj_or_collection.hide_render = False
bobj_or_collection.hide_viewport = False
previous = bobj_or_collection
return bobj_or_collection
return f
[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 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:
try:
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)
except Exception as e:
traceback.print_exception(e)
# If we didn't exit, then Blender would print the traceback to
# stderr, but it wouldn't actually crash. This can cause the user
# to miss errors and has in fact happened multiple times in CI.
exit(1)
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.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)