oscillode/src/blender_maxwell/utils/bl_instance.py

300 lines
12 KiB
Python

# 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 <http://www.gnu.org/licenses/>.
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