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.
main
Sofus Albert Høgsbro Rose 2024-04-23 07:55:54 +02:00
parent b4d6eae036
commit 44d61b5639
Signed by: so-rose
GPG Key ID: AD901CB0F3701434
9 changed files with 483 additions and 58 deletions

View File

@ -497,6 +497,7 @@ Reported:
Unreported: Unreported:
- The `__mp_main__` bug. - The `__mp_main__` bug.
- Animated properties within custom node trees don't update with the frame. See: <https://projects.blender.org/blender/blender/issues/66392> - Animated properties within custom node trees don't update with the frame. See: <https://projects.blender.org/blender/blender/issues/66392>
- Can't update `items` using `id_propertie_ui` of `EnumProperty`
## Tidy3D bugs ## Tidy3D bugs
Unreported: Unreported:
@ -532,10 +533,10 @@ We need Python properties to work together with Blender properties.
### Type Support ### Type Support
We need support for arbitrary objects, but still backed by the persistance semantics of native Blender properties. 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. - 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 - **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". - This is important for certain kinds of properties ex. "select a Blender object".
- [ ] Implement Enum property, (also see <https://developer.blender.org/docs/release_notes/4.1/python_api/#enum-id-properties>) - [ ] Implement Enum property, (also see <https://developer.blender.org/docs/release_notes/4.1/python_api/#enum-id-properties>)
- Use this to bridge the enum UI to actual StrEnum objects. - 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. - [ ] `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 ### 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. - [ ] 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. - **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. - 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.

View File

@ -108,6 +108,7 @@ ignore = [
"E701", # class foo(Parent): pass or if simple: return are perfectly elegant "E701", # class foo(Parent): pass or if simple: return are perfectly elegant
"ERA001", # 'Commented-out code' seems to be just about anything to ruff "ERA001", # 'Commented-out code' seems to be just about anything to ruff
"F722", # jaxtyping uses type annotations that ruff sees as "syntax error" "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 # Line Length - Controversy Incoming
## Hot Take: Let the Formatter Worry about Line Length ## Hot Take: Let the Formatter Worry about Line Length

View File

@ -2,13 +2,17 @@ from . import addon
from .bl import ( from .bl import (
BLClass, BLClass,
BLColorRGBA, BLColorRGBA,
BLEnumElement,
BLEnumID, BLEnumID,
BLIcon,
BLIconSet, BLIconSet,
BLIDStruct,
BLImportMethod, BLImportMethod,
BLKeymapItem, BLKeymapItem,
BLModifierType, BLModifierType,
BLNodeTreeInterfaceID, BLNodeTreeInterfaceID,
BLOperatorStatus, BLOperatorStatus,
BLPropFlag,
BLRegionType, BLRegionType,
BLSpaceType, BLSpaceType,
KeymapItemDef, KeymapItemDef,
@ -27,13 +31,17 @@ __all__ = [
'addon', 'addon',
'BLClass', 'BLClass',
'BLColorRGBA', 'BLColorRGBA',
'BLEnumElement',
'BLEnumID', 'BLEnumID',
'BLIcon',
'BLIconSet', 'BLIconSet',
'BLIDStruct',
'BLImportMethod', 'BLImportMethod',
'BLKeymapItem', 'BLKeymapItem',
'BLModifierType', 'BLModifierType',
'BLNodeTreeInterfaceID', 'BLNodeTreeInterfaceID',
'BLOperatorStatus', 'BLOperatorStatus',
'BLPropFlag',
'BLRegionType', 'BLRegionType',
'BLSpaceType', 'BLSpaceType',
'KeymapItemDef', 'KeymapItemDef',

View File

@ -15,10 +15,12 @@ BLImportMethod: typ.TypeAlias = typ.Literal['append', 'link']
BLModifierType: typ.TypeAlias = typ.Literal['NODES', 'ARRAY'] BLModifierType: typ.TypeAlias = typ.Literal['NODES', 'ARRAY']
BLNodeTreeInterfaceID: typ.TypeAlias = str 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() bpy.types.UILayout.bl_rna.functions['prop'].parameters['icon'].enum_items.keys()
) )
BLEnumElement = tuple[BLEnumID, str, str, BLIcon, int]
#################### ####################
# - Blender Structs # - Blender Structs
@ -34,7 +36,57 @@ BLClass: typ.TypeAlias = (
| bpy.types.AssetShelf | bpy.types.AssetShelf
| bpy.types.FileHandler | 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 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] BLColorRGBA = tuple[float, float, float, float]

View File

@ -1,12 +1,16 @@
from blender_maxwell.contracts import ( from blender_maxwell.contracts import (
BLClass, BLClass,
BLColorRGBA, BLColorRGBA,
BLEnumElement,
BLEnumID, BLEnumID,
BLIcon,
BLIconSet, BLIconSet,
BLIDStruct,
BLKeymapItem, BLKeymapItem,
BLModifierType, BLModifierType,
BLNodeTreeInterfaceID, BLNodeTreeInterfaceID,
BLOperatorStatus, BLOperatorStatus,
BLPropFlag,
BLRegionType, BLRegionType,
BLSpaceType, BLSpaceType,
KeymapItemDef, KeymapItemDef,
@ -45,12 +49,16 @@ from .unit_systems import UNITS_BLENDER, UNITS_TIDY3D
__all__ = [ __all__ = [
'BLClass', 'BLClass',
'BLColorRGBA', 'BLColorRGBA',
'BLEnumElement',
'BLEnumID', 'BLEnumID',
'BLIcon',
'BLIconSet', 'BLIconSet',
'BLIDStruct',
'BLKeymapItem', 'BLKeymapItem',
'BLModifierType', 'BLModifierType',
'BLNodeTreeInterfaceID', 'BLNodeTreeInterfaceID',
'BLOperatorStatus', 'BLOperatorStatus',
'BLPropFlag',
'BLRegionType', 'BLRegionType',
'BLSpaceType', 'BLSpaceType',
'KeymapItemDef', 'KeymapItemDef',

View File

@ -1,3 +1,4 @@
import enum
import typing as typ import typing as typ
import bpy import bpy
@ -5,7 +6,7 @@ import jax
import jax.numpy as jnp import jax.numpy as jnp
import sympy as sp 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 contracts as ct
from .... import sockets from .... import sockets
@ -17,6 +18,12 @@ X_COMPLEX = sp.Symbol('x', complex=True)
class MapMathNode(base.MaxwellSimNode): 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 node_type = ct.NodeType.MapMath
bl_label = 'Map Math' bl_label = 'Map Math'
@ -41,14 +48,11 @@ class MapMathNode(base.MaxwellSimNode):
#################### ####################
# - Properties # - Properties
#################### ####################
operation: bpy.props.EnumProperty( operation: enum.Enum = bl_cache.BLField(
name='Op', prop_ui=True, enum_cb=lambda self, _: self.search_operations()
description='Operation to apply to the input',
items=lambda self, _: self.search_operations(),
update=lambda self, context: self.on_prop_changed('operation', context),
) )
def search_operations(self) -> list[tuple[str, str, str]]: def search_operations(self) -> list[ct.BLEnumElement]:
items = [] items = []
if self.active_socket_set == 'By Element': if self.active_socket_set == 'By Element':
items += [ items += [
@ -92,14 +96,20 @@ class MapMathNode(base.MaxwellSimNode):
] ]
elif self.active_socket_set == 'Expr': elif self.active_socket_set == 'Expr':
items += [('EXPR_EL', 'By Element', 'Expression-defined (by el)')] 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: 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 # - Compute: LazyValueFunc / Array

View File

@ -72,6 +72,10 @@ class MaxwellSimNode(bpy.types.Node):
def reset_instance_id(self) -> None: def reset_instance_id(self) -> None:
self.instance_id = str(uuid.uuid4()) self.instance_id = str(uuid.uuid4())
# BLFields
blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({})
ui_blfields: typ.ClassVar[set[str]] = frozenset()
#################### ####################
# - Class Methods # - Class Methods
#################### ####################
@ -89,6 +93,15 @@ class MaxwellSimNode(bpy.types.Node):
msg = f'Node class {cls} does not define mandatory attribute "{cls_attr}".' msg = f'Node class {cls} does not define mandatory attribute "{cls_attr}".'
raise ValueError(msg) 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 @classmethod
def set_prop( def set_prop(
cls, cls,
@ -855,6 +868,12 @@ class MaxwellSimNode(bpy.types.Node):
prop_name: The name of the property that changed. prop_name: The name of the property that changed.
""" """
if hasattr(self, prop_name): 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) self.trigger_event(ct.FlowEvent.DataChanged, prop_name=prop_name)
else: else:
msg = f'Property {prop_name} not defined on node {self}' msg = f'Property {prop_name} not defined on node {self}'

View File

@ -2,13 +2,14 @@ import abc
import functools import functools
import typing as typ import typing as typ
import uuid import uuid
from types import MappingProxyType
import bpy import bpy
import pydantic as pyd import pydantic as pyd
import sympy as sp 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 extra_sympy_units as spux
from blender_maxwell.utils import logger, serialize
from .. import contracts as ct from .. import contracts as ct
@ -133,6 +134,10 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
# Computed # Computed
bl_idname: str bl_idname: str
# BLFields
blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({})
ui_blfields: typ.ClassVar[set[str]] = frozenset()
#################### ####################
# - Initialization # - Initialization
#################### ####################
@ -140,6 +145,15 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
def reset_instance_id(self) -> None: def reset_instance_id(self) -> None:
self.instance_id = str(uuid.uuid4()) 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 @classmethod
def set_prop( def set_prop(
cls, cls,
@ -323,6 +337,11 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
# Valid Properties # Valid Properties
elif hasattr(self, prop_name): 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) self.trigger_event(ct.FlowEvent.DataChanged)
# Undefined Properties # Undefined Properties

View File

@ -7,9 +7,11 @@ import functools
import inspect import inspect
import typing as typ import typing as typ
import uuid import uuid
from pathlib import Path
import bpy import bpy
from blender_maxwell import contracts as ct
from blender_maxwell.utils import logger, serialize from blender_maxwell.utils import logger, serialize
log = logger.get(__name__) 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. **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): class BLInstance(typ.Protocol):
@ -46,6 +50,11 @@ class BLInstance(typ.Protocol):
def reset_instance_id(self) -> None: ... def reset_instance_id(self) -> None: ...
@classmethod
def declare_blfield(
cls, attr_name: str, bl_attr_name: str, prop_ui: bool = False
) -> None: ...
@classmethod @classmethod
def set_prop( def set_prop(
cls, cls,
@ -57,6 +66,25 @@ class BLInstance(typ.Protocol):
) -> None: ... ) -> 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[ PropGetMethod: typ.TypeAlias = typ.Callable[
[BLInstance], serialize.NaivelyEncodableType [BLInstance], serialize.NaivelyEncodableType
] ]
@ -327,7 +355,7 @@ class CachedBLProperty:
) )
return value 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. """Runs the user-provided setter, after invalidating the caches.
Notes: Notes:
@ -416,7 +444,6 @@ class CachedBLProperty:
setattr(bl_instance, self._bl_prop_name, '') setattr(bl_instance, self._bl_prop_name, '')
## TODO: How do we invalidate the data that the computed cached property depends on?
#################### ####################
# - Property Decorators # - Property Decorators
#################### ####################
@ -459,25 +486,120 @@ class BLField:
"""A descriptor that allows persisting arbitrary types in Blender objects, with cached reads.""" """A descriptor that allows persisting arbitrary types in Blender objects, with cached reads."""
def __init__( 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: ) -> typ.Self:
"""Initializes and sets the attribute to a given default value. """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: Parameters:
default_value: The default value to use if the value is read before it's set. 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( log.debug(
'Initializing BLField (default_value=%s, triggers_prop_update=%s)', 'Initializing BLField (default_value=%s, use_prop_update=%s)',
str(default_value), str(default_value),
str(triggers_prop_update), str(use_prop_update),
) )
self._default_value: typ.Any = default_value 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: 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 Our getter/setter essentially reads/writes to a `bpy.props.StringProperty`, with
@ -487,49 +609,205 @@ class BLField:
Notes: Notes:
Run by Python when setting an instance of this class to an attribute. Run by Python when setting an instance of this class to an attribute.
For StringProperty subtypes, see: <https://blender.stackexchange.com/questions/104875/what-do-the-different-options-for-subtype-enumerator-in-stringproperty-do>
Parameters: Parameters:
owner: The class that contains an attribute assigned to an instance of this descriptor. 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. name: The name of the attribute that an instance of descriptor was assigned to.
""" """
# Compute Name and Type of Property # Compute Name of Property
## Also compute the internal ## Internal name uses 'blfield__' to avoid unfortunate overlaps.
attr_name = name attr_name = name
bl_attr_name = f'blattr__{name}' bl_attr_name = f'blfield__{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.' 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) raise TypeError(msg)
# Define Blender Property (w/Update Sync) # Define Blender Property (w/Update Sync)
encoded_default_value = serialize.encode(self._default_value).decode('utf-8') default_value = None
log.debug( no_default_value = False
'%s set to StringProperty w/default "%s" and no_update="%s"', prop_is_serialized = False
bl_attr_name, kwargs_prop = {}
encoded_default_value,
str(not self._triggers_prop_update), ## 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( owner.set_prop(
bl_attr_name, bl_attr_name,
bpy.props.StringProperty, BLProp,
name=f'Encoded Attribute for {attr_name}', # Update Callback Options
default=encoded_default_value, no_update=not self._use_prop_update,
no_update=not self._triggers_prop_update,
update_with_name=attr_name, 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: # Define Property Getter
## 1. Initialize bpy.props.StringProperty to Default (if undefined). if prop_is_serialized:
## 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))
## Setter: def getter(_self: BLInstance) -> AttrType:
## 1. Initialize bpy.props.StringProperty to Default (if undefined). return serialize.decode(AttrType, getattr(_self, bl_attr_name))
## 3. Encode value (implicitly using the annotated type). else:
## 2. Set bpy.props.StringProperty string.
def setter(_self: BLInstance, value: AttrType) -> None: def getter(_self: BLInstance) -> AttrType:
encoded_value = serialize.encode(value).decode('utf-8') return getattr(_self, bl_attr_name)
setattr(_self, bl_attr_name, encoded_value)
# 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 # Initialize CachedBLProperty w/Getter and Setter
## This is the usual descriptor assignment procedure. ## This is the usual descriptor assignment procedure.
@ -542,5 +820,34 @@ class BLField:
) -> typ.Any: ) -> typ.Any:
return self._cached_bl_property.__get__(bl_instance, owner) return self._cached_bl_property.__get__(bl_instance, owner)
def __set__(self, bl_instance: BLInstance, value: typ.Any) -> None: def __set__(self, bl_instance: BLInstance | None, value: typ.Any) -> None:
self._cached_bl_property.__set__(bl_instance, value) 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)