From 44d61b5639e09baa5db90b1c7491deb3aa2f3f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sofus=20Albert=20H=C3=B8gsbro=20Rose?= Date: Tue, 23 Apr 2024 07:55:54 +0200 Subject: [PATCH] feat: Safe, practical BLField. BLField has gotten a huge facelift, to make it practical to wrangle properties without the sharp edges. - All the "special" UI-exposed property types can now be directly constructed in a BLField marked with 'prop_ui'. - The most appropriate internal representation will be chosen to represent the attribute based on its type annotation, including sized vector-like `bool`, `int`, `float` for `tuple[...]`. - Static EnumProperties can now be derived from a special `StrEnum`, to which a `to_name` and `to_icon` method is attached. - Dynamic `EnumProperty` can now be used safely, with builtin workarounds to the real-world reference-loss-crash (realized in the Tidy3D Cloud Task node) and jankiness like empty enum. - The update method is now fully managed, negating all bugs related to improper update callback naming. - Python-side getter caching is preserved for ui-exposed properties, with the help of node/socket base class support for passing a `Signal.InvalidateCache` to BLFields that are altered in the UI. The cost to all this niceness is rather low, and arguably, positive: - Dynamic Enum/String searchers no longer "magically" invoke all the time, since the values seen by Blender are cached by the BLField. - To regenerate the searcher output, an `@on_value_changed` should be made by the user to pass `Signal.ResetEnumItems` or `Signal.ResetStrSearch` to the `BLField`. - Since searching is no longer eager, there is no danger of out-of-reference strings (which crash Blender from EnumProperty), but also a greatly reduced performance problems associated with the hot-loop regeneration of EnumProperty strings. - The base classes are now involved with BLField invalidation, to ensure that the getter caches are cleared up when the UI changes. For the price of that small indirection (done cheaply with set lookup), all attribute lookups are generally done in a single lookup, completely avoiding Blender until needed. - This does represent another increase in confidence wrt. the event system's integrity, but so far, that has been a very productive direction. **NOTE**: The entire feature set of BLField is not well tested, and will likely need adjustments as the codebase is converted to use them. --- TODO.md | 7 +- pyproject.toml | 1 + src/blender_maxwell/contracts/__init__.py | 8 + src/blender_maxwell/contracts/bl.py | 54 ++- .../maxwell_sim_nodes/contracts/__init__.py | 8 + .../nodes/analysis/math/map_math.py | 34 +- .../maxwell_sim_nodes/nodes/base.py | 19 + .../maxwell_sim_nodes/sockets/base.py | 21 +- src/blender_maxwell/utils/bl_cache.py | 389 ++++++++++++++++-- 9 files changed, 483 insertions(+), 58 deletions(-) diff --git a/TODO.md b/TODO.md index 0f7e058..9bcf0a3 100644 --- a/TODO.md +++ b/TODO.md @@ -497,6 +497,7 @@ Reported: Unreported: - The `__mp_main__` bug. - Animated properties within custom node trees don't update with the frame. See: +- Can't update `items` using `id_propertie_ui` of `EnumProperty` ## Tidy3D bugs Unreported: @@ -532,10 +533,10 @@ We need Python properties to work together with Blender properties. ### Type Support We need support for arbitrary objects, but still backed by the persistance semantics of native Blender properties. -- [ ] Add logic that matches appropriate types to native IntProperty, FloatProperty, IntVectorProperty, FloatVectorProperty. +- [x] Add logic that matches appropriate types to native IntProperty, FloatProperty, IntVectorProperty, FloatVectorProperty. - We want absolute minimal overhead for types that actually already do work in Blender. - **REMEMBER8* they can do matrices too! https://developer.blender.org/docs/release_notes/3.0/python_api/#other-additions -- [ ] Add logic that matches any bpy.types.ID subclass to a PointerProperty. +- [x] Add logic that matches any bpy.types.ID subclass to a PointerProperty. - This is important for certain kinds of properties ex. "select a Blender object". - [ ] Implement Enum property, (also see ) - Use this to bridge the enum UI to actual StrEnum objects. @@ -545,7 +546,7 @@ We need support for arbitrary objects, but still backed by the persistance seman - [ ] `description`: Use the docstring parser to extract the first description sentence of the attribute name from the subclass docstring, so we are both encouraged to document our nodes/sockets, and so we're not documenting twice. ### Niceness -- [ ] Rename the internal property to 'blfield__'. +- [x] Rename the internal property to 'blfield__'. - [ ] Add a method that extracts the internal property name, for places where we need the Blender property name. - **Key use case**: `draw.prop(self, self.field_name._bl_prop_name)`, which is also nice b/c no implicit string-based reference. - The work done above with types makes this as fast and useful as internal props. Just make sure we validate that the type can be usefully accessed like this. diff --git a/pyproject.toml b/pyproject.toml index c5568c3..11c54f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,7 @@ ignore = [ "E701", # class foo(Parent): pass or if simple: return are perfectly elegant "ERA001", # 'Commented-out code' seems to be just about anything to ruff "F722", # jaxtyping uses type annotations that ruff sees as "syntax error" + "N806", # Sometimes we like using types w/uppercase in functions, sue me # Line Length - Controversy Incoming ## Hot Take: Let the Formatter Worry about Line Length diff --git a/src/blender_maxwell/contracts/__init__.py b/src/blender_maxwell/contracts/__init__.py index 9322849..57a09a3 100644 --- a/src/blender_maxwell/contracts/__init__.py +++ b/src/blender_maxwell/contracts/__init__.py @@ -2,13 +2,17 @@ from . import addon from .bl import ( BLClass, BLColorRGBA, + BLEnumElement, BLEnumID, + BLIcon, BLIconSet, + BLIDStruct, BLImportMethod, BLKeymapItem, BLModifierType, BLNodeTreeInterfaceID, BLOperatorStatus, + BLPropFlag, BLRegionType, BLSpaceType, KeymapItemDef, @@ -27,13 +31,17 @@ __all__ = [ 'addon', 'BLClass', 'BLColorRGBA', + 'BLEnumElement', 'BLEnumID', + 'BLIcon', 'BLIconSet', + 'BLIDStruct', 'BLImportMethod', 'BLKeymapItem', 'BLModifierType', 'BLNodeTreeInterfaceID', 'BLOperatorStatus', + 'BLPropFlag', 'BLRegionType', 'BLSpaceType', 'KeymapItemDef', diff --git a/src/blender_maxwell/contracts/bl.py b/src/blender_maxwell/contracts/bl.py index f7ed8d1..d38bca4 100644 --- a/src/blender_maxwell/contracts/bl.py +++ b/src/blender_maxwell/contracts/bl.py @@ -15,10 +15,12 @@ BLImportMethod: typ.TypeAlias = typ.Literal['append', 'link'] BLModifierType: typ.TypeAlias = typ.Literal['NODES', 'ARRAY'] BLNodeTreeInterfaceID: typ.TypeAlias = str -BLIconSet: frozenset[str] = frozenset( +BLIcon: typ.TypeAlias = str +BLIconSet: frozenset[BLIcon] = frozenset( bpy.types.UILayout.bl_rna.functions['prop'].parameters['icon'].enum_items.keys() ) +BLEnumElement = tuple[BLEnumID, str, str, BLIcon, int] #################### # - Blender Structs @@ -34,7 +36,57 @@ BLClass: typ.TypeAlias = ( | bpy.types.AssetShelf | bpy.types.FileHandler ) +BLIDStruct: typ.TypeAlias = ( + bpy.types.Action, + bpy.types.Armature, + bpy.types.Brush, + bpy.types.CacheFile, + bpy.types.Camera, + bpy.types.Collection, + bpy.types.Curve, + bpy.types.Curves, + bpy.types.FreestyleLineStyle, + bpy.types.GreasePencil, + bpy.types.Image, + bpy.types.Key, + bpy.types.Lattice, + bpy.types.Library, + bpy.types.Light, + bpy.types.LightProbe, + bpy.types.Mask, + bpy.types.Material, + bpy.types.Mesh, + bpy.types.MetaBall, + bpy.types.MovieClip, + bpy.types.NodeTree, + bpy.types.Object, + bpy.types.PaintCurve, + bpy.types.Palette, + bpy.types.ParticleSettings, + bpy.types.PointCloud, + bpy.types.Scene, + bpy.types.Screen, + bpy.types.Sound, + bpy.types.Speaker, + bpy.types.Text, + bpy.types.Texture, + bpy.types.VectorFont, + bpy.types.Volume, + bpy.types.WindowManager, + bpy.types.WorkSpace, + bpy.types.World, +) BLKeymapItem: typ.TypeAlias = typ.Any ## TODO: Better Type +BLPropFlag: typ.TypeAlias = typ.Literal[ + 'HIDDEN', + 'SKIP_SAVE', + 'SKIP_PRESET', + 'ANIMATABLE', + 'LIBRARY_EDITABLE', + 'PROPORTIONAL', + 'TEXTEDIT_UPDATE', + 'OUTPUT_PATH', +] BLColorRGBA = tuple[float, float, float, float] diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py index a92e4f5..84766f3 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py @@ -1,12 +1,16 @@ from blender_maxwell.contracts import ( BLClass, BLColorRGBA, + BLEnumElement, BLEnumID, + BLIcon, BLIconSet, + BLIDStruct, BLKeymapItem, BLModifierType, BLNodeTreeInterfaceID, BLOperatorStatus, + BLPropFlag, BLRegionType, BLSpaceType, KeymapItemDef, @@ -45,12 +49,16 @@ from .unit_systems import UNITS_BLENDER, UNITS_TIDY3D __all__ = [ 'BLClass', 'BLColorRGBA', + 'BLEnumElement', 'BLEnumID', + 'BLIcon', 'BLIconSet', + 'BLIDStruct', 'BLKeymapItem', 'BLModifierType', 'BLNodeTreeInterfaceID', 'BLOperatorStatus', + 'BLPropFlag', 'BLRegionType', 'BLSpaceType', 'KeymapItemDef', diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py index 345920d..731805b 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py @@ -1,3 +1,4 @@ +import enum import typing as typ import bpy @@ -5,7 +6,7 @@ import jax import jax.numpy as jnp import sympy as sp -from blender_maxwell.utils import logger +from blender_maxwell.utils import logger, bl_cache from .... import contracts as ct from .... import sockets @@ -17,6 +18,12 @@ X_COMPLEX = sp.Symbol('x', complex=True) class MapMathNode(base.MaxwellSimNode): + """Applies a function by-structure to the data. + + Attributes: + operation: Operation to apply to the input. + """ + node_type = ct.NodeType.MapMath bl_label = 'Map Math' @@ -41,14 +48,11 @@ class MapMathNode(base.MaxwellSimNode): #################### # - Properties #################### - operation: bpy.props.EnumProperty( - name='Op', - description='Operation to apply to the input', - items=lambda self, _: self.search_operations(), - update=lambda self, context: self.on_prop_changed('operation', context), + operation: enum.Enum = bl_cache.BLField( + prop_ui=True, enum_cb=lambda self, _: self.search_operations() ) - def search_operations(self) -> list[tuple[str, str, str]]: + def search_operations(self) -> list[ct.BLEnumElement]: items = [] if self.active_socket_set == 'By Element': items += [ @@ -92,14 +96,20 @@ class MapMathNode(base.MaxwellSimNode): ] elif self.active_socket_set == 'Expr': items += [('EXPR_EL', 'By Element', 'Expression-defined (by el)')] - else: - msg = f'Invalid socket set {self.active_socket_set}' - raise RuntimeError(msg) - return items + return [(*item, '', i) for i, item in enumerate(items)] def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout) -> None: - layout.prop(self, 'operation', text='') + layout.prop(self, self.blfields['operation'], text='') + + #################### + # - Events + #################### + @events.on_value_changed( + prop_name='active_socket_set', + ) + def on_operation_changed(self): + self.operation = bl_cache.Signal.ResetEnumItems #################### # - Compute: LazyValueFunc / Array diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py index 2438a21..d52113a 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py @@ -72,6 +72,10 @@ class MaxwellSimNode(bpy.types.Node): def reset_instance_id(self) -> None: self.instance_id = str(uuid.uuid4()) + # BLFields + blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({}) + ui_blfields: typ.ClassVar[set[str]] = frozenset() + #################### # - Class Methods #################### @@ -89,6 +93,15 @@ class MaxwellSimNode(bpy.types.Node): msg = f'Node class {cls} does not define mandatory attribute "{cls_attr}".' raise ValueError(msg) + @classmethod + def declare_blfield( + cls, attr_name: str, bl_attr_name: str, prop_ui: bool = False + ) -> None: + cls.blfields = cls.blfields | {attr_name: bl_attr_name} + + if prop_ui: + cls.ui_blfields = cls.ui_blfields | {attr_name} + @classmethod def set_prop( cls, @@ -855,6 +868,12 @@ class MaxwellSimNode(bpy.types.Node): prop_name: The name of the property that changed. """ if hasattr(self, prop_name): + # Invalidate UI BLField Caches + log.critical((prop_name, self.ui_blfields)) + if prop_name in self.ui_blfields: + setattr(self, prop_name, bl_cache.Signal.InvalidateCache) + + # Trigger Event self.trigger_event(ct.FlowEvent.DataChanged, prop_name=prop_name) else: msg = f'Property {prop_name} not defined on node {self}' diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py index 04c2370..b58e66b 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py @@ -2,13 +2,14 @@ import abc import functools import typing as typ import uuid +from types import MappingProxyType import bpy import pydantic as pyd import sympy as sp +from blender_maxwell.utils import bl_cache, logger, serialize from blender_maxwell.utils import extra_sympy_units as spux -from blender_maxwell.utils import logger, serialize from .. import contracts as ct @@ -133,6 +134,10 @@ class MaxwellSimSocket(bpy.types.NodeSocket): # Computed bl_idname: str + # BLFields + blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({}) + ui_blfields: typ.ClassVar[set[str]] = frozenset() + #################### # - Initialization #################### @@ -140,6 +145,15 @@ class MaxwellSimSocket(bpy.types.NodeSocket): def reset_instance_id(self) -> None: self.instance_id = str(uuid.uuid4()) + @classmethod + def declare_blfield( + cls, attr_name: str, bl_attr_name: str, prop_ui: bool = False + ) -> None: + cls.blfields = cls.blfields | {attr_name: bl_attr_name} + + if prop_ui: + cls.ui_blfields = cls.ui_blfields | {attr_name} + @classmethod def set_prop( cls, @@ -323,6 +337,11 @@ class MaxwellSimSocket(bpy.types.NodeSocket): # Valid Properties elif hasattr(self, prop_name): + # Invalidate UI BLField Caches + if prop_name in self.ui_blfields: + setattr(self, prop_name, bl_cache.Signal.InvalidateCache) + + # Trigger Event self.trigger_event(ct.FlowEvent.DataChanged) # Undefined Properties diff --git a/src/blender_maxwell/utils/bl_cache.py b/src/blender_maxwell/utils/bl_cache.py index d9dbee8..42673b0 100644 --- a/src/blender_maxwell/utils/bl_cache.py +++ b/src/blender_maxwell/utils/bl_cache.py @@ -7,9 +7,11 @@ import functools import inspect import typing as typ import uuid +from pathlib import Path import bpy +from blender_maxwell import contracts as ct from blender_maxwell.utils import logger, serialize log = logger.get(__name__) @@ -32,7 +34,9 @@ class Signal(enum.StrEnum): **Do not** persist this enum; the values will change whenever `bl_cache` is (re)loaded. """ - InvalidateCache: str = str(uuid.uuid4()) #'1569c45a-7cf3-4307-beab-5729c2f8fa4b' + InvalidateCache: str = str(uuid.uuid4()) + ResetEnumItems: str = str(uuid.uuid4()) + ResetStrSearch: str = str(uuid.uuid4()) class BLInstance(typ.Protocol): @@ -46,6 +50,11 @@ class BLInstance(typ.Protocol): def reset_instance_id(self) -> None: ... + @classmethod + def declare_blfield( + cls, attr_name: str, bl_attr_name: str, prop_ui: bool = False + ) -> None: ... + @classmethod def set_prop( cls, @@ -57,6 +66,25 @@ class BLInstance(typ.Protocol): ) -> None: ... +class BLEnumStrEnum(typ.Protocol): + @staticmethod + def to_name(value: typ.Self) -> str: ... + + @staticmethod + def to_icon(value: typ.Self) -> ct.BLIcon: ... + + +StringPropSubType: typ.TypeAlias = typ.Literal[ + 'FILE_PATH', 'DIR_PATH', 'FILE_NAME', 'BYTE_STRING', 'PASSWORD', 'NONE' +] + +StrMethod: typ.TypeAlias = typ.Callable[ + [BLInstance, bpy.types.Context, str], list[tuple[str, str]] +] +EnumMethod: typ.TypeAlias = typ.Callable[ + [BLInstance, bpy.types.Context], list[ct.BLEnumElement] +] + PropGetMethod: typ.TypeAlias = typ.Callable[ [BLInstance], serialize.NaivelyEncodableType ] @@ -327,7 +355,7 @@ class CachedBLProperty: ) return value - def __set__(self, bl_instance: BLInstance, value: typ.Any) -> None: + def __set__(self, bl_instance: BLInstance | None, value: typ.Any) -> None: """Runs the user-provided setter, after invalidating the caches. Notes: @@ -416,7 +444,6 @@ class CachedBLProperty: setattr(bl_instance, self._bl_prop_name, '') -## TODO: How do we invalidate the data that the computed cached property depends on? #################### # - Property Decorators #################### @@ -459,25 +486,120 @@ class BLField: """A descriptor that allows persisting arbitrary types in Blender objects, with cached reads.""" def __init__( - self, default_value: typ.Any, triggers_prop_update: bool = True + self, + default_value: typ.Any = None, + use_prop_update: bool = True, + ## Static + prop_ui: bool = False, + prop_flags: set[ct.BLPropFlag] | None = None, + abs_min: int | float | None = None, + abs_max: int | float | None = None, + soft_min: int | float | None = None, + soft_max: int | float | None = None, + float_step: int | None = None, + float_prec: int | None = None, + str_secret: bool | None = None, + path_type: typ.Literal['dir', 'file'] | None = None, + ## Static / Dynamic + enum_many: bool | None = None, + ## Dynamic + str_cb: StrMethod | None = None, + enum_cb: EnumMethod | None = None, ) -> typ.Self: """Initializes and sets the attribute to a given default value. + The attribute **must** declare a type annotation, and it **must** match the type of `default_value`. + Parameters: default_value: The default value to use if the value is read before it's set. - triggers_prop_update: Whether to run `bl_instance.on_prop_changed(attr_name)` whenever value is set. + use_prop_update: Configures the BLField to run `bl_instance.on_prop_changed(attr_name)` whenever value is set. + This is done by setting the `update` method. + enum_cb: Method used to generate new enum elements whenever `Signal.ResetEnum` is presented. """ log.debug( - 'Initializing BLField (default_value=%s, triggers_prop_update=%s)', + 'Initializing BLField (default_value=%s, use_prop_update=%s)', str(default_value), - str(triggers_prop_update), + str(use_prop_update), ) self._default_value: typ.Any = default_value - self._triggers_prop_update: bool = triggers_prop_update + self._use_prop_update: bool = use_prop_update + + ## Static + self._prop_ui = prop_ui + self._prop_flags = prop_flags + self._min = abs_min + self._max = abs_max + self._soft_min = soft_min + self._soft_max = soft_max + self._float_step = float_step + self._float_prec = float_prec + self._str_secret = str_secret + self._path_type = path_type + + ## Static / Dynamic + self._enum_many = enum_many + + ## Dynamic + self._set_ser_default = False + self._str_cb = str_cb + self._enum_cb = enum_cb + + self._str_cb_cache = {} + self._enum_cb_cache = {} + + #################### + # - Safe Callbacks + #################### + def _safe_str_cb( + self, _self: BLInstance, context: bpy.types.Context, edit_text: str + ): + """Wrapper around StringProperty.search which **guarantees** that returned strings will not be garbage collected. + + Regenerate by passing `Signal.ResetStrSearch`. + """ + if self._str_cb_cache.get(_self.instance_id) is None: + self._str_cb_cache[_self.instance_id] = self._str_cb( + _self, context, edit_text + ) + + return self._str_cb_cache[_self.instance_id] + + def _safe_enum_cb(self, _self: BLInstance, context: bpy.types.Context): + """Wrapper around EnumProperty.items callback, which **guarantees** that returned strings will not be garbage collected. + + The mechanism is simple: The user-generated callback is run once, then cached in the descriptor instance for subsequent use. + This guarantees that the user won't crash Blender by returning dynamically generated strings in the user-provided callback. + + The cost, however, is that user-provided callback won't run eagerly anymore. + Thus, whenever the user wants the items in the enum to update, they must manually set the descriptor attribute to the value `Signal.ResetEnumItems`. + """ + if self._enum_cb_cache.get(_self.instance_id) is None: + # Retrieve Dynamic Enum Items + enum_items = self._enum_cb(_self, context) + + # Ensure len(enum_items) >= 1 + ## There must always be one element to prevent invalid usage. + if len(enum_items) == 0: + self._enum_cb_cache[_self.instance_id] = [ + ( + 'NONE', + 'None', + 'No items...', + '', + 0 if not self._enum_many else 2**0, + ) + ] + else: + self._enum_cb_cache[_self.instance_id] = enum_items + + return self._enum_cb_cache[_self.instance_id] def __set_name__(self, owner: type[BLInstance], name: str) -> None: - """Sets up getters/setters for attribute access, and sets up a `CachedBLProperty` to internally utilize them. + """Sets up the descriptor on the class level, preparing it for per-instance use. + + - The type annotation of the attribute is noted, as it might later guide (de)serialization of the field. + - An appropriate `bpy.props.Property` is chosen for the type annotaiton, with a default-case fallback of `bpy.props.StringProperty` containing serialized data. Our getter/setter essentially reads/writes to a `bpy.props.StringProperty`, with @@ -487,49 +609,205 @@ class BLField: Notes: Run by Python when setting an instance of this class to an attribute. + For StringProperty subtypes, see: + Parameters: owner: The class that contains an attribute assigned to an instance of this descriptor. name: The name of the attribute that an instance of descriptor was assigned to. """ - # Compute Name and Type of Property - ## Also compute the internal + # Compute Name of Property + ## Internal name uses 'blfield__' to avoid unfortunate overlaps. attr_name = name - bl_attr_name = f'blattr__{name}' - if (AttrType := inspect.get_annotations(owner).get(name)) is None: # noqa: N806 - msg = f'BLField "{self.prop_name}" must define a type annotation, but doesn\'t.' + bl_attr_name = f'blfield__{name}' + + owner.declare_blfield(attr_name, bl_attr_name, prop_ui=self._prop_ui) + + # Compute Type of Property + ## The type annotation of the BLField guides (de)serialization. + if (AttrType := inspect.get_annotations(owner).get(name)) is None: + msg = f'BLField "{self.prop_name}" must define a type annotation, but doesn\'t' raise TypeError(msg) # Define Blender Property (w/Update Sync) - encoded_default_value = serialize.encode(self._default_value).decode('utf-8') - log.debug( - '%s set to StringProperty w/default "%s" and no_update="%s"', - bl_attr_name, - encoded_default_value, - str(not self._triggers_prop_update), - ) + default_value = None + no_default_value = False + prop_is_serialized = False + kwargs_prop = {} + + ## Reusable Snippets + def _add_min_max_kwargs(): + kwargs_prop |= {'min': self._abs_min} if self._abs_min is not None else {} + kwargs_prop |= {'max': self._abs_max} if self._abs_max is not None else {} + kwargs_prop |= ( + {'soft_min': self._soft_min} if self._soft_min is not None else {} + ) + kwargs_prop |= ( + {'soft_max': self._soft_max} if self._soft_max is not None else {} + ) + + def _add_float_kwargs(): + kwargs_prop |= ( + {'step': self._float_step} if self._float_step is not None else {} + ) + kwargs_prop |= ( + {'precision': self._float_prec} if self._float_prec is not None else {} + ) + + ## Property Flags + kwargs_prop |= { + 'options': self._prop_flags if self._prop_flags is not None else set() + } + + ## Scalar Bool + if AttrType is bool: + default_value = self._default_value + BLProp = bpy.props.BoolProperty + + ## Scalar Int + elif AttrType is int: + default_value = self._default_value + BLProp = bpy.props.IntProperty + _add_min_max_kwargs() + + ## Scalar Float + elif AttrType is float: + default_value = self._default_value + BLProp = bpy.props.FloatProperty + _add_min_max_kwargs() + _add_float_kwargs() + + ## Vector Bool + elif typ.get_origin(AttrType) is tuple and all( + T is bool for T in typ.get_args(AttrType) + ): + default_value = self._default_value + BLProp = bpy.props.BoolVectorProperty + kwargs_prop |= {'size': len(typ.get_args(AttrType))} + + ## Vector Int + elif typ.get_origin(AttrType) is tuple and all( + T is int for T in typ.get_args(AttrType) + ): + default_value = self._default_value + BLProp = bpy.props.IntVectorProperty + _add_min_max_kwargs() + kwargs_prop |= {'size': len(typ.get_args(AttrType))} + + ## Vector Float + elif typ.get_origin(AttrType) is tuple and all( + T is float for T in typ.get_args(AttrType) + ): + default_value = self._default_value + BLProp = bpy.props.FloatVectorProperty + _add_min_max_kwargs() + _add_float_kwargs() + kwargs_prop |= {'size': len(typ.get_args(AttrType))} + + ## Generic String + elif AttrType is str: + default_value = self._default_value + BLProp = bpy.props.StringProperty + if self._str_secret: + kwargs_prop |= {'subtype': 'PASSWORD'} + kwargs_prop['options'].add('SKIP_SAVE') + + if self._str_cb is not None: + kwargs_prop |= lambda _self, context, edit_text: self._safe_str_cb( + _self, context, edit_text + ) + + ## Path + elif AttrType is Path: + if self._path_type is None: + msg = 'Path BLField must define "path_type"' + raise ValueError(msg) + + default_value = self._default_value + BLProp = bpy.props.StringProperty + kwargs_prop |= { + 'subtype': 'FILE_PATH' if self._path_type == 'file' else 'DIR_PATH' + } + + ## StrEnum + elif issubclass(AttrType, enum.StrEnum): + default_value = self._default_value + BLProp = bpy.props.EnumProperty + kwargs_prop |= { + 'items': [ + ( + str(value), + AttrType.to_name(value), + AttrType.to_name(value), ## TODO: From AttrType.__doc__ + AttrType.to_icon(), + i if not self._enum_many else 2**i, + ) + for i, value in enumerate(list(AttrType)) + ] + } + if self._enum_many: + kwargs_prop['options'].add('ENUM_FLAG') + + ## Dynamic Enum + elif AttrType is enum.Enum and self._enum_cb is not None: + if self._default_value is not None: + msg = 'When using dynamic enum, default value must be None' + raise ValueError(msg) + no_default_value = True + + BLProp = bpy.props.EnumProperty + kwargs_prop |= { + 'items': lambda _self, context: self._safe_enum_cb(_self, context), + } + if self._enum_many: + kwargs_prop['options'].add('ENUM_FLAG') + + ## BL Reference + elif AttrType in typ.get_args(ct.BLIDStruct): + default_value = self._default_value + BLProp = bpy.props.PointerProperty + + ## Serializable Object + else: + default_value = serialize.encode(self._default_value).decode('utf-8') + BLProp = bpy.props.StringProperty + prop_is_serialized = True + + # Set Default Value (probably) + if not no_default_value: + kwargs_prop |= {'default': default_value} + + # Set Blender Property on Class __annotations__ owner.set_prop( bl_attr_name, - bpy.props.StringProperty, - name=f'Encoded Attribute for {attr_name}', - default=encoded_default_value, - no_update=not self._triggers_prop_update, + BLProp, + # Update Callback Options + no_update=not self._use_prop_update, update_with_name=attr_name, - ) + # Property Options + name=('[JSON] ' if prop_is_serialized else '') + f'BLField: {attr_name}', + **kwargs_prop, + ) ## TODO: Mine description from owner class __doc__ - ## Getter: - ## 1. Initialize bpy.props.StringProperty to Default (if undefined). - ## 2. Retrieve bpy.props.StringProperty string. - ## 3. Decode using annotated type. - def getter(_self: BLInstance) -> AttrType: - return serialize.decode(AttrType, getattr(_self, bl_attr_name)) + # Define Property Getter + if prop_is_serialized: - ## Setter: - ## 1. Initialize bpy.props.StringProperty to Default (if undefined). - ## 3. Encode value (implicitly using the annotated type). - ## 2. Set bpy.props.StringProperty string. - def setter(_self: BLInstance, value: AttrType) -> None: - encoded_value = serialize.encode(value).decode('utf-8') - setattr(_self, bl_attr_name, encoded_value) + def getter(_self: BLInstance) -> AttrType: + return serialize.decode(AttrType, getattr(_self, bl_attr_name)) + else: + + def getter(_self: BLInstance) -> AttrType: + return getattr(_self, bl_attr_name) + + # Define Property Setter + if prop_is_serialized: + + def setter(_self: BLInstance, value: AttrType) -> None: + encoded_value = serialize.encode(value).decode('utf-8') + setattr(_self, bl_attr_name, encoded_value) + else: + + def setter(_self: BLInstance, value: AttrType) -> None: + setattr(_self, bl_attr_name, value) # Initialize CachedBLProperty w/Getter and Setter ## This is the usual descriptor assignment procedure. @@ -542,5 +820,34 @@ class BLField: ) -> typ.Any: return self._cached_bl_property.__get__(bl_instance, owner) - def __set__(self, bl_instance: BLInstance, value: typ.Any) -> None: - self._cached_bl_property.__set__(bl_instance, value) + def __set__(self, bl_instance: BLInstance | None, value: typ.Any) -> None: + if value == Signal.ResetEnumItems: + # Set Enum to First Item + ## Prevents the seemingly "missing" enum element bug. + ## -> Caused by the old int still trying to hang on after. + ## -> We can mitigate this by preemptively setting the enum. + ## -> Infinite recursion if we don't check current value. + ## -> May cause a hiccup (chains will trigger twice) + ## To work, there **must** be a guaranteed-available string at 0,0. + first_old_value = self._safe_enum_cb(bl_instance, None)[0][0] + current_value = self._cached_bl_property.__get__( + bl_instance, bl_instance.__class__ + ) + if current_value != first_old_value: + self._cached_bl_property.__set__(bl_instance, first_old_value) + + # Pop the Cached Enum Items + ## The next time Blender asks for the enum items, it'll update. + self._enum_cb_cache.pop(bl_instance.instance_id, None) + + # Invalidate the Getter Cache + ## The next time the user runs __get__, they'll get the new value. + self._cached_bl_property.__set__(bl_instance, Signal.InvalidateCache) + + elif value == Signal.ResetStrSearch: + # Pop the Cached String Search Items + ## The next time Blender does a str search, it'll update. + self._str_cb_cache.pop(bl_instance.instance_id, None) + + else: + self._cached_bl_property.__set__(bl_instance, value)