Source code for ddg.visualization.blender.props

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)