Source code for ddg.blender.props

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)