# blender_maxwell # Copyright (C) 2024 blender_maxwell Project Contributors # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program 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 Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import typing as typ import uuid from types import MappingProxyType import bpy from blender_maxwell.utils import bl_cache, logger InstanceID: typ.TypeAlias = str ## Stringified UUID4 log = logger.get(__name__) class BLInstance: """An instance of a blender object, ex. nodes/sockets. Used as a common base of functionality for nodes/sockets, especially when it comes to the magic introduced by `bl_cache`. Notes: All the `@classmethod`s are designed to be invoked with `cls` as the subclass of `BLInstance`, not `BLInstance` itself. For practical reasons, introducing a metaclass here is not a good idea, and thus `abc.ABC` can't be used. To this end, only `self.on_prop_changed` needs a subclass implementation. It's a little sharp, but managable. Inheritance schemes like this are generally not enjoyable. However, the way Blender's node/socket classes are structured makes it the most practical way design for the functionality encapsulated here. Attributes: instance_id: Stringified UUID4 that uniquely identifies an instance, among all active instances on all active classes. """ #################### # - Attributes #################### instance_id: bpy.props.StringProperty(default='') blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({}) blfield_deps: typ.ClassVar[dict[str, list[str]]] = MappingProxyType({}) blfields_dynamic_enum: typ.ClassVar[set[str]] = frozenset() blfield_dynamic_enum_deps: typ.ClassVar[dict[str, list[str]]] = MappingProxyType({}) blfields_str_search: typ.ClassVar[set[str]] = frozenset() blfield_str_search_deps: typ.ClassVar[dict[str, list[str]]] = MappingProxyType({}) #################### # - Runtime Instance Management #################### def reset_instance_id(self) -> None: """Reset the Instance ID of a BLInstance. The Instance ID is used to index the instance-specific cache, since Blender doesn't always directly support keeping changing data on node/socket instances. Notes: Should be run whenever the instance is copied, so that the copy will index its own cache. The Instance ID is a `UUID4`, which is globally unique, negating the need for extraneous overlap-checks. """ self.instance_id = str(uuid.uuid4()) self.regenerate_dynamic_field_persistance() @classmethod def assert_attrs_valid(cls, mandatory_props: set[str]) -> None: """Asserts that all mandatory attributes are defined on the class. The list of mandatory objects is generally sourced from a global variable, `MANDATORY_PROPS`, which should be passed to this function while running `__init_subclass__`. Raises: ValueError: If a mandatory attribute defined in `base.MANDATORY_PROPS` is not defined on the class. """ for cls_attr in mandatory_props: if not hasattr(cls, cls_attr): msg = f'Node class {cls} does not define mandatory attribute "{cls_attr}".' raise ValueError(msg) #################### # - Field Registration #################### @classmethod def declare_blfield( cls, attr_name: str, bl_attr_name: str, use_dynamic_enum: bool = False, use_str_search: bool = False, ) -> None: """Declare the existance of a (cached) field and any properties affecting its invalidation. Primarily, the `attr_name -> bl_attr_name` map will be available via the `cls.blfields` dictionary. Thus, for use in UIs (where `bl_attr_name` must be used), one can use `cls.blfields[attr_name]`. Parameters: attr_name: The name of the attribute accessible via the instance. bl_attr_name: The name of the attribute containing the Blender property. This is used both as a persistant cache for `attr_name`, as well as (possibly) the data altered by the user from the UI. use_dynamic_enum: Will mark `attr_name` as a dynamic enum. Allows `self.regenerate_dynamic_field_persistance` to reset this property, whenever all dynamic `EnumProperty`s are reset at once. use_str_searc: The name of the attribute containing the Blender property. Allows `self.regenerate_dynamic_field_persistance` to reset this property, whenever all searched `StringProperty`s are reset at once. """ cls.blfields = cls.blfields | {attr_name: bl_attr_name} if use_dynamic_enum: cls.blfields_dynamic_enum = cls.blfields_dynamic_enum | {attr_name} if use_str_search: cls.blfields_str_search = cls.blfields_str_search | {attr_name} @classmethod def declare_blfield_dep( cls, src_prop_name: str, dst_prop_name: str, method: typ.Literal[ 'invalidate', 'reset_enum', 'reset_strsearch' ] = 'invalidate', ) -> None: """Declare that `prop_name` relies on another property. This is critical for cached, computed properties that must invalidate their cache whenever any of the data they rely on changes. In practice, a chain of invalidation emerges naturally when this is put together, managed internally for performance. Notes: If the relevant `*_deps` dictionary is not defined on `cls`, we manually create it. This shadows the relevant `BLInstance` attribute, which is an immutable `MappingProxyType` on purpose, precisely to prevent the situation of altering data that shouldn't be common to all classes inheriting from `BLInstance`. Not clean, but it works. Parameters: dep_prop_name: The property that should, whenever changed, also invalidate the cache of `prop_name`. prop_name: The property that relies on another property. """ match method: case 'invalidate': if not cls.blfield_deps: cls.blfield_deps = {} deps = cls.blfield_deps case 'reset_enum': if not cls.blfield_dynamic_enum_deps: cls.blfield_dynamic_enum_deps = {} deps = cls.blfield_dynamic_enum_deps case 'reset_strsearch': if not cls.blfield_str_search_deps: cls.blfield_str_search_deps = {} deps = cls.blfield_str_search_deps if deps.get(src_prop_name) is None: deps[src_prop_name] = [] deps[src_prop_name].append(dst_prop_name) @classmethod def set_prop( cls, bl_prop_name: str, prop: bpy.types.Property, **kwargs, ) -> None: """Adds a Blender property via `__annotations__`, so that it will be initialized on all subclasses. **All Blender properties trigger an update method** when updated from the UI, in order to invalidate the non-persistent cache of the associated `BLField`. Specifically, this behavior happens in `on_bl_prop_changed()`. However, whether anything else happens after that invalidation is entirely up to the particular `BLField`. Thus, `BLField` is put in charge of how/when updates occur. Notes: In general, Blender properties can't be set on classes directly. They must be added as type annotations, which Blender will read and understand. This is essentially a convenience method to encapsulate this unexpected behavior, as well as constrain the behavior of the `update` method somewhat. Parameters: bl_prop_name: The name of the property to set, as accessible from Blender. Generally, from code, the user would access the wrapping `BLField` instead of directly accessing the `bl_prop_name` attribute. prop: The `bpy.types.Property` to instantiate and attach.. kwargs: Constructor arguments to pass to the Blender property. There are many mostly-documented nuances with these. The methods of `bl_cache.BLPropType` are designed to provide more strict, helpful abstractions for practical use. """ cls.__annotations__[bl_prop_name] = prop( update=lambda self, context: self.on_bl_prop_changed(bl_prop_name, context), **kwargs, ) #################### # - Runtime Field Management #################### def regenerate_dynamic_field_persistance(self): """Regenerate the persisted data of all dynamic enums and str search BLFields. In practice, this sets special "signal" values: - **Dynamic Enums**: The signal value `bl_cache.Signal.ResetEnumItems` will be set, causing `BLField.__set__` to regenerate the enum items using the user-provided callback. - **Searched Strings**: The signal value `bl_cache.Signal.ResetStrSearch` will be set, causing `BLField.__set__` to regenerate the available search strings using the user-provided callback. """ # Generate Enum Items ## -> This guarantees that the items are persisted from the start. for dyn_enum_prop_name in self.blfields_dynamic_enum: setattr(self, dyn_enum_prop_name, bl_cache.Signal.ResetEnumItems) # Generate Str Search Items ## -> Match dynamic enum semantics for str_search_prop_name in self.blfields_str_search: setattr(self, str_search_prop_name, bl_cache.Signal.ResetStrSearch) def on_bl_prop_changed(self, bl_prop_name: str, _: bpy.types.Context) -> None: """Called when a property has been updated via the Blender UI. The only effect is to invalidate the non-persistent cache of the associated BLField. The BLField then decides whether to take any other action, ex. calling `self.on_prop_changed()`. """ ## TODO: What about non-Blender set properties? # Strip the Internal Prefix ## -> TODO: This is a bit of a hack. Use a contracts constant. prop_name = bl_prop_name.removeprefix('blfield__') # log.debug( # 'Callback on Property %s (stripped: %s)', # bl_prop_name, # prop_name, # ) # log.debug( # 'Dependencies (PROP: %s) (ENUM: %s) (SEAR: %s)', # self.blfield_deps, # self.blfield_dynamic_enum_deps, # self.blfield_str_search_deps, # ) # Invalidate Property Cache ## -> Only the non-persistent cache is regenerated. ## -> The BLField decides whether to trigger `on_prop_changed`. if prop_name in self.blfields: # RULE: =1 DataChanged per Dependency Chain ## -> We MUST invalidate the cache, but might not want to update. ## -> Update should only be triggered when ==0 dependents. setattr(self, prop_name, bl_cache.Signal.InvalidateCacheNoUpdate) # Invalidate Dependent Properties (incl. DynEnums and StrSearch) ## -> NOTE: Dependent props may also trigger `on_prop_changed`. ## -> Meaning, don't use extraneous dependencies (as usual). for deps, invalidate_signal in zip( [ self.blfield_deps, self.blfield_dynamic_enum_deps, self.blfield_str_search_deps, ], [ bl_cache.Signal.InvalidateCache, bl_cache.Signal.ResetEnumItems, bl_cache.Signal.ResetStrSearch, ], strict=True, ): if prop_name in deps: for dst_prop_name in deps[prop_name]: # log.debug( # 'Property %s is invalidating %s', # prop_name, # dst_prop_name, # ) setattr( self, dst_prop_name, invalidate_signal, ) # Do Update AFTER Dependencies ## -> Yes, update will run once per dependency. ## -> Don't abuse dependencies :) ## -> If no-update is important, use_prop_update is still respected. setattr(self, prop_name, bl_cache.Signal.DoUpdate) def on_prop_changed(self, prop_name: str) -> None: """Triggers changes/an event chain based on a changed property. In general, the `BLField` descriptor associated with `prop_name` decides whether this method should be called whenever `__set__` is used. An indirect consequence of this is that `self.on_bl_prop_changed`, which is _always_ triggered, may only _sometimes_ result in `on_prop_changed` being called, at the discretion of the relevant `BLField`. Notes: **Must** be overridden on all `BLInstance` subclasses. """ raise NotImplementedError