feat: big refactor to fight fundamental crashes

Some rather foundational things were fundamentally broken, especially related to the initialization procedures of fields / cached properties.

- We completely revamped `bl_cache`, fixing many to-be-discovered bugs.
- We completely streamlined `BLField` property logic into reusable
  `bl_cache.BLProp` and `bl_cache.BLPropType`.
- We implemented `BLInstance` superclass to handle ex. deterministic
  persistance of dynamic enum items, and other nuanced common
  functionality that was being duplicated raw.
- We implemented inter `cached_bl_property` / `BLField` dependency
  logic, including the ability to invalidate dynamic enums without
  @on_value_changed logic. This **greatly** simplifies a vast quantity
  of nodes that were traditionally very difficult to get working due to
  the sharp edges imposed by needing manual invalidation logic.
- We gave `ExprSocket` a significant usability upgrade, including
  thorough parsing logic in the `SocketDef`.

It's not that existing nodes are as such broken, but their existing bugs
are now going to cause problems a lot faster.
Which is a good thing.

BREAKING CHANGE: Closes #13. Closes #16. Big work on #64. Work on #37.
main
Sofus Albert Høgsbro Rose 2024-05-15 12:37:38 +02:00
parent 929fb2dae9
commit c9936b8942
Signed by: so-rose
GPG Key ID: AD901CB0F3701434
49 changed files with 3591 additions and 1953 deletions

View File

@ -36,6 +36,7 @@ from .bl import (
PresetName,
SocketName,
)
from .bl_types import BLEnumStrEnum
from .operator_types import (
OperatorType,
)
@ -64,6 +65,9 @@ __all__ = [
'ManagedObjName',
'PresetName',
'SocketName',
'BLEnumStrEnum',
'BLInstance',
'InstanceID',
'OperatorType',
'PanelType',
]

View File

@ -0,0 +1,28 @@
# 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
####################
# - Blender Enum (w/EnumProperty support)
####################
class BLEnumStrEnum(typ.Protocol):
@staticmethod
def to_name(value: typ.Self) -> str: ...
@staticmethod
def to_icon(value: typ.Self) -> str: ...

View File

@ -44,7 +44,7 @@ def socket_def_from_bl_isocket(
## -> Accounts for any combo of shape/MathType/PhysicalType.
if blsck_info.socket_type == ct.SocketType.Expr:
return sockets.ExprSocketDef(
shape=blsck_info.size.shape,
size=blsck_info.size,
mathtype=blsck_info.mathtype,
physical_type=blsck_info.physical_type,
default_unit=ct.UNITS_BLENDER[blsck_info.physical_type],

View File

@ -50,6 +50,7 @@ from .flow_kinds import (
LazyArrayRangeFlow,
LazyValueFuncFlow,
ParamsFlow,
ScalingMode,
ValueFlow,
)
from .flow_signals import FlowSignal
@ -116,6 +117,7 @@ __all__ = [
'LazyArrayRangeFlow',
'LazyValueFuncFlow',
'ParamsFlow',
'ScalingMode',
'ValueFlow',
'FlowSignal',
]

View File

@ -18,7 +18,7 @@ from .array import ArrayFlow
from .capabilities import CapabilitiesFlow
from .flow_kinds import FlowKind
from .info import InfoFlow
from .lazy_array_range import LazyArrayRangeFlow
from .lazy_array_range import LazyArrayRangeFlow, ScalingMode
from .lazy_value_func import LazyValueFuncFlow
from .params import ParamsFlow
from .value import ValueFlow
@ -29,6 +29,7 @@ __all__ = [
'FlowKind',
'InfoFlow',
'LazyArrayRangeFlow',
'ScalingMode',
'LazyValueFuncFlow',
'ParamsFlow',
'ValueFlow',

View File

@ -96,7 +96,8 @@ class ArrayFlow:
msg = f'Tried to correct unit of unitless LazyDataValueRange "{corrected_unit}"'
raise ValueError(msg)
def rescale_to_unit(self, unit: spu.Quantity) -> typ.Self:
def rescale_to_unit(self, unit: spu.Quantity | None) -> typ.Self:
## TODO: Cache by unit would be a very nice speedup for Viz node.
if self.unit is not None:
return ArrayFlow(
values=float(spux.scaling_factor(self.unit, unit)) * self.values,
@ -104,8 +105,8 @@ class ArrayFlow:
is_sorted=self.is_sorted,
)
if unit is None:
return self
msg = f'Tried to rescale unitless LazyDataValueRange to unit {unit}'
raise ValueError(msg)
def rescale_to_unit_system(self, unit: spu.Quantity) -> typ.Self:
raise NotImplementedError

View File

@ -62,6 +62,9 @@ class FlowKind(enum.StrEnum):
Params = enum.auto()
Info = enum.auto()
####################
# - Class Methods
####################
@classmethod
def scale_to_unit_system(
cls,
@ -85,3 +88,43 @@ class FlowKind(enum.StrEnum):
msg = 'Tried to scale unknown kind'
raise ValueError(msg)
####################
# - Computed
####################
@property
def flow_kind(self) -> str:
return {
FlowKind.Value: FlowKind.Value,
FlowKind.Array: FlowKind.Array,
FlowKind.LazyValueFunc: FlowKind.LazyValueFunc,
FlowKind.LazyArrayRange: FlowKind.LazyArrayRange,
}[self]
@property
def socket_shape(self) -> str:
return {
FlowKind.Value: 'CIRCLE',
FlowKind.Array: 'SQUARE',
FlowKind.LazyArrayRange: 'SQUARE',
FlowKind.LazyValueFunc: 'DIAMOND',
}[self]
####################
# - Blender Enum
####################
@staticmethod
def to_name(v: typ.Self) -> str:
return {
FlowKind.Capabilities: 'Capabilities',
FlowKind.Value: 'Value',
FlowKind.Array: 'Array',
FlowKind.LazyArrayRange: 'Range',
FlowKind.LazyValueFunc: 'Lazy Value',
FlowKind.Params: 'Parameters',
FlowKind.Info: 'Information',
}[v]
@staticmethod
def to_icon(_: typ.Self) -> str:
return ''

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import dataclasses
import enum
import functools
import typing as typ
from types import MappingProxyType
@ -33,6 +34,25 @@ from .lazy_value_func import LazyValueFuncFlow
log = logger.get(__name__)
class ScalingMode(enum.StrEnum):
Lin = enum.auto()
Geom = enum.auto()
Log = enum.auto()
@staticmethod
def to_name(v: typ.Self) -> str:
SM = ScalingMode
return {
SM.Lin: 'Linear',
SM.Geom: 'Geometric',
SM.Log: 'Logarithmic',
}[v]
@staticmethod
def to_icon(_: typ.Self) -> str:
return ''
@dataclasses.dataclass(frozen=True, kw_only=True)
class LazyArrayRangeFlow:
r"""Represents a linearly/logarithmically spaced array using symbolic boundary expressions, with support for units and lazy evaluation.
@ -84,7 +104,7 @@ class LazyArrayRangeFlow:
start: spux.ScalarUnitlessComplexExpr
stop: spux.ScalarUnitlessComplexExpr
steps: int
scaling: typ.Literal['lin', 'geom', 'log'] = 'lin'
scaling: ScalingMode = ScalingMode.Lin
unit: spux.Unit | None = None
@ -295,9 +315,9 @@ class LazyArrayRangeFlow:
A `jax` function that takes a valid `start`, `stop`, and `steps`, and returns a 1D `jax` array.
"""
jnp_nspace = {
'lin': jnp.linspace,
'geom': jnp.geomspace,
'log': jnp.logspace,
ScalingMode.Lin: jnp.linspace,
ScalingMode.Geom: jnp.geomspace,
ScalingMode.Log: jnp.logspace,
}.get(self.scaling)
if jnp_nspace is None:
msg = f'ArrayFlow scaling method {self.scaling} is unsupported'

View File

@ -430,17 +430,40 @@ class MaxwellSimTree(bpy.types.NodeTree):
####################
# - Post-Load Handler
####################
def initialize_sim_tree_node_link_cache(_: bpy.types.Scene):
@bpy.app.handlers.persistent
def initialize_sim_tree_node_link_cache(_):
"""Whenever a file is loaded, create/regenerate the NodeLinkCache in all trees."""
for node_tree in bpy.data.node_groups:
if node_tree.bl_idname == 'MaxwellSimTree':
node_tree.on_load()
@bpy.app.handlers.persistent
def populate_missing_persistence(_) -> None:
"""For all nodes and sockets with elements that don't have persistent elements computed, compute them.
This is used when new dynamic enum properties are added to nodes and sockets, which need to first be computed and persisted in a context where setting properties is allowed.
"""
# Iterate over MaxwellSim Trees
for node_tree in [
_node_tree
for _node_tree in bpy.data.node_groups
if _node_tree.bl_idname == ct.TreeType.MaxwellSim.value and _node_tree.is_active
]:
# Iterate over MaxwellSim Nodes
# -> Excludes ex. frame and reroute nodes.
for node in [_node for _node in node_tree.nodes if hasattr(_node, 'node_type')]:
node.regenerate_dynamic_field_persistance()
for bl_sockets in [node.inputs, node.outputs]:
for bl_socket in bl_sockets:
bl_socket.regenerate_dynamic_field_persistance()
####################
# - Blender Registration
####################
bpy.app.handlers.load_post.append(initialize_sim_tree_node_link_cache)
bpy.app.handlers.load_post.append(populate_missing_persistence)
## TODO: Move to top-level registration.
BL_REGISTER = [

View File

@ -67,8 +67,7 @@ class ExtractDataNode(base.MaxwellSimNode):
####################
# - Properties
####################
extract_filter: enum.Enum = bl_cache.BLField(
prop_ui=True,
extract_filter: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_extract_filters(),
)

View File

@ -126,10 +126,10 @@ class FilterMathNode(base.MaxwellSimNode):
bl_label = 'Filter Math'
input_sockets: typ.ClassVar = {
'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array),
'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc),
}
output_sockets: typ.ClassVar = {
'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array),
'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc),
}
####################
@ -141,12 +141,8 @@ class FilterMathNode(base.MaxwellSimNode):
)
# Dimension Selection
dim_0: enum.Enum = bl_cache.BLField(
None, prop_ui=True, enum_cb=lambda self, _: self.search_dims()
)
dim_1: enum.Enum = bl_cache.BLField(
None, prop_ui=True, enum_cb=lambda self, _: self.search_dims()
)
dim_0: enum.StrEnum = bl_cache.BLField(enum_cb=lambda self, _: self.search_dims())
dim_1: enum.StrEnum = bl_cache.BLField(enum_cb=lambda self, _: self.search_dims())
####################
# - Computed
@ -259,14 +255,14 @@ class FilterMathNode(base.MaxwellSimNode):
# Determine Whether to Declare New Loose Input SOcket
if (
current_bl_socket is None
or current_bl_socket.shape is not None
or current_bl_socket.size is not spux.NumberSize1D.Scalar
or current_bl_socket.physical_type != pinned_physical_type
or current_bl_socket.mathtype != wanted_mathtype
):
self.loose_input_sockets = {
'Value': sockets.ExprSocketDef(
active_kind=ct.FlowKind.Value,
shape=None,
size=spux.NumberSize1D.Scalar,
physical_type=pinned_physical_type,
mathtype=wanted_mathtype,
default_unit=pinned_unit,

View File

@ -364,10 +364,10 @@ class MapMathNode(base.MaxwellSimNode):
bl_label = 'Map Math'
input_sockets: typ.ClassVar = {
'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array),
'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc),
}
output_sockets: typ.ClassVar = {
'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array),
'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc),
}
####################

View File

@ -71,22 +71,22 @@ class OperateMathNode(base.MaxwellSimNode):
bl_label = 'Operate Math'
input_sockets: typ.ClassVar = {
'Expr L': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array),
'Expr R': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array),
'Expr L': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc),
'Expr R': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc),
}
output_sockets: typ.ClassVar = {
'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array),
'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc),
}
####################
# - Properties
####################
category: enum.Enum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_categories()
category: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_categories()
)
operation: enum.Enum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_operations()
operation: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_operations()
)
def search_categories(self) -> list[ct.BLEnumElement]:

View File

@ -62,8 +62,8 @@ class TransformMathNode(base.MaxwellSimNode):
####################
# - Properties
####################
operation: enum.Enum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_operations()
operation: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_operations()
)
def search_operations(self) -> list[ct.BLEnumElement]:

View File

@ -209,7 +209,7 @@ class VizNode(base.MaxwellSimNode):
####################
input_sockets: typ.ClassVar = {
'Expr': sockets.ExprSocketDef(
active_kind=ct.FlowKind.Array,
active_kind=ct.FlowKind.LazyValueFunc,
symbols={_x := sp.Symbol('x', real=True)},
default_value=2 * _x,
),
@ -225,31 +225,33 @@ class VizNode(base.MaxwellSimNode):
#####################
## - Properties
#####################
viz_mode: enum.Enum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_viz_modes()
)
viz_target: enum.Enum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_targets()
)
# Mode-Dependent Properties
colormap: image_ops.Colormap = bl_cache.BLField(
image_ops.Colormap.Viridis, prop_ui=True
)
#####################
## - Mode Searcher
#####################
@property
def data_info(self) -> ct.InfoFlow | None:
@bl_cache.cached_bl_property()
def input_info(self) -> ct.InfoFlow | None:
info = self._compute_input('Expr', kind=ct.FlowKind.Info)
if not ct.FlowSignal.check(info):
return info
return None
viz_mode: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_viz_modes(),
cb_depends_on={'input_info'},
)
viz_target: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_targets(),
cb_depends_on={'viz_mode'},
)
# Mode-Dependent Properties
colormap: image_ops.Colormap = bl_cache.BLField(
image_ops.Colormap.Viridis,
)
#####################
## - Searchers
#####################
def search_viz_modes(self) -> list[ct.BLEnumElement]:
if self.data_info is not None:
if self.input_info is not None:
return [
(
viz_mode,
@ -258,14 +260,11 @@ class VizNode(base.MaxwellSimNode):
VizMode.to_icon(viz_mode),
i,
)
for i, viz_mode in enumerate(VizMode.valid_modes_for(self.data_info))
for i, viz_mode in enumerate(VizMode.valid_modes_for(self.input_info))
]
return []
#####################
## - Target Searcher
#####################
def search_targets(self) -> list[ct.BLEnumElement]:
if self.viz_mode is not None:
return [
@ -302,20 +301,14 @@ class VizNode(base.MaxwellSimNode):
input_sockets_optional={'Expr': True},
)
def on_any_changed(self, input_sockets: dict):
self.input_info = bl_cache.Signal.InvalidateCache
info = input_sockets['Expr'][ct.FlowKind.Info]
params = input_sockets['Expr'][ct.FlowKind.Params]
has_info = not ct.FlowSignal.check(info)
has_params = not ct.FlowSignal.check(params)
# Reset Viz Mode/Target
has_nonpending_info = not ct.FlowSignal.check_single(
info, ct.FlowSignal.FlowPending
)
if has_nonpending_info:
self.viz_mode = bl_cache.Signal.ResetEnumItems
self.viz_target = bl_cache.Signal.ResetEnumItems
# Provide Sockets for Symbol Realization
## -> This happens if Params contains not-yet-realized symbols.
if has_info and has_params and params.symbols:
@ -325,7 +318,7 @@ class VizNode(base.MaxwellSimNode):
self.loose_input_sockets = {
sym.name: sockets.ExprSocketDef(
active_kind=ct.FlowKind.LazyArrayRange,
shape=None,
size=spux.NumberSize1D.Scalar,
mathtype=info.dim_mathtypes[sym.name],
physical_type=info.dim_physical_types[sym.name],
default_min=(
@ -340,22 +333,13 @@ class VizNode(base.MaxwellSimNode):
),
default_steps=50,
)
for sym in sorted(
params.symbols, key=lambda el: info.dim_names.index(el.name)
)
for sym in params.sorted_symbols
if sym.name in info.dim_names
}
elif self.loose_input_sockets:
self.loose_input_sockets = {}
@events.on_value_changed(
prop_name='viz_mode',
run_on_init=True,
)
def on_viz_mode_changed(self):
self.viz_target = bl_cache.Signal.ResetEnumItems
#####################
## - Plotting
#####################
@ -374,12 +358,15 @@ class VizNode(base.MaxwellSimNode):
self, managed_objs, props, input_sockets, loose_input_sockets, unit_systems
):
# Retrieve Inputs
lazy_value_func = input_sockets['Expr'][ct.FlowKind.LazyValueFunc]
info = input_sockets['Expr'][ct.FlowKind.Info]
params = input_sockets['Expr'][ct.FlowKind.Params]
has_info = not ct.FlowSignal.check(info)
has_params = not ct.FlowSignal.check(params)
# Invalid Mode | Target
## -> To limit branching, return now if things aren't right.
if (
not has_info
or not has_params
@ -388,18 +375,21 @@ class VizNode(base.MaxwellSimNode):
):
return
# Compute Data
lazy_value_func = input_sockets['Expr'][ct.FlowKind.LazyValueFunc]
symbol_values = (
loose_input_sockets
if not params.symbols
else {
sym: loose_input_sockets[sym.name]
# Compute LazyArrayRanges for Symbols from Loose Sockets
## -> These are the concrete values of the symbol for plotting.
## -> In a quite nice turn of events, all this is cached lookups.
## -> ...Unless something changed, in which case, well. It changed.
symbol_values = {
sym: (
loose_input_sockets[sym.name]
.realize_array.rescale_to_unit(info.dim_units[sym.name])
.values
)
for sym in params.sorted_symbols
}
)
# Realize LazyValueFunc w/Symbolic Values, Unit System
## -> This gives us the actual plot data!
data = lazy_value_func.func_jax(
*params.scaled_func_args(
unit_systems['BlenderUnits'], symbol_values=symbol_values
@ -408,6 +398,9 @@ class VizNode(base.MaxwellSimNode):
unit_systems['BlenderUnits'], symbol_values=symbol_values
),
)
# Replace InfoFlow Indices w/Realized Symbolic Ranges
## -> This ensures correct axis scaling.
if params.symbols:
info = info.rescale_dim_idxs(loose_input_sockets)
@ -417,6 +410,7 @@ class VizNode(base.MaxwellSimNode):
lambda ax: VizMode.to_plotter(props['viz_mode'])(data, info, ax),
bl_select=True,
)
if props['viz_target'] == VizTarget.Pixels:
managed_objs['plot'].map_2d_to_image(
data,

View File

@ -20,17 +20,15 @@ Attributes:
MANDATORY_PROPS: Properties that must be defined on the `MaxwellSimNode`.
"""
## TODO: Check whether input_socket_sets and output_socket_sets have the right shape? Or just use a type checker...
import enum
import typing as typ
import uuid
from collections import defaultdict
from types import MappingProxyType
import bpy
import sympy as sp
from blender_maxwell.utils import bl_cache, logger
from blender_maxwell.utils import bl_cache, bl_instance, logger
from .. import contracts as ct
from .. import managed_objs as _managed_objs
@ -40,10 +38,20 @@ from . import presets as _presets
log = logger.get(__name__)
####################
# - Types
####################
Sockets: typ.TypeAlias = dict[str, sockets.base.SocketDef]
Preset: typ.TypeAlias = dict[str, _presets.PresetDef]
ManagedObjs: typ.TypeAlias = dict[ct.ManagedObjName, type[_managed_objs.ManagedObj]]
MANDATORY_PROPS: set[str] = {'node_type', 'bl_label'}
class MaxwellSimNode(bpy.types.Node):
####################
# - Node
####################
class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
"""A specialized Blender node for Maxwell simulations.
Attributes:
@ -58,100 +66,111 @@ class MaxwellSimNode(bpy.types.Node):
locked: Whether the node is currently 'locked' aka. non-editable.
"""
####################
# - Properties
####################
node_type: ct.NodeType
bl_label: str
# Features
use_sim_node_name: bool = False
## TODO: bl_description from first line of __doc__?
# Sockets
input_sockets: typ.ClassVar[dict[str, sockets.base.SocketDef]] = MappingProxyType(
{}
)
output_sockets: typ.ClassVar[dict[str, sockets.base.SocketDef]] = MappingProxyType(
{}
)
input_socket_sets: typ.ClassVar[dict[str, dict[str, sockets.base.SocketDef]]] = (
MappingProxyType({})
)
output_socket_sets: typ.ClassVar[dict[str, dict[str, sockets.base.SocketDef]]] = (
MappingProxyType({})
# Declarations
input_sockets: typ.ClassVar[Sockets] = MappingProxyType({})
output_sockets: typ.ClassVar[Sockets] = MappingProxyType({})
input_socket_sets: typ.ClassVar[dict[str, Sockets]] = MappingProxyType({})
output_socket_sets: typ.ClassVar[dict[str, Sockets]] = MappingProxyType({})
managed_obj_types: typ.ClassVar[ManagedObjs] = MappingProxyType({})
presets: typ.ClassVar[dict[str, Preset]] = MappingProxyType({})
## __init_subclass__ Computed
bl_idname: str
####################
# - Fields
####################
sim_node_name: str = bl_cache.BLField('')
# Loose Sockets
loose_input_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({})
loose_output_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({})
# UI Options
preview_active: bool = bl_cache.BLField(False)
locked: bool = bl_cache.BLField(False, use_prop_update=False)
# Active Socket Set
active_socket_set: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.socket_sets_bl_enum()
)
# Presets
presets: typ.ClassVar[dict[str, dict[str, _presets.PresetDef]]] = MappingProxyType(
{}
@classmethod
def socket_sets_bl_enum(cls) -> list[ct.BLEnumElement]:
return [
(socket_set_name, socket_set_name, socket_set_name, '', i)
for i, socket_set_name in enumerate(cls.socket_set_names())
]
# Active Preset
active_preset: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.presets_bl_enum()
)
# Managed Objects
managed_obj_types: typ.ClassVar[
dict[ct.ManagedObjName, type[_managed_objs.ManagedObj]]
] = MappingProxyType({})
@classmethod
def presets_bl_enum(cls) -> list[ct.BLEnumElement]:
return [
(
preset_name,
preset_def.label,
preset_def.description,
'',
i,
)
for i, (preset_name, preset_def) in enumerate(cls.presets.items())
]
def reset_instance_id(self) -> None:
self.instance_id = str(uuid.uuid4())
####################
# - Managed Objects
####################
@bl_cache.cached_bl_property(depends_on={'sim_node_name'})
def managed_objs(self) -> dict[str, _managed_objs.ManagedObj]:
"""Access the constructed managed objects defined in `self.managed_obj_types`.
# BLFields
blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({})
ui_blfields: typ.ClassVar[set[str]] = frozenset()
Managed objects are special in that they **don't keep any non-reproducible state**.
In fact, all managed object state can generally be derived entirely from the managed object's `name` attribute.
As a result, **consistency in namespacing is of the utmost importance**, if reproducibility of managed objects is to be guaranteed.
This name must be in sync with the name of the managed "thing", which is where this computed property comes in.
The node's half of the responsibility is to push a new name whenever `self.sim_node_name` changes.
"""
if self.managed_obj_types:
return {
mobj_name: mobj_type(self.sim_node_name)
for mobj_name, mobj_type in self.managed_obj_types.items()
}
return {}
####################
# - Class Methods
####################
@classmethod
def _assert_attrs_valid(cls) -> None:
"""Asserts that all mandatory attributes are defined on the class.
The list of mandatory objects is sourced from `base.MANDATORY_PROPS`.
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)
@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,
prop_name: str,
prop: bpy.types.Property,
no_update: bool = False,
update_with_name: str | None = None,
**kwargs,
) -> None:
"""Adds a Blender property to a class via `__annotations__`, so it initializes with any subclass.
def socket_set_names(cls) -> list[str]:
"""Retrieve the names of socket sets, in an order-preserving way.
Notes:
- Blender properties can't be set within `__init_subclass__` simply by adding attributes to the class; they must be added as type annotations.
- Must be called **within** `__init_subclass__`.
Semantically similar to `list(set(...) | set(...))`.
Parameters:
name: The name of the property to set.
prop: The `bpy.types.Property` to instantiate and attach..
no_update: Don't attach a `self.on_prop_changed()` callback to the property's `update`.
Returns:
List of socket set names, without duplicates, in definition order.
"""
_update_with_name = prop_name if update_with_name is None else update_with_name
extra_kwargs = (
{
'update': lambda self, context: self.on_prop_changed(
_update_with_name, context
),
}
if not no_update
else {}
)
cls.__annotations__[prop_name] = prop(
**kwargs,
**extra_kwargs,
)
return (_input_socket_set_names := list(cls.input_socket_sets.keys())) + [
output_socket_set_name
for output_socket_set_name in cls.output_socket_sets
if output_socket_set_name not in _input_socket_set_names
]
@classmethod
def _gather_event_methods(cls) -> dict[str, typ.Callable[[], None]]:
@ -169,6 +188,7 @@ class MaxwellSimNode(bpy.types.Node):
for attr_name in dir(cls)
if hasattr(method := getattr(cls, attr_name), 'event')
and method.event in set(ct.FlowEvent)
## Forbidding blfields prevents triggering __get__ on bl_property
]
event_methods_by_event = {event: [] for event in set(ct.FlowEvent)}
for method in event_methods:
@ -176,22 +196,6 @@ class MaxwellSimNode(bpy.types.Node):
return event_methods_by_event
@classmethod
def socket_set_names(cls) -> list[str]:
"""Retrieve the names of socket sets, in an order-preserving way.
Notes:
Semantically similar to `list(set(...) | set(...))`.
Returns:
List of socket set names, without duplicates, in definition order.
"""
return (_input_socket_set_names := list(cls.input_socket_sets.keys())) + [
output_socket_set_name
for output_socket_set_name in cls.output_socket_sets
if output_socket_set_name not in _input_socket_set_names
]
####################
# - Subclass Initialization
####################
@ -204,64 +208,20 @@ class MaxwellSimNode(bpy.types.Node):
"""
log.debug('Initializing Node: %s', cls.node_type)
super().__init_subclass__(**kwargs)
cls._assert_attrs_valid()
# Check Attribute Validity
cls.assert_attrs_valid(MANDATORY_PROPS)
# Node Properties
## Identifiers
cls.bl_idname: str = str(cls.node_type.value)
cls.set_prop('instance_id', bpy.props.StringProperty, no_update=True)
cls.set_prop('sim_node_name', bpy.props.StringProperty, default='')
## Special States
cls.set_prop('preview_active', bpy.props.BoolProperty, default=False)
cls.set_prop('locked', bpy.props.BoolProperty, no_update=True, default=False)
## Event Method Callbacks
cls.event_methods_by_event = cls._gather_event_methods()
## Active Socket Set
if len(cls.input_socket_sets) + len(cls.output_socket_sets) > 0:
socket_set_names = cls.socket_set_names()
cls.set_prop(
'active_socket_set',
bpy.props.EnumProperty,
name='Active Socket Set',
items=[
(socket_set_name, socket_set_name, socket_set_name)
for socket_set_name in socket_set_names
],
default=socket_set_names[0],
)
else:
cls.active_socket_set = None
## Active Preset
## TODO: Validate Presets
if cls.presets:
cls.set_prop(
'active_preset',
bpy.props.EnumProperty,
name='Active Preset',
description='The currently active preset',
items=[
(
preset_name,
preset_def.label,
preset_def.description,
)
for preset_name, preset_def in cls.presets.items()
],
default=next(cls.presets.keys()),
)
else:
cls.active_preset = None
####################
# - Events: Default
# - Events: Sim Node Name | Active Socket Set | Active Preset
####################
@events.on_value_changed(
prop_name='sim_node_name',
props={'sim_node_name'},
props={'sim_node_name', 'managed_objs'},
stop_propagation=True,
)
def _on_sim_node_name_changed(self, props):
@ -273,7 +233,7 @@ class MaxwellSimNode(bpy.types.Node):
)
# Set Name of Managed Objects
for mobj in self.managed_objs.values():
for mobj in props['managed_objs'].values():
mobj.name = props['sim_node_name']
## Invalidate Cache
@ -290,7 +250,10 @@ class MaxwellSimNode(bpy.types.Node):
self._sync_sockets()
@events.on_value_changed(
prop_name='active_preset', props=['presets', 'active_preset']
prop_name='active_preset',
run_on_init=True,
props={'presets', 'active_preset'},
stop_propagation=True,
)
def _on_active_preset_changed(self, props: dict):
if props['active_preset'] is not None:
@ -313,6 +276,9 @@ class MaxwellSimNode(bpy.types.Node):
## TODO: Account for FlowKind
bl_socket.value = socket_value
####################
# - Events: Preview | Plot
####################
@events.on_show_plot(stop_propagation=False)
def _on_show_plot(self):
node_tree = self.id_data
@ -339,6 +305,9 @@ class MaxwellSimNode(bpy.types.Node):
for mobj in self.managed_objs.values():
mobj.hide_preview()
####################
# - Events: Lock
####################
@events.on_enable_lock()
def _on_enabled_lock(self):
# Set Locked to Active
@ -354,11 +323,8 @@ class MaxwellSimNode(bpy.types.Node):
self.locked = False
####################
# - Loose Sockets w/Events
# - Events: Loose Sockets
####################
loose_input_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({})
loose_output_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({})
@events.on_value_changed(prop_name={'loose_input_sockets', 'loose_output_sockets'})
def _on_loose_sockets_changed(self):
self._sync_sockets()
@ -516,23 +482,6 @@ class MaxwellSimNode(bpy.types.Node):
self._prune_inactive_sockets()
self._add_new_active_sockets()
####################
# - Managed Objects
####################
@bl_cache.cached_bl_property(persist=True)
def managed_objs(self) -> dict[str, _managed_objs.ManagedObj]:
"""Access the managed objects defined on this node.
Persistent cache ensures that the managed objects are only created on first access, even across file reloads.
"""
if self.managed_obj_types:
return {
mobj_name: mobj_type(self.sim_node_name)
for mobj_name, mobj_type in self.managed_obj_types.items()
}
return {}
####################
# - Event Methods
####################
@ -733,7 +682,7 @@ class MaxwellSimNode(bpy.types.Node):
)
)
@bl_cache.cached_bl_property(persist=False)
@bl_cache.cached_bl_property()
def _dependent_outputs(
self,
) -> dict[
@ -904,7 +853,7 @@ class MaxwellSimNode(bpy.types.Node):
####################
# - Property Event: On Update
####################
def on_prop_changed(self, prop_name: str, _: bpy.types.Context) -> None:
def on_prop_changed(self, prop_name: str) -> None:
"""Report that a particular property has changed, which may cause certain caches to regenerate.
Notes:
@ -916,10 +865,6 @@ class MaxwellSimNode(bpy.types.Node):
prop_name: The name of the property that changed.
"""
if 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, prop_name=prop_name)
else:
@ -952,16 +897,16 @@ class MaxwellSimNode(bpy.types.Node):
layout.enabled = False
if self.active_socket_set:
layout.prop(self, 'active_socket_set', text='')
layout.prop(self, self.blfields['active_socket_set'], text='')
if self.active_preset is not None:
layout.prop(self, 'active_preset', text='')
layout.prop(self, self.blfields['active_preset'], text='')
# Draw Name
if self.use_sim_node_name:
row = layout.row(align=True)
row.label(text='', icon='FILE_TEXT')
row.prop(self, 'sim_node_name', text='')
row.prop(self, self.blfields['sim_node_name'], text='')
# Draw Name
self.draw_props(context, layout)
@ -1029,24 +974,20 @@ class MaxwellSimNode(bpy.types.Node):
Notes:
Run by Blender when a new instance of a node is added to a tree.
"""
# Initialize Sockets
## -> Ensures the availability of static sockets before items/methods.
## -> Ensures the availability of static sockets before items/methods.
self._sync_sockets()
# Initialize Instance ID
## This is used by various caches from 'bl_cache'.
## -> This is used by various caches from 'bl_cache'.
## -> Also generates (first-time) the various enums.
self.reset_instance_id()
# Initialize Name
## This is used whenever a unique name pointing to this node is needed.
## Contrary to self.name, it can be altered by the user as a property.
## -> Ensures the availability of sim_node_name immediately.
self.sim_node_name = self.name
# Initialize Sockets
## This initializes any nodes that need initializing
self._sync_sockets()
# Apply Preset
## This applies the default preset, if any.
if self.active_preset:
self._on_active_preset_changed()
# Event Methods
## Run any 'DataChanged' methods with 'run_on_init' set.
## Semantically: Creating data _arguably_ changes it.

View File

@ -61,7 +61,7 @@ class AdiabAbsorbBoundCondNode(base.MaxwellSimNode):
####################
input_sockets: typ.ClassVar = {
'Layers': sockets.ExprSocketDef(
shape=None,
size=spux.NumberSize1D.Scalar,
mathtype=spux.MathType.Integer,
abs_min=1,
default_value=40,
@ -71,14 +71,13 @@ class AdiabAbsorbBoundCondNode(base.MaxwellSimNode):
'Simple': {},
'Full': {
'σ Order': sockets.ExprSocketDef(
shape=None,
size=spux.NumberSize1D.Scalar,
mathtype=spux.MathType.Integer,
abs_min=1,
default_value=3,
),
'σ Range': sockets.ExprSocketDef(
shape=(2,),
mathtype=spux.MathType.Real,
size=spux.NumberSize1D.Vec2,
default_value=sp.Matrix([0, 1.5]),
abs_min=0,
),

View File

@ -64,7 +64,7 @@ class PMLBoundCondNode(base.MaxwellSimNode):
####################
input_sockets: typ.ClassVar = {
'Layers': sockets.ExprSocketDef(
shape=None,
size=spux.NumberSize1D.Scalar,
mathtype=spux.MathType.Integer,
abs_min=1,
default_value=12,
@ -74,37 +74,37 @@ class PMLBoundCondNode(base.MaxwellSimNode):
'Simple': {},
'Full': {
'σ Order': sockets.ExprSocketDef(
shape=None,
size=spux.NumberSize1D.Scalar,
mathtype=spux.MathType.Integer,
abs_min=1,
default_value=3,
),
'σ Range': sockets.ExprSocketDef(
shape=(2,),
size=spux.NumberSize1D.Vec2,
mathtype=spux.MathType.Real,
default_value=sp.Matrix([0, 1.5]),
abs_min=0,
),
'κ Order': sockets.ExprSocketDef(
shape=None,
size=spux.NumberSize1D.Scalar,
mathtype=spux.MathType.Integer,
abs_min=1,
default_value=3,
),
'κ Range': sockets.ExprSocketDef(
shape=(2,),
size=spux.NumberSize1D.Vec2,
mathtype=spux.MathType.Real,
default_value=sp.Matrix([0, 1.5]),
abs_min=0,
),
'α Order': sockets.ExprSocketDef(
shape=None,
size=spux.NumberSize1D.Scalar,
mathtype=spux.MathType.Integer,
abs_min=1,
default_value=3,
),
'α Range': sockets.ExprSocketDef(
shape=(2,),
size=spux.NumberSize1D.Vec2,
mathtype=spux.MathType.Real,
default_value=sp.Matrix([0, 1.5]),
abs_min=0,

View File

@ -59,7 +59,6 @@ class PhysicalConstantNode(base.MaxwellSimNode):
size: spux.NumberSize1D = bl_cache.BLField(
enum_cb=lambda self, _: self.search_sizes(),
prop_ui=True,
)
####################
@ -75,7 +74,7 @@ class PhysicalConstantNode(base.MaxwellSimNode):
return [
spux.NumberSize1D.from_shape(shape).bl_enum_element(i)
for i, shape in enumerate(self.physical_type.valid_shapes)
if spux.NumberSize1D.supports_shape(shape)
if spux.NumberSize1D.has_shape(shape)
]
####################

View File

@ -16,11 +16,11 @@
"""Implements `SceneNode`."""
import enum
import typing as typ
import bpy
import sympy as sp
import sympy.physics.units as spu
from blender_maxwell.utils import bl_cache, logger
from blender_maxwell.utils import extra_sympy_units as spux
@ -45,7 +45,11 @@ class SceneNode(base.MaxwellSimNode):
input_sockets: typ.ClassVar = {
'Frames / Unit': sockets.ExprSocketDef(
mathtype=spux.MathType.Integer,
default_value=24,
default_value=48,
),
'Unit': sockets.ExprSocketDef(
default_unit=spu.ps,
default_value=1,
),
}
output_sockets: typ.ClassVar = {
@ -60,7 +64,7 @@ class SceneNode(base.MaxwellSimNode):
####################
# - Properties: Frame
####################
@property
@bl_cache.cached_bl_property()
def scene_frame(self) -> int:
"""Retrieve the current frame of the scene.
@ -71,6 +75,7 @@ class SceneNode(base.MaxwellSimNode):
@property
def scene_frame_range(self) -> ct.LazyArrayRangeFlow:
"""Retrieve the current start/end frame of the scene, with `steps` corresponding to single-frame steps."""
frame_start = bpy.context.scene.frame_start
frame_stop = bpy.context.scene.frame_end
return ct.LazyArrayRangeFlow(
@ -79,69 +84,20 @@ class SceneNode(base.MaxwellSimNode):
steps=frame_stop - frame_start + 1,
)
####################
# - Property: Time Unit
####################
active_time_unit: enum.Enum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_units(), prop_ui=True
)
def search_units(self) -> list[ct.BLEnumElement]:
return [
(sp.sstr(unit), spux.sp_to_str(unit), sp.sstr(unit), '', i)
for i, unit in enumerate(spux.PhysicalType.Time.valid_units)
]
@property
def time_unit(self) -> spux.Unit | None:
"""Gets the current active unit.
Returns:
The current active `sympy` unit.
If the socket expression is unitless, this returns `None`.
"""
if self.active_time_unit is not None:
return spux.unit_str_to_unit(self.active_time_unit)
return None
@time_unit.setter
def time_unit(self, time_unit: spux.Unit | None) -> None:
"""Set the unit, without touching the `raw_*` UI properties.
Notes:
To set a new unit, **and** convert the `raw_*` UI properties to the new unit, use `self.convert_unit()` instead.
"""
if time_unit in spux.PhysicalType.Time.valid_units:
self.active_time_unit = sp.sstr(time_unit)
else:
msg = f'Tried to set invalid time unit {time_unit}'
raise ValueError(msg)
####################
# - UI
####################
def draw_props(self, _: bpy.types.Context, col: bpy.types.UILayout) -> None:
"""Draws the button that allows toggling between single and range output.
Parameters:
col: Target for defining UI elements.
"""
col.prop(self, self.blfields['active_time_unit'], toggle=True, text='Unit')
####################
# - FlowKinds
####################
@events.computes_output_socket(
'Time',
kind=ct.FlowKind.Value,
input_sockets={'Frames / Unit'},
props={'scene_frame', 'active_time_unit', 'time_unit'},
input_sockets={'Frames / Unit', 'Unit'},
props={'scene_frame'},
)
def compute_time(self, props, input_sockets) -> sp.Expr:
return (
props['scene_frame'] / input_sockets['Frames / Unit'] * props['time_unit']
props['scene_frame']
/ input_sockets['Frames / Unit']
* input_sockets['Unit']
)
@events.computes_output_socket(
@ -159,10 +115,18 @@ class SceneNode(base.MaxwellSimNode):
BL_REGISTER = [
SceneNode,
]
BL_NODES = {ct.NodeType.Scene: (ct.NodeCategory.MAXWELLSIM_INPUTS)}
####################
# - Blender Handlers
####################
@bpy.app.handlers.persistent
def update_scene_node_after_frame_changed(scene, depsgraph) -> None:
def update_scene_node_after_frame_changed(
scene: bpy.types.Scene, # noqa: ARG001
depsgraph: bpy.types.Depsgraph, # noqa: ARG001
) -> None:
"""Invalidate the cached scene frame on all `SceneNode`s in all active simulation node trees, whenever the frame changes."""
for node_tree in [
_node_tree
for _node_tree in bpy.data.node_groups
@ -173,9 +137,7 @@ def update_scene_node_after_frame_changed(scene, depsgraph) -> None:
for _node in node_tree.nodes
if hasattr(_node, 'node_type') and _node.node_type == ct.NodeType.Scene
]:
node.trigger_event(ct.FlowEvent.DataChanged, prop_name='scene_frame')
node.scene_frame = bl_cache.Signal.InvalidateCache
bpy.app.handlers.frame_change_post.append(update_scene_node_after_frame_changed)
BL_NODES = {ct.NodeType.Scene: (ct.NodeCategory.MAXWELLSIM_INPUTS)}

View File

@ -49,36 +49,28 @@ class WaveConstantNode(base.MaxwellSimNode):
input_socket_sets: typ.ClassVar = {
'Wavelength': {
'WL': sockets.ExprSocketDef(
active_kind=ct.FlowKind.Value,
physical_type=spux.PhysicalType.Length,
# Defaults
default_unit=spu.nm,
default_value=500,
default_min=200,
default_max=700,
default_steps=2,
default_steps=50,
)
},
'Frequency': {
'Freq': sockets.ExprSocketDef(
active_kind=ct.FlowKind.Value,
physical_type=spux.PhysicalType.Freq,
# Defaults
default_unit=spux.THz,
default_value=1,
default_min=0.3,
default_max=3,
default_steps=2,
default_steps=50,
),
},
}
output_sockets: typ.ClassVar = {
'WL': sockets.ExprSocketDef(
active_kind=ct.FlowKind.Value,
physical_type=spux.PhysicalType.Length,
),
'Freq': sockets.ExprSocketDef(
active_kind=ct.FlowKind.Value,
physical_type=spux.PhysicalType.Freq,
),
}
@ -86,7 +78,7 @@ class WaveConstantNode(base.MaxwellSimNode):
####################
# - Properties
####################
use_range: bool = bl_cache.BLField(False, prop_ui=True)
use_range: bool = bl_cache.BLField(False)
####################
# - UI
@ -192,6 +184,7 @@ class WaveConstantNode(base.MaxwellSimNode):
sci_constants.vac_speed_of_light / (freq.start * freq.unit), spu.um
),
steps=freq.steps,
scaling=freq.scaling,
unit=spu.um,
)
@ -220,6 +213,7 @@ class WaveConstantNode(base.MaxwellSimNode):
sci_constants.vac_speed_of_light / (wl.start * wl.unit), spux.THz
),
steps=wl.steps,
scaling=wl.scaling,
unit=spux.THz,
)

View File

@ -109,11 +109,7 @@ class LibraryMediumNode(base.MaxwellSimNode):
####################
# - Sockets
####################
input_sockets: typ.ClassVar = {
'Generated Steps': sockets.ExprSocketDef(
mathtype=spux.MathType.Integer, default_value=2, abs_min=2
)
}
input_sockets: typ.ClassVar = {}
output_sockets: typ.ClassVar = {
'Medium': sockets.MaxwellMediumSocketDef(),
'Valid Freqs': sockets.ExprSocketDef(
@ -133,9 +129,9 @@ class LibraryMediumNode(base.MaxwellSimNode):
####################
# - Properties
####################
vendored_medium: VendoredMedium = bl_cache.BLField(VendoredMedium.Au, prop_ui=True)
variant_name: enum.Enum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_variants()
vendored_medium: VendoredMedium = bl_cache.BLField(VendoredMedium.Au)
variant_name: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_variants()
)
def search_variants(self) -> list[ct.BLEnumElement]:
@ -145,28 +141,28 @@ class LibraryMediumNode(base.MaxwellSimNode):
####################
# - Computed
####################
@property
@bl_cache.cached_bl_property(depends_on={'vendored_medium', 'variant_name'})
def variant(self) -> Tidy3DMediumVariant:
"""Deduce the actual medium variant from `self.vendored_medium` and `self.variant_name`."""
return self.vendored_medium.medium_variants[self.variant_name]
@property
@bl_cache.cached_bl_property(depends_on={'variant'})
def medium(self) -> td.PoleResidue:
"""Deduce the actual currently selected `PoleResidue` medium from `self.variant`."""
return self.variant.medium
@property
@bl_cache.cached_bl_property(depends_on={'variant'})
def data_url(self) -> str | None:
"""Deduce the URL associated with the currently selected medium from `self.variant`."""
return self.variant.data_url
@property
@bl_cache.cached_bl_property(depends_on={'variant'})
def references(self) -> td.PoleResidue:
"""Deduce the references associated with the currently selected `PoleResidue` medium from `self.variant`."""
return self.variant.reference
@property
def freq_range(self) -> spux.SympyExpr:
@bl_cache.cached_bl_property(depends_on={'medium'})
def freq_range(self) -> sp.Expr:
"""Deduce the frequency range as a unit-aware (THz, for convenience) column vector.
A rational approximation to each frequency bound is computed with `sp.nsimplify`, in order to **guarantee** lack of precision-loss as computations are performed on the frequency.
@ -178,8 +174,8 @@ class LibraryMediumNode(base.MaxwellSimNode):
spux.terahertz,
)
@property
def wl_range(self) -> spux.SympyExpr:
@bl_cache.cached_bl_property(depends_on={'freq_range'})
def wl_range(self) -> sp.Expr:
"""Deduce the vacuum wavelength range as a unit-aware (nanometer, for convenience) column vector."""
return sp.Matrix(
self.freq_range.applyfunc(
@ -203,12 +199,12 @@ class LibraryMediumNode(base.MaxwellSimNode):
formatted_str = f'{number:.2e}'
return formatted_str
@bl_cache.cached_bl_property()
@bl_cache.cached_bl_property(depends_on={'freq_range'})
def ui_freq_range(self) -> tuple[str, str]:
"""Cached mirror of `self.wl_range` which contains UI-ready strings."""
return tuple([self._ui_range_format(el) for el in self.freq_range])
@bl_cache.cached_bl_property()
@bl_cache.cached_bl_property(depends_on={'wl_range'})
def ui_wl_range(self) -> tuple[str, str]:
"""Cached mirror of `self.wl_range` which contains UI-ready strings."""
return tuple([self._ui_range_format(el) for el in self.wl_range])
@ -279,14 +275,13 @@ class LibraryMediumNode(base.MaxwellSimNode):
'Valid Freqs',
kind=ct.FlowKind.LazyArrayRange,
props={'freq_range'},
input_sockets={'Generated Steps'},
)
def compute_valid_freqs_lazy(self, props, input_sockets) -> sp.Expr:
def compute_valid_freqs_lazy(self, props) -> sp.Expr:
return ct.LazyArrayRangeFlow(
start=props['freq_range'][0] / spux.THz,
stop=props['freq_range'][1] / spux.THz,
steps=input_sockets['Generated Steps'],
scaling='lin',
steps=0,
scaling=ct.ScalingMode.Lin,
unit=spux.THz,
)
@ -301,14 +296,13 @@ class LibraryMediumNode(base.MaxwellSimNode):
'Valid WLs',
kind=ct.FlowKind.LazyArrayRange,
props={'wl_range'},
input_sockets={'Generated Steps'},
)
def compute_valid_wls_lazy(self, props, input_sockets) -> sp.Expr:
def compute_valid_wls_lazy(self, props) -> sp.Expr:
return ct.LazyArrayRangeFlow(
start=props['wl_range'][0] / spu.nm,
stop=props['wl_range'][0] / spu.nm,
steps=input_sockets['Generated Steps'],
scaling='lin',
steps=0,
scaling=ct.ScalingMode.Lin,
unit=spu.nm,
)

View File

@ -43,16 +43,16 @@ class EHFieldMonitorNode(base.MaxwellSimNode):
####################
input_sockets: typ.ClassVar = {
'Center': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
physical_type=spux.PhysicalType.Length,
),
'Size': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
physical_type=spux.PhysicalType.Length,
default_value=sp.Matrix([1, 1, 1]),
),
'Spatial Subdivs': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Integer,
default_value=sp.Matrix([10, 10, 10]),
),

View File

@ -41,16 +41,16 @@ class PowerFluxMonitorNode(base.MaxwellSimNode):
####################
input_sockets: typ.ClassVar = {
'Center': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
physical_type=spux.PhysicalType.Length,
),
'Size': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
physical_type=spux.PhysicalType.Length,
default_value=sp.Matrix([1, 1, 1]),
),
'Samples/Space': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Integer,
default_value=sp.Matrix([10, 10, 10]),
),

View File

@ -19,8 +19,8 @@ import typing as typ
import bpy
import sympy as sp
from blender_maxwell.utils import bl_cache, logger
from blender_maxwell.utils import extra_sympy_units as spux
from blender_maxwell.utils import logger
from ... import contracts as ct
from ... import sockets
@ -73,33 +73,15 @@ class ViewerNode(base.MaxwellSimNode):
####################
# - Properties
####################
print_kind: bpy.props.EnumProperty(
name='Print Kind',
description='FlowKind of the input socket to print',
items=[(kind, kind.name, kind.name) for kind in list(ct.FlowKind)],
default=ct.FlowKind.Value,
update=lambda self, context: self.on_prop_changed('print_kind', context),
)
auto_plot: bpy.props.BoolProperty(
name='Auto-Plot',
description='Whether to auto-plot anything plugged into the viewer node',
default=False,
update=lambda self, context: self.on_prop_changed('auto_plot', context),
)
auto_3d_preview: bpy.props.BoolProperty(
name='Auto 3D Preview',
description="Whether to auto-preview anything 3D, that's plugged into the viewer node",
default=True,
update=lambda self, context: self.on_prop_changed('auto_3d_preview', context),
)
print_kind: ct.FlowKind = bl_cache.BLField(ct.FlowKind.Value)
auto_plot: bool = bl_cache.BLField(False)
auto_3d_preview: bool = bl_cache.BLField(True)
####################
# - UI
####################
def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout):
layout.prop(self, 'print_kind', text='')
layout.prop(self, self.blfields['print_kind'], text='')
def draw_operators(self, _: bpy.types.Context, layout: bpy.types.UILayout):
split = layout.split(factor=0.4)
@ -118,7 +100,7 @@ class ViewerNode(base.MaxwellSimNode):
## Plot Options
row = col.row(align=True)
row.prop(self, 'auto_plot', text='Plot', toggle=True)
row.prop(self, self.blfields['auto_plot'], text='Plot', toggle=True)
row.operator(
RefreshPlotViewOperator.bl_idname,
text='',
@ -127,7 +109,7 @@ class ViewerNode(base.MaxwellSimNode):
## 3D Preview Options
row = col.row(align=True)
row.prop(self, 'auto_3d_preview', text='3D Preview', toggle=True)
row.prop(self, self.blfields['auto_3d_preview'], text='3D Preview', toggle=True)
####################
# - Methods

View File

@ -144,7 +144,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
####################
# - Computed - Sim
####################
@bl_cache.cached_bl_property(persist=False)
@bl_cache.cached_bl_property()
def sim(self) -> td.Simulation | None:
sim = self._compute_input('Sim')
has_sim = not ct.FlowSignal.check(sim)
@ -153,7 +153,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
return sim
return None
@bl_cache.cached_bl_property(persist=False)
@bl_cache.cached_bl_property()
def total_monitor_data(self) -> float | None:
if self.sim is not None:
return sum(self.sim.monitors_data_size.values())
@ -188,8 +188,9 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
If one can't be loaded, return None.
"""
has_uploaded_task = self.uploaded_task_id != ''
has_new_cloud_task = self.new_cloud_task is not None
if has_uploaded_task:
if has_uploaded_task and has_new_cloud_task:
return tdcloud.TidyCloudTasks.tasks(self.new_cloud_task.cloud_folder).get(
self.uploaded_task_id
)
@ -206,7 +207,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
return tdcloud.TidyCloudTasks.task_info(self.uploaded_task_id)
return None
@bl_cache.cached_bl_property(persist=False)
@bl_cache.cached_bl_property()
def uploaded_est_cost(self) -> float | None:
task_info = self.uploaded_task_info
if task_info is not None:
@ -219,7 +220,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
####################
# - Computed - Combined
####################
@bl_cache.cached_bl_property(persist=False)
@bl_cache.cached_bl_property()
def is_sim_uploadable(self) -> bool:
if (
self.sim is not None

View File

@ -43,14 +43,14 @@ class SimDomainNode(base.MaxwellSimNode):
abs_min=0,
),
'Center': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_unit=spu.micrometer,
default_value=sp.Matrix([0, 0, 0]),
),
'Size': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_unit=spu.micrometer,

View File

@ -54,13 +54,13 @@ class GaussianBeamSourceNode(base.MaxwellSimNode):
input_sockets: typ.ClassVar = {
'Temporal Shape': sockets.MaxwellTemporalShapeSocketDef(),
'Center': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_value=sp.Matrix([0, 0, 0]),
),
'Size': sockets.ExprSocketDef(
shape=(2,),
size=spux.NumberSize1D.Vec2,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_value=sp.Matrix([1, 1]),
@ -77,7 +77,7 @@ class GaussianBeamSourceNode(base.MaxwellSimNode):
abs_min=0.01,
),
'Spherical': sockets.ExprSocketDef(
shape=(2,),
size=spux.NumberSize1D.Vec2,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Angle,
default_value=sp.Matrix([0, 0]),

View File

@ -52,13 +52,13 @@ class PlaneWaveSourceNode(base.MaxwellSimNode):
input_sockets: typ.ClassVar = {
'Temporal Shape': sockets.MaxwellTemporalShapeSocketDef(),
'Center': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_value=sp.Matrix([0, 0, 0]),
),
'Spherical': sockets.ExprSocketDef(
shape=(2,),
size=spux.NumberSize1D.Vec2,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Angle,
default_value=sp.Matrix([0, 0]),

View File

@ -42,7 +42,7 @@ class PointDipoleSourceNode(base.MaxwellSimNode):
input_sockets: typ.ClassVar = {
'Temporal Shape': sockets.MaxwellTemporalShapeSocketDef(),
'Center': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_value=sp.Matrix([0, 0, 0]),

View File

@ -42,7 +42,7 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
'GeoNodes': sockets.BlenderGeoNodesSocketDef(),
'Medium': sockets.MaxwellMediumSocketDef(),
'Center': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_unit=spu.micrometer,

View File

@ -42,14 +42,14 @@ class BoxStructureNode(base.MaxwellSimNode):
input_sockets: typ.ClassVar = {
'Medium': sockets.MaxwellMediumSocketDef(),
'Center': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_unit=spu.micrometer,
default_value=sp.Matrix([0, 0, 0]),
),
'Size': sockets.ExprSocketDef(
shape=(3,),
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_unit=spu.nanometer,

View File

@ -42,14 +42,11 @@ class SphereStructureNode(base.MaxwellSimNode):
input_sockets: typ.ClassVar = {
'Medium': sockets.MaxwellMediumSocketDef(),
'Center': sockets.ExprSocketDef(
shape=(3,),
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
size=spux.NumberSize1D.Vec3,
default_unit=spu.micrometer,
default_value=sp.Matrix([0, 0, 0]),
),
'Radius': sockets.ExprSocketDef(
physical_type=spux.PhysicalType.Length,
default_unit=spu.nanometer,
default_value=150,
),

View File

@ -16,13 +16,11 @@
import abc
import typing as typ
import uuid
from types import MappingProxyType
import bpy
import pydantic as pyd
from blender_maxwell.utils import bl_cache, logger, serialize
from blender_maxwell.utils import bl_cache, bl_instance, logger, serialize
from .. import contracts as ct
@ -60,7 +58,7 @@ class SocketDef(pyd.BaseModel, abc.ABC):
Parameters:
bl_socket: The Blender node socket to alter using data from this SocketDef.
"""
bl_socket.initializing = False
bl_socket.is_initializing = False
bl_socket.on_active_kind_changed()
@abc.abstractmethod
@ -116,9 +114,12 @@ class SocketDef(pyd.BaseModel, abc.ABC):
####################
# - SocketDef
# - Socket
####################
class MaxwellSimSocket(bpy.types.NodeSocket):
MANDATORY_PROPS: set[str] = {'socket_type', 'bl_label'}
class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
"""A specialized Blender socket for nodes in a Maxwell simulation.
Attributes:
@ -128,112 +129,45 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
locked: The lock-state of a particular socket, which determines the socket's user editability
"""
# Fundamentals
# Properties
## Class
socket_type: ct.SocketType
bl_label: str
# Style
display_shape: typ.Literal[
'CIRCLE',
'SQUARE',
'DIAMOND',
'CIRCLE_DOT',
'SQUARE_DOT',
'DIAMOND_DOT',
]
## We use the following conventions for shapes:
## - CIRCLE: Single Value.
## - SQUARE: Container of Value.
## - DIAMOND: Pointer Value.
## - +DOT: Uses Units
socket_color: tuple
# Options
use_prelock: bool = False
use_info_draw: bool = False
# Computed
## Computed by Subclass
bl_idname: str
# BLFields
blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({})
ui_blfields: typ.ClassVar[set[str]] = frozenset()
## Identifying
is_initializing: bool = bl_cache.BLField(True, use_prop_update=False)
active_kind: ct.FlowKind = bl_cache.BLField(ct.FlowKind.Value)
## UI
use_info_draw: bool = bl_cache.BLField(False, use_prop_update=False)
use_prelock: bool = bl_cache.BLField(False, use_prop_update=False)
locked: bool = bl_cache.BLField(False, use_prop_update=False)
use_socket_color: bool = bl_cache.BLField(False, use_prop_update=False)
socket_color: tuple[float, float, float, float] = bl_cache.BLField(
(0, 0, 0, 0), use_prop_update=False
)
####################
# - Initialization
####################
## TODO: Common implementation of this for both sockets and nodes - perhaps a BLInstance base class?
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,
prop_name: str,
prop: bpy.types.Property,
no_update: bool = False,
update_with_name: str | None = None,
**kwargs,
) -> None:
"""Adds a Blender property to a class via `__annotations__`, so it initializes with any subclass.
def __init_subclass__(cls, **kwargs: typ.Any):
"""Initializes socket properties and attributes for use.
Notes:
- Blender properties can't be set within `__init_subclass__` simply by adding attributes to the class; they must be added as type annotations.
- Must be called **within** `__init_subclass__`.
Parameters:
name: The name of the property to set.
prop: The `bpy.types.Property` to instantiate and attach..
no_update: Don't attach a `self.on_prop_changed()` callback to the property's `update`.
Run when initializing any subclass of MaxwellSimSocket.
"""
_update_with_name = prop_name if update_with_name is None else update_with_name
extra_kwargs = (
{
'update': lambda self, context: self.on_prop_changed(
_update_with_name, context
),
}
if not no_update
else {}
)
cls.__annotations__[prop_name] = prop(
**kwargs,
**extra_kwargs,
)
def __init_subclass__(cls, **kwargs: typ.Any):
log.debug('Initializing Socket: %s', cls.socket_type)
super().__init_subclass__(**kwargs)
# cls._assert_attrs_valid()
## TODO: Implement this :)
cls.assert_attrs_valid(MANDATORY_PROPS)
# Socket Properties
## Identifiers
cls.bl_idname: str = str(cls.socket_type.value)
cls.set_prop('instance_id', bpy.props.StringProperty, no_update=True)
cls.set_prop(
'initializing', bpy.props.BoolProperty, default=True, no_update=True
)
## Special States
cls.set_prop('locked', bpy.props.BoolProperty, no_update=True, default=False)
# Setup Style
cls.socket_color = ct.SOCKET_COLORS[cls.socket_type]
# Setup List
cls.set_prop(
'active_kind', bpy.props.StringProperty, default=str(ct.FlowKind.Value)
)
####################
# - Property Event: On Update
@ -244,12 +178,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
Notes:
Called by `self.on_prop_changed()` when `self.active_kind` was changed.
"""
self.display_shape = {
ct.FlowKind.Value: 'CIRCLE',
ct.FlowKind.Array: 'SQUARE',
ct.FlowKind.LazyArrayRange: 'SQUARE',
ct.FlowKind.LazyValueFunc: 'DIAMOND',
}[self.active_kind]
self.display_shape = self.active_kind.socket_shape
def on_socket_prop_changed(self, prop_name: str) -> None:
"""Called when a property has been updated.
@ -264,7 +193,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
prop_name: The name of the property that was changed.
"""
def on_prop_changed(self, prop_name: str, _: bpy.types.Context) -> None:
def on_prop_changed(self, prop_name: str) -> None:
"""Called when a property has been updated.
Contrary to `node.on_prop_changed()`, socket-specific callbacks are baked into this function:
@ -275,17 +204,13 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
prop_name: The name of the property that was changed.
"""
## TODO: Evaluate this properly
if self.initializing:
if self.is_initializing:
log.debug(
'%s: Rejected on_prop_changed("%s") while initializing',
self.bl_label,
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)
# Property Callbacks: Active Kind
if prop_name == 'active_kind':
self.on_active_kind_changed()
@ -469,13 +394,13 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
if event in [ct.FlowEvent.EnableLock, ct.FlowEvent.DisableLock]:
self.locked = event == ct.FlowEvent.EnableLock
# Input Socket | Input Flow
if not self.is_output and flow_direction == 'input':
# Event by Socket Orientation | Flow Direction
match (self.is_output, flow_direction):
case (False, 'input'):
for link in self.links:
link.from_socket.trigger_event(event, socket_kinds=socket_kinds)
# Input Socket | Output Flow
if not self.is_output and flow_direction == 'output':
case (False, 'output'):
if event == ct.FlowEvent.LinkChanged:
self.node.trigger_event(
ct.FlowEvent.DataChanged,
@ -487,14 +412,12 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
event, socket_name=self.name, socket_kinds=socket_kinds
)
# Output Socket | Input Flow
if self.is_output and flow_direction == 'input':
case (True, 'input'):
self.node.trigger_event(
event, socket_name=self.name, socket_kinds=socket_kinds
)
# Output Socket | Output Flow
if self.is_output and flow_direction == 'output':
case (True, 'output'):
for link in self.links:
link.to_socket.trigger_event(event, socket_kinds=socket_kinds)
@ -729,19 +652,24 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
raise NotImplementedError(msg)
####################
# - Theme
# - UI - Color
####################
@classmethod
def draw_color_simple(cls) -> ct.BLColorRGBA:
"""Sets the socket's color to `cls.socket_color`.
def draw_color(
self,
_: bpy.types.Context,
node: bpy.types.Node, # noqa: ARG002
) -> tuple[float, float, float, float]:
"""Draw the socket color depending on context.
When `self.use_socket_color` is set, the property `socket_color` can be used to control the socket color directly.
Otherwise, a default based on `self.socket_type` will be used.
Notes:
Blender calls this method to determine the socket color.
Returns:
A Blender-compatible RGBA value, with no explicit color space.
Called by Blender to call the socket color.
"""
return cls.socket_color
if self.use_socket_color:
return self.socket_color
return ct.SOCKET_COLORS[self.socket_type]
####################
# - UI

View File

@ -18,9 +18,13 @@ from pathlib import Path
import bpy
from blender_maxwell.utils import bl_cache, logger
from ... import contracts as ct
from .. import base
log = logger.get(__name__)
####################
# - Blender Socket
@ -32,30 +36,25 @@ class FilePathBLSocket(base.MaxwellSimSocket):
####################
# - Properties
####################
raw_value: bpy.props.StringProperty(
name='File Path',
description='Represents the path to a file',
subtype='FILE_PATH',
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
raw_value: Path = bl_cache.BLField(Path(), path_type='file')
####################
# - Socket UI
####################
def draw_value(self, col: bpy.types.UILayout) -> None:
col_row = col.row(align=True)
col_row.prop(self, 'raw_value', text='')
# col_row = col.row(align=True)
col.prop(self, self.blfields['raw_value'], text='')
####################
# - Computation of Default Value
# - FlowKind: Value
####################
@property
def value(self) -> Path:
return Path(bpy.path.abspath(self.raw_value))
return self.raw_value
@value.setter
def value(self, value: Path) -> None:
self.raw_value = bpy.path.relpath(str(value))
self.raw_value = value
####################

View File

@ -99,18 +99,16 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
socket_type = ct.SocketType.Tidy3DCloudTask
bl_label = 'Tidy3D Cloud Task'
use_prelock = True
####################
# - Properties
####################
api_key: str = bl_cache.BLField('', prop_ui=True, str_secret=True)
should_exist: bool = bl_cache.BLField(False)
existing_folder_id: enum.Enum = bl_cache.BLField(
existing_folder_id: enum.StrEnum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_cloud_folders()
)
existing_task_id: enum.Enum = bl_cache.BLField(
existing_task_id: enum.StrEnum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_cloud_tasks()
)
@ -299,6 +297,7 @@ class Tidy3DCloudTaskSocketDef(base.SocketDef):
def init(self, bl_socket: Tidy3DCloudTaskBLSocket) -> None:
bl_socket.should_exist = self.should_exist
bl_socket.use_prelock = True
####################

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
# 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/>.
"""Package providing various tools to handle cached data on Blender objects, especially nodes and node socket classes."""
from .bl_field import BLField
from .bl_prop import BLProp, BLPropType
from .cached_bl_property import CachedBLProperty, cached_bl_property
from .keyed_cache import KeyedCache, keyed_cache
from .managed_cache import invalidate_nonpersist_instance_id
from .signal import Signal
__all__ = [
'BLField',
'BLProp',
'BLPropType',
'CachedBLProperty',
'cached_bl_property',
'KeyedCache',
'keyed_cache',
'invalidate_nonpersist_instance_id',
'Signal',
]

View File

@ -0,0 +1,424 @@
# 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/>.
"""Implements various key caches on instances of Blender objects, especially nodes and sockets."""
import functools
import inspect
import typing as typ
import bpy
from blender_maxwell import contracts as ct
from blender_maxwell.utils import bl_instance, logger
from .bl_prop import BLProp
from .bl_prop_type import BLPropType
from .signal import Signal
log = logger.get(__name__)
StringPropSubType: typ.TypeAlias = typ.Literal[
'FILE_PATH', 'DIR_PATH', 'FILE_NAME', 'BYTE_STRING', 'PASSWORD', 'NONE'
]
StrMethod: typ.TypeAlias = typ.Callable[
[bl_instance.BLInstance, bpy.types.Context, str], list[tuple[str, str]]
]
EnumMethod: typ.TypeAlias = typ.Callable[
[bl_instance.BLInstance, bpy.types.Context], list[ct.BLEnumElement]
]
DEFAULT_ENUM_ITEMS_SINGLE = [('NONE', 'None', 'No items...', '', 0)]
DEFAULT_ENUM_ITEMS_MANY = [('NONE', 'None', 'No items...', '', 2**0)]
@functools.cache
def default_enum_items(enum_many: bool) -> list[ct.BLEnumElement]:
return DEFAULT_ENUM_ITEMS_MANY if enum_many else DEFAULT_ENUM_ITEMS_SINGLE
####################
# - BLField
####################
class BLField:
"""A descriptor that allows persisting arbitrary types in Blender objects, with cached reads."""
def __init__(
self,
default_value: typ.Any = None,
use_prop_update: bool = True,
## Static
prop_ui: bool = False, ## TODO: Remove
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,
# blptr_type: typ.Any | None = None, ## A Blender ID type
## TODO: Test/Implement
## Dynamic
str_cb: StrMethod | None = None,
enum_cb: EnumMethod | None = None,
cb_depends_on: set[str] | 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.
use_prop_update: If True, `BLField` will consent to `bl_instance.on_prop_changed(attr_name)` being run whenever the field is changed.
UI changes done to the property via Blender **always** trigger `bl_instance.on_bl_prop_changed`; however, the `BLField` decides whether `on_prop_changed` should be run as well.
That control is offered through `use_prop_update`.
abs_min: Sets the absolute minimum value of the property.
Only meaningful for numerical properties.
abs_max: Sets the absolute maximum value of the property.
Only meaningful for numerical properties.
soft_min: Sets a value which will feel like a minimum in the UI, but which can be overridden by setting a value directly.
In practice, "scrolling" through values will stop here.
Only meaningful for numerical properties.
soft_max: Sets a value which will feel like a maximum in the UI, but which can be overridden by setting a value directly.
In practice, "scrolling" through values will stop here.
Only meaningful for numerical properties.
float_step: Sets the interval (/100) of each step when "scrolling" through the values of a float property, aka. the speed.
Only meaningful for float-like properties.
float_step: Sets the decimal places of precision to display.
Only meaningful for float-like properties.
str_secret: Marks the string as "secret", which prevents its save-persistance, and causes the UI to display dots instead of characters.
**DO NOT** rely on this property for "real" security.
_If in doubt, this isn't good enough._
Only meaningful for `str` properties.
path_type: Makes the path as pointing to a folder or to a file.
Only meaningful for `pathlib.Path` properties.
**NOTE**: No effort is made to make paths portable between operating systems.
Use with care.
str_cb: Method used to determine all valid strings, which presents to the user as a fuzzy-style search dropdown.
Only meaningful for `str` properties.
Results are not persisted, and must therefore re-run when reloading the file.
Otherwise, it is cached, but is re-run whenever `Signal.ResetStrSearch` is set.
enum_cb: Method used to determine all valid enum elements, which presents to the user as a dropdown.
The caveats with dynamic `bpy.props.EnumProperty`s are **absurdly sharp**.
Those caveats are entirely mitigated when using this callback, at the cost of manual resets.
Is re-run whenever `Signal.ResetEnumItems` is set, and otherwise cached both persistently and non-persistently.
cb_depends_on: Declares that `str_cb` / `enum_cb` should be regenerated whenever any of the given property names change.
This allows fully automating the invocation of `Signal.ResetEnumItems` / `Signal.ResetStrSearch` in common cases.
"""
log.debug(
'Initializing BLField (default_value=%s, use_prop_update=%s)',
str(default_value),
str(use_prop_update),
)
self.use_dynamic_enum = enum_cb is not None
self.use_str_search = str_cb is not None
## TODO: Use prop_flags
self.prop_info = {
'default': default_value,
'use_prop_update': use_prop_update,
# Int* | Float*: Bounds
'min': abs_min,
'max': abs_max,
'soft_min': soft_min,
'soft_max': soft_max,
# Float*: UI
'step': float_step,
'precision': float_prec,
# BLPointer: ID Type
#'blptr_type': blptr_type,
# Str | Path | Enum: Flag Setters
'str_secret': str_secret,
'path_type': path_type,
# Search: Str
'str_search': self.use_str_search,
'safe_str_cb': lambda _self, context, edit_text: self.safe_str_cb(
_self, context, edit_text
),
# Search: Enum
'enum_dynamic': self.use_dynamic_enum,
'safe_enum_cb': lambda _self, context: self.safe_enum_cb(_self, context),
}
# BLProp
self.bl_prop: BLProp | None = None
self.bl_prop_enum_items: BLProp | None = None
self.bl_prop_str_search: BLProp | None = None
self.enum_cb = enum_cb
self.str_cb = str_cb
self.cb_depends_on: set[str] | None = cb_depends_on
# Update Suppressing
self.suppress_update: dict[str, bool] = {}
####################
# - Descriptor Setup
####################
def __set_name__(self, owner: type[bl_instance.BLInstance], name: str) -> None:
"""Sets up this descriptor on the class, preparing it for per-instance use.
A `BLProp` is constructed using this descriptor's attribute name on `owner`, and the `self.prop_info` previously created during `self.__init__()`.
Then, a corresponding / underlying `bpy.types.Property` is initialized on `owner` using `self.bl_prop.init_bl_type(owner)`
Notes:
Run by Python when setting an instance of a "descriptor" class, to an attribute of another class (denoted `owner`).
For more, search for "Python descriptor protocol".
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.
"""
prop_type = inspect.get_annotations(owner).get(name)
self.bl_prop = BLProp(
name=name,
prop_info=self.prop_info,
prop_type=prop_type,
bl_prop_type=BLPropType.from_type(prop_type),
)
# Initialize Field on BLClass
self.bl_prop.init_bl_type(
owner,
enum_depends_on=self.cb_depends_on,
strsearch_depends_on=self.cb_depends_on,
)
# Dynamic Enum: Initialize Persistent Enum Items
if self.prop_info['enum_dynamic']:
self.bl_prop_enum_items = BLProp(
name=self.bl_prop.enum_cache_key,
prop_info={'default': [], 'use_prop_update': False},
prop_type=list[ct.BLEnumElement],
bl_prop_type=BLPropType.Serialized,
)
self.bl_prop_enum_items.init_bl_type(owner)
# Searched Str: Initialize Persistent Str List
if self.prop_info['str_search']:
self.bl_prop_str_search = BLProp(
name=self.bl_prop.str_cache_key,
prop_info={'default': [], 'use_prop_update': False},
prop_type=list[str],
bl_prop_type=BLPropType.Serialized,
)
self.bl_prop_str_search.init_bl_type(owner)
def __get__(
self,
bl_instance: bl_instance.BLInstance | None,
owner: type[bl_instance.BLInstance],
) -> typ.Any:
"""Retrieves the value described by the BLField.
Notes:
Run by Python when the attribute described by the descriptor is accessed.
For more, search for "Python descriptor protocol".
Parameters:
bl_instance: Instance that is accessing the attribute.
owner: The class that owns the instance.
"""
# Compute Value (if available)
cached_value = self.bl_prop.read_nonpersist(bl_instance)
if cached_value is Signal.CacheNotReady or cached_value is Signal.CacheEmpty:
if bl_instance is not None:
persisted_value = self.bl_prop.read(bl_instance)
self.bl_prop.write_nonpersist(bl_instance, persisted_value)
return persisted_value
return self.bl_prop.default_value ## TODO: Good idea?
return cached_value
def suppress_next_update(self, bl_instance) -> None:
self.suppress_update[bl_instance.instance_id] = True
## TODO: Make it a context manager to prevent the worst of surprises
def __set__(
self, bl_instance: bl_instance.BLInstance | None, value: typ.Any
) -> None:
"""Sets the value described by the BLField.
In general, any BLField modified in the UI will set `InvalidateCache` on this descriptor.
If `self.prop_info['use_prop_update']` is set, the method `bl_instance.on_prop_changed(self.bl_prop.name)` will then be called and start a `FlowKind.DataChanged` event chain.
Notes:
Run by Python when the attribute described by the descriptor is set.
For more, search for "Python descriptor protocol".
Parameters:
bl_instance: Instance that is accessing the attribute.
owner: The class that owns the instance.
"""
# Perform Update Chain
## -> We still respect 'use_prop_update', since it is user-sourced.
if value is Signal.DoUpdate:
if self.prop_info['use_prop_update']:
bl_instance.on_prop_changed(self.bl_prop.name)
# Invalidate Cache
## -> This empties the non-persistent cache.
## -> As a result, the value must be reloaded from the property.
## The 'on_prop_changed' method on the bl_instance might also be called.
elif value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate:
self.bl_prop.invalidate_nonpersist(bl_instance)
# Update Suppression
if self.suppress_update.get(bl_instance.instance_id):
self.suppress_update[bl_instance.instance_id] = False
# ELSE: Trigger Update Chain
elif self.prop_info['use_prop_update'] and value is Signal.InvalidateCache:
bl_instance.on_prop_changed(self.bl_prop.name)
# Reset Enum Items
elif value is Signal.ResetEnumItems:
# Retrieve Old Items
## -> This is verbatim what is being persisted, currently.
## -> len(0): Manually replaced w/fallback to guarantee >=len(1)
## -> Fallback element is 'NONE'.
_old_items: list[ct.BLEnumElement] = self.bl_prop_enum_items.read(
bl_instance
)
old_items = (
_old_items
if _old_items
else default_enum_items(self.bl_prop.is_enum_many)
)
# Retrieve Current Items
## -> len(0): Manually replaced w/fallback to guarantee >=len(1)
## -> Manually replaced fallback element is 'NONE'.
_current_items: list[ct.BLEnumElement] = self.enum_cb(bl_instance, None)
current_items = (
_current_items
if _current_items
else default_enum_items(self.bl_prop.is_enum_many)
)
# Compare Old | Current
## -> We don't involve non-persistent caches (they lie!)
## -> Since we persist the user callback directly, we can compare.
if old_items != current_items:
# Retrieve Old Enum Item
## -> This is verbatim what is being used.
## -> De-Coerce None -> 'NONE' to avoid special-cased search.
_old_item = self.bl_prop.read(bl_instance)
old_item = 'NONE' if _old_item is None else _old_item
# Swap Enum Items
## -> This is the hot stuff - the enum elements are overwritten.
## -> The safe_enum_cb will pick up on this immediately.
self.suppress_next_update(bl_instance)
self.bl_prop_enum_items.write(bl_instance, current_items)
# Old Item in Current Items
## -> It's possible that the old enum key is in the new enum.
## -> If so, the user will expect it to "remain".
## -> Thus, we set it - Blender sees a change, user doesn't.
## -> DO NOT trigger on_prop_changed (since "nothing changed").
if any(old_item == item[0] for item in current_items):
self.suppress_next_update(bl_instance)
self.bl_prop.write(bl_instance, old_item)
## -> TODO: Don't write if not needed.
# Old Item Not in Current Items
## -> In this case, fallback to the first current item.
## -> DO trigger on_prop_changed (since it changed!)
else:
_first_current_item = current_items[0][0]
first_current_item = (
_first_current_item if _first_current_item != 'NONE' else None
)
self.suppress_next_update(bl_instance)
self.bl_prop.write(bl_instance, first_current_item)
if self.prop_info['use_prop_update']:
bl_instance.on_prop_changed(self.bl_prop.name)
# Reset Str Search
elif value is Signal.ResetStrSearch:
self.bl_prop_str_search.invalidate_nonpersist(bl_instance)
# General __set__
else:
self.bl_prop.write(bl_instance, value)
# Update Semantics
if self.suppress_update.get(bl_instance.instance_id):
self.suppress_update[bl_instance.instance_id] = False
elif self.prop_info['use_prop_update']:
bl_instance.on_prop_changed(self.bl_prop.name)
####################
# - Safe Callbacks
####################
def safe_str_cb(
self, _self: bl_instance.BLInstance, context: bpy.types.Context, edit_text: str
):
"""Wrapper around `StringProperty.search` which keeps a non-persistent cache around search results.
Reset by setting the descriptor to `Signal.ResetStrSearch`.
"""
cached_items = self.bl_prop_str_search.read_nonpersist(_self)
if cached_items is not Signal.CacheNotReady:
if cached_items is Signal.CacheEmpty:
computed_items = self.str_cb(_self, context, edit_text)
self.bl_prop_str_search.write_nonpersist(_self, computed_items)
return computed_items
return cached_items
return []
def safe_enum_cb(
self, _self: bl_instance.BLInstance, context: bpy.types.Context
) -> list[ct.BLEnumElement]:
"""Wrapper around `EnumProperty.items` callback, which **guarantees** that returned strings will not be GCed by keeping a persistent + non-persistent cache.
When a persistent cache exists, then the non-persistent cache will be filled at-will, since this is always guaranteed possible.
Otherwise, the persistent cache will only be regenerated when `Signal.ResetEnumItems` is run.
The original callback won't ever run other than then.
Until then, `DEFAULT_ENUM_ITEMS_MANY` or `DEFAULT_ENUM_ITEMS_SINGLE` will be used as defaults (guaranteed to not dereference so long as the module is loaded).
"""
# Compute Value (if available)
cached_items = self.bl_prop_enum_items.read_nonpersist(_self)
if cached_items is Signal.CacheNotReady or cached_items is Signal.CacheEmpty:
if _self is not None:
persisted_items = self.bl_prop_enum_items.read(_self)
if not persisted_items:
computed_items = self.enum_cb(_self, context)
_items = computed_items
else:
_items = persisted_items
else:
computed_items = self.enum_cb(_self, context)
_items = computed_items
# Fallback for Empty Persisted Items
## -> Use [('NONE', ...)]
## -> This guarantees that the enum items always has >=len(1)
items = _items if _items else default_enum_items(self.bl_prop.is_enum_many)
# Write Items -> Non-Persistent Cache
self.bl_prop_enum_items.write_nonpersist(_self, items)
return items
return cached_items

View File

@ -0,0 +1,235 @@
# 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/>.
"""Defines `BLProp`, a high-level wrapper for interacting with Blender properties."""
import dataclasses
import functools
import typing as typ
from blender_maxwell.utils import bl_instance, logger
from . import managed_cache
from .bl_prop_type import BLPropInfo, BLPropType
from .signal import Signal
log = logger.get(__name__)
####################
# - Blender Property (Abstraction)
####################
@dataclasses.dataclass(kw_only=True, frozen=True)
class BLProp:
"""A high-level wrapper encapsulating access to a Blender property.
Attributes:
name: The name of the Blender property, as one uses it.
prop_info: Specifies the property's particular behavior, including subtype and UI.
prop_type: The type to associate with the property.
Especially relevant for structured deserialization.
bl_prop_type: Identifier encapsulating which Blender property used for data storage, and how.
"""
name: str
prop_info: BLPropInfo ## TODO: Validate / Typing
prop_type: type
bl_prop_type: BLPropType
####################
# - Computed
####################
@functools.cached_property
def bl_name(self):
"""Deduces the actual attribute name at which the Blender property will be available."""
return f'blfield__{self.name}'
@functools.cached_property
def enum_cache_key(self):
"""Deduces an attribute name for use by the persistent cache component of `EnumProperty.items`.
For dynamic enums, a persistent cache is not enough - a non-persistent cache must also be used to guarantee that returned strings will not dereference.
**Letting dynamic enum strings dereference causes Blender to crash**.
Use of a non-persistent cache alone introduces a massive startup burden, as _all_ of the potentially expensive `EnumProperty.items` methods must re-run.
Should any depend on ex. internet connectivity, which is no longer available, elaborate failure modes may trigger.
By using this key, we can persist `items` for re-caching on startup, to reap the benefits of both schemes and make dynamic `EnumProperty` usable in practice.
"""
return self.name + '__enum_cache'
@functools.cached_property
def str_cache_key(self):
"""Deduce an internal name for string-search names distinct from the property name.
Compared to dynamic enums, string-search names are very gentle.
However, the mechanism is otherwise almost same, so similar logic makes a lot of sense.
"""
return self.name + '__str_cache'
@functools.cached_property
def display_name(self):
"""Deduce a display name for the Blender property, assigned to the `name=` attribute."""
return (
'[JSON] ' if self.bl_prop_type == BLPropType.Serialized else ''
) + f'BLField: {self.name}'
@functools.cached_property
def is_enum_many(self):
return self.bl_prop_type in [BLPropType.SetEnum, BLPropType.SetDynEnum]
####################
# - Low-Level Methods
####################
def encode(self, value: typ.Any):
"""Encode a value for compatibility with this Blender property, using the encapsulated types.
A convenience method for `BLPropType.encode()`.
"""
return self.bl_prop_type.encode(value)
@functools.cached_property
def default_value(self) -> typ.Any:
return self.prop_info.get('default')
def decode(self, value: typ.Any):
"""Encode a value for compatibility with this Blender property, using the encapsulated types.
A convenience method for `BLPropType.decode()`.
"""
return self.bl_prop_type.decode(value, self.prop_type)
####################
# - Initialization
####################
def init_bl_type(
self,
bl_type: type[bl_instance.BLInstance],
depends_on: frozenset[str] = frozenset(),
enum_depends_on: frozenset[str] | None = None,
strsearch_depends_on: frozenset[str] | None = None,
) -> None:
"""Declare the Blender property on a Blender class, ensuring that the property will be available to all `bl_instance.BLInstance` respecting instances of that class.
- **Declare BLField**: Runs `bl_type.declare_blfield()` to ensure that `on_prop_changed` will invalidate the cache for this property.
- **Set Property**: Runs `bl_type.set_prop()` to ensure that the Blender property will be available on instances of `bl_type`.
Parameters:
obj_type: The exact object type that will be stored in the Blender property.
**Must** be chosen such that `BLPropType.from_type(obj_type) == self`.
"""
# Parse KWArgs for Blender Property
kwargs_prop = self.bl_prop_type.parse_kwargs(
self.prop_type,
self.prop_info,
)
# Set Blender Property
bl_type.declare_blfield(
self.name,
self.bl_name,
use_dynamic_enum=self.prop_info.get('enum_dynamic', False),
use_str_search=self.prop_info.get('str_search', False),
)
bl_type.set_prop(
self.bl_name,
self.bl_prop_type.bl_prop,
# Property Options
name=self.display_name,
**kwargs_prop,
) ## TODO: Parse __doc__ for property descs
for src_prop_name in depends_on:
bl_type.declare_blfield_dep(src_prop_name, self.name)
if self.prop_info.get('enum_dynamic', False) and enum_depends_on is not None:
for src_prop_name in enum_depends_on:
bl_type.declare_blfield_dep(
src_prop_name, self.name, method='reset_enum'
)
if self.prop_info.get('str_search', False) and strsearch_depends_on is not None:
for src_prop_name in strsearch_depends_on:
bl_type.declare_blfield_dep(
src_prop_name, self.name, method='reset_strsearch'
)
####################
# - Instance Methods
####################
def read_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> typ.Any:
"""Read the non-persistent cache value for this property.
Returns:
Generally, the cache value, with two exceptions.
- `Signal.CacheNotReady`: When either `bl_instance` is None, or it doesn't yet have a unique `bl_instance.instance_id`.
Indicates that the instance is not yet ready for use.
For nodes, `init()` has not yet run.
For sockets, `preinit()` has not yet run.
- `Signal.CacheEmpty`: When the cache has no entry.
A good idea might be to fill it immediately with `self.write_nonpersist(bl_instance)`.
"""
return managed_cache.read(
bl_instance,
self.bl_name,
use_nonpersist=True,
use_persist=False,
)
def read(self, bl_instance: bl_instance.BLInstance) -> typ.Any:
"""Read the Blender property's particular value on the given `bl_instance`."""
persisted_value = self.decode(
managed_cache.read(
bl_instance,
self.bl_name,
use_nonpersist=False,
use_persist=True,
)
)
if persisted_value is not Signal.CacheEmpty:
return persisted_value
msg = f"{self.name}: Can't read BLProp from instance {bl_instance}"
raise ValueError(msg)
def write(self, bl_instance: bl_instance.BLInstance, value: typ.Any) -> None:
managed_cache.write(
bl_instance,
self.bl_name,
self.encode(value),
use_nonpersist=False,
use_persist=True,
)
self.write_nonpersist(bl_instance, value)
def write_nonpersist(
self, bl_instance: bl_instance.BLInstance, value: typ.Any
) -> None:
managed_cache.write(
bl_instance,
self.bl_name,
value,
use_nonpersist=True,
use_persist=False,
)
def invalidate_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> None:
managed_cache.invalidate_nonpersist(
bl_instance,
self.bl_name,
)

View File

@ -0,0 +1,755 @@
# 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/>.
"""Defines `BLPropType`, which provides stronger lower-level interfaces for interacting with data that can be conformed to work with Blender properties."""
import builtins
import enum
import functools
import inspect
import pathlib
import typing as typ
from pathlib import Path
import bpy
import numpy as np
from blender_maxwell import contracts as ct
from blender_maxwell.utils import logger, serialize
from blender_maxwell.utils.staticproperty import staticproperty
from .signal import Signal
log = logger.get(__name__)
####################
# - Types
####################
BLIDStructs = typ.get_args(ct.BLIDStruct)
Shape: typ.TypeAlias = None | tuple[int, ...]
BLPropInfo: typ.TypeAlias = dict[str, typ.Any]
@functools.cache
def _parse_vector_size(obj_type: type[tuple[int, ...]]) -> int:
"""Parse the size of an arbitrarily sized generic tuple type, which is representing a vector.
Parameters:
obj_type: The type of a flat, generic tuple integer, representing a static vector shape.
Returns:
The length of any object that has the given type.
"""
return len(typ.get_args(obj_type))
@functools.cache
def _parse_matrix_size(obj_type: type[tuple[int, ...], ...]) -> tuple[int, int]:
"""Parse the rows and columns of an arbitrarily sized generic tuple-of-tuple type, which is representing a row-major matrix.
Parameters:
obj_type: The type of a singly-nested, generic tuple integer, representing a static matrix shape.
Returns:
The rows and columns of any object that has the given type.
"""
rows = len(typ.get_args(obj_type))
cols = len(typ.get_args(typ.get_args(obj_type)[0]))
for i, col_generic in enumerate(typ.get_args(obj_type)):
col_els = typ.get_args(col_generic)
if len(col_els) != cols:
msg = f'Value {obj_type} has mismatching column length {i} (to column 0)'
raise ValueError(msg)
return (rows, cols)
def _is_strenum(T: type) -> bool: # noqa: N803
return inspect.isclass(T) and issubclass(T, enum.StrEnum)
####################
# - Blender Property Type
####################
class BLPropType(enum.StrEnum):
"""A type identifier which can be directly associated with a Blender property.
For general use, the higher-level interface `BLProp` is more appropriate.
This is a low-level interface to Blender properties, allowing for directly identifying and utilizing a subset of types that are trivially representable using Blender's property system.
This hard-coded approach is especially required when managing the nuances of UI methods.
`BLPropType` should generally be treated as a "dumb" enum identifying the low-level representation of an object in a Blender property.
Use of `BLPropType.from_type` is encouraged; use of other methods is generally discouraged outside of higher-level encapsulating interfaces.
Attributes:
Bool: A boolean.
Int: An integer.
Float: A floating point number.
BoolVector: Between 2 and 32 booleans.
IntVector: Between 2 and 32 integers.
FloatVector: Between 2 and 32 floats.
BoolVector: 2D booleans of 2 - 32 elements per axis.
IntVector: 2D integers of 2 - 32 elements per axis.
FloatVector: 2D floats of 2 - 32 elements per axis.
Str: A simple string.
Path: A particular filesystem path.
SingleEnum: A single string value from a statically known `StrEnum`.
SetEnum: A set of string values, each from a statically known `StrEnum`.
SingleDynEnum: A single string value from a dynamically computed set of string values.
SetDynEnum: A set of string value, each from a dynamically computed set of string values.
BLPointer: A reference to a Blender object.
Blender manages correctly reconstructing this reference on startup, and the underlying pointer value is not stable.
Serialized: An arbitrary, serialized representation of an object.
"""
# Scalar
Bool = enum.auto()
Int = enum.auto()
Float = enum.auto()
## TODO: Support complex
# Vector
BoolVector = enum.auto()
IntVector = enum.auto()
FloatVector = enum.auto()
# Matrix
BoolMatrix = enum.auto()
IntMatrix = enum.auto()
FloatMatrix = enum.auto()
## TODO: Support jaxtyping JAX arrays (as serialized) directly?
# String
Str = enum.auto()
Path = enum.auto()
## TODO: OS checks for Path
# Enums
SingleEnum = enum.auto()
SetEnum = enum.auto()
SingleDynEnum = enum.auto()
SetDynEnum = enum.auto()
# Special
BLPointer = enum.auto()
Serialized = enum.auto()
####################
# - Static
####################
@staticproperty
def vector_types() -> frozenset[typ.Self]:
"""The set of `BLPropType`s that are considered "vectors"."""
BPT = BLPropType
return frozenset({BPT.BoolVector, BPT.IntVector, BPT.FloatVector})
@staticproperty
def matrix_types() -> frozenset[typ.Self]:
"""The set of `BLPropType`s that are considered "matrices"."""
BPT = BLPropType
return frozenset({BPT.BoolMatrix, BPT.IntMatrix, BPT.FloatMatrix})
####################
# - Computed
####################
@functools.cached_property
def is_vector(self) -> bool:
"""Checks whether this `BLPropType` is considered a vector.
Returns:
A boolean indicating "vectorness".
"""
return self in BLPropType.vector_types
@functools.cached_property
def is_matrix(self) -> bool:
"""Checks whether this `BLPropType` is considered a matrix.
Returns:
A boolean indicating "matrixness".
"""
return self in BLPropType.matrix_types
@functools.cached_property
def bl_prop(self) -> bpy.types.Property:
"""Deduce which `bpy.props.*` type should implement this `BLPropType` in practice.
In practice, `self.parse_kwargs()` collects arguments usable by the type returned by this property.
Thus, this property provides the key bridge between `BLPropType` and vanilla Blender properties.
Returns:
A Blender property type, for use as a constructor.
"""
BPT = BLPropType
return {
# Scalar
BPT.Bool: bpy.props.BoolProperty,
BPT.Int: bpy.props.IntProperty,
BPT.Float: bpy.props.FloatProperty,
# Vector
BPT.BoolVector: bpy.props.BoolVectorProperty,
BPT.IntVector: bpy.props.IntVectorProperty,
BPT.FloatVector: bpy.props.FloatVectorProperty,
# Matrix
BPT.BoolMatrix: bpy.props.BoolVectorProperty,
BPT.IntMatrix: bpy.props.IntVectorProperty,
BPT.FloatMatrix: bpy.props.FloatVectorProperty,
# String
BPT.Str: bpy.props.StringProperty,
BPT.Path: bpy.props.StringProperty,
# Enum
BPT.SingleEnum: bpy.props.EnumProperty,
BPT.SetEnum: bpy.props.EnumProperty,
BPT.SingleDynEnum: bpy.props.EnumProperty,
BPT.SetDynEnum: bpy.props.EnumProperty,
# Special
BPT.BLPointer: bpy.props.PointerProperty,
BPT.Serialized: bpy.props.StringProperty,
}[self]
@functools.cached_property
def primitive_type(self) -> type:
"""The "primitive" type representable using this property.
Generally, "primitive" types are Python standard library types.
However, exceptions may exist for a ubiquitously used type.
Returns:
A type guaranteed to be representable as a Blender property via. `self.encode()`.
Note that any relevant constraints on the type are not taken into account in this type.
For example, `SingleEnum` has `str`, even though all strings are not valid.
Similarly for ex. non-negative integers simply returning `int`.
"""
BPT = BLPropType
return {
# Scalar
BPT.Bool: bool,
BPT.Int: int,
BPT.Float: float,
# Vector
BPT.BoolVector: bool,
BPT.IntVector: int,
BPT.FloatVector: float,
# Matrix
BPT.BoolMatrix: bool,
BPT.IntMatrix: int,
BPT.FloatMatrix: float,
# String
BPT.Str: str,
BPT.Path: Path,
# Enum
BPT.SingleEnum: str,
BPT.SetEnum: set[str],
BPT.SingleDynEnum: str,
BPT.SetDynEnum: set[str],
# Special
BPT.BLPointer: None,
BPT.Serialized: str,
}[self]
####################
# - Parser Methods
####################
def parse_size(self, obj_type: type) -> Shape:
"""Retrieve the shape / shape of data associated with this `BLPropType`.
Returns:
Vectors have `(size,)`.
Matrices have `(rows, cols)`.
Otherwise, `None` indicates a single value/scalar.
"""
BPT = BLPropType
match self:
case BPT.BoolVector | BPT.IntVector | BPT.FloatVector:
return _parse_vector_size(obj_type)
case BPT.BoolMatrix | BPT.IntMatrix | BPT.FloatMatrix:
return _parse_matrix_size(obj_type)
case _:
return None
####################
# - KWArg Parsers
####################
@functools.cached_property
def required_info(self) -> list[str]:
"""Retrieve a list of required keyword arguments to the constructor returned by `self.bl_prop`.
Mainly useful via `self.check_info()`.
Returns:
A list of required keys for the Blender property constructor.
"""
BPT = BLPropType
return {
# Scalar
BPT.Bool: ['default'],
BPT.Int: ['default'],
BPT.Float: ['default'],
# Vector
BPT.BoolVector: ['default'],
BPT.IntVector: ['default'],
BPT.FloatVector: ['default'],
# Matrix
BPT.BoolMatrix: ['default'],
BPT.IntMatrix: ['default'],
BPT.FloatMatrix: ['default'],
# String
BPT.Str: ['default', 'str_search'],
BPT.Path: ['default', 'path_type'],
# Enum
BPT.SingleEnum: ['default'],
BPT.SetEnum: ['default'],
BPT.SingleDynEnum: ['enum_dynamic'],
BPT.SetDynEnum: ['enum_dynamic'],
# Special
BPT.BLPointer: ['blptr_type'],
BPT.Serialized: [],
}[self]
def check_info(self, prop_info: BLPropInfo) -> bool:
"""Validate that a dictionary contains all required entries needed when creating a Blender property.
Returns:
True if the provided dictionary is guaranteed to result in a valid Blender property when used as keyword arguments in the `self.bl_prop` constructor.
"""
return all(
required_info_key in prop_info for required_info_key in self.required_info
)
def parse_kwargs( # noqa: PLR0915, PLR0912, C901
self,
obj_type: type,
prop_info: BLPropInfo,
) -> BLPropInfo:
"""Parse the kwargs dictionary used to construct the Blender property.
Parameters:
obj_type: The exact object type that will be stored in the Blender property.
**Generally** should be chosen such that `BLPropType.from_type(obj_type) == self`.
prop_info: The property info.
**Must** contain keys such that `required_info`
Returns:
Keyword arguments, which can be passed directly as to `self.bl_type` to construct a Blender property according to the `prop_info`.
In total, creating a Blender property can be done simply using `self.bl_type(**parse_kwargs(...))`.
"""
BPT = BLPropType
# Check Availability of Required Information
## -> All required fields must be defined.
if not self.check_info(prop_info):
msg = f'{self} ({obj_type}): Required property attribute is missing from prop_info="{prop_info}"'
raise ValueError(msg)
# Define Information -> KWArg Getter
def g_kwarg(name: str, force_key: str | None = None):
key = force_key if force_key is not None else name
return {key: prop_info[name]} if prop_info.get(name) is not None else {}
# Encode Default Value
if prop_info.get('default', Signal.CacheEmpty) is not Signal.CacheEmpty:
encoded_default = {'default': self.encode(prop_info.get('default'))}
else:
encoded_default = {}
# Assemble KWArgs
kwargs = {}
match self:
case BPT.Bool if obj_type is bool:
kwargs |= encoded_default
case BPT.Int | BPT.IntVector | BPT.IntMatrix:
kwargs |= encoded_default
kwargs |= g_kwarg('abs_min')
kwargs |= g_kwarg('abs_max')
kwargs |= g_kwarg('soft_min')
kwargs |= g_kwarg('soft_max')
case BPT.Float | BPT.FloatVector | BPT.FloatMatrix:
kwargs |= encoded_default
kwargs |= g_kwarg('abs_min')
kwargs |= g_kwarg('abs_max')
kwargs |= g_kwarg('soft_min')
kwargs |= g_kwarg('soft_max')
kwargs |= g_kwarg('step')
kwargs |= g_kwarg('precision')
case BPT.Str if obj_type is str:
kwargs |= encoded_default
# Str: Secret
if prop_info.get('str_secret'):
kwargs |= {'subtype': 'PASSWORD', 'options': {'SKIP_SAVE'}}
# Str: Search
if prop_info.get('str_search'):
kwargs |= g_kwarg('safe_str_cb', force_key='search')
case BPT.Path if obj_type is Path:
kwargs |= encoded_default
# Path: File/Dir
if prop_info.get('path_type'):
kwargs |= {
'subtype': (
'FILE_PATH'
if prop_info['path_type'] == 'file'
else 'DIR_PATH'
)
}
# Explicit Enums
case BPT.SingleEnum:
SubStrEnum = obj_type
# Static | Dynamic Enum
## -> Dynamic enums are responsible for respecting type.
if prop_info.get('enum_dynamic'):
kwargs |= g_kwarg('safe_enum_cb', force_key='items')
else:
kwargs |= encoded_default
kwargs |= {
'items': [
## TODO: Parse __doc__ for item descs
(
str(value),
SubStrEnum.to_name(value),
SubStrEnum.to_name(value),
SubStrEnum.to_icon(value),
i,
)
for i, value in enumerate(list(obj_type))
]
}
case BPT.SetEnum:
SubStrEnum = typ.get_args(obj_type)[0]
# Enum Set: Use ENUM_FLAG option.
kwargs |= {'options': {'ENUM_FLAG'}}
# Static | Dynamic Enum
## -> Dynamic enums are responsible for respecting type.
if prop_info.get('enum_dynamic'):
kwargs |= g_kwarg('safe_enum_cb', force_key='items')
else:
kwargs |= encoded_default
kwargs |= {
'items': [
## TODO: Parse __doc__ for item descs
(
str(value),
SubStrEnum.to_name(value),
SubStrEnum.to_name(value),
SubStrEnum.to_icon(value),
2**i,
)
for i, value in enumerate(list(SubStrEnum))
]
}
# Anonymous Enums
case BPT.SingleDynEnum:
kwargs |= g_kwarg('safe_enum_cb', force_key='items')
case BPT.SetDynEnum:
kwargs |= g_kwarg('safe_enum_cb', force_key='items')
# Enum Set: Use ENUM_FLAG option.
kwargs |= {'options': {'ENUM_FLAG'}}
# BLPointer
case BPT.BLPointer:
kwargs |= encoded_default
# BLPointer: ID Type
kwargs |= g_kwarg('blptr_type', force_key='type')
# BLPointer
case BPT.Serialized:
kwargs |= encoded_default
# Match Size
## -> Matrices have inverted order to mitigate the Matrix Display Bug.
size = self.parse_size(obj_type)
if size is not None:
if self in [BPT.BoolVector, BPT.IntVector, BPT.FloatVector]:
kwargs |= {'size': size}
if self in [BPT.BoolMatrix, BPT.IntMatrix, BPT.FloatMatrix]:
kwargs |= {'size': size[::-1]}
return kwargs
####################
# - Encode Value
####################
def encode(self, value: typ.Any) -> typ.Any: # noqa: PLR0911
"""Transform a value to a form that can be directly written to a Blender property.
Parameters:
value: A value which should be transformed into a form that can be written to the Blender property returned by `self.bl_type`.
Returns:
A value that can be written directly to the Blender property returned by `self.bl_type`.
"""
BPT = BLPropType
match self:
# Scalars: Coerce Losslessly
## -> We choose to be very strict, except for float.is_integer() -> int
case BPT.Bool if isinstance(value, bool):
return value
case BPT.Int if isinstance(value, int):
return value
case BPT.Int if isinstance(value, float) and value.is_integer():
return int(value)
case BPT.Float if isinstance(value, int | float):
return float(value)
# Vectors | Matrices: list()
## -> We could use tuple(), but list() works just as fine when writing.
## -> Later, we read back as tuple() to respect the type annotation.
## -> Part of the workaround for the Matrix Display Bug happens here.
case BPT.BoolVector | BPT.IntVector | BPT.FloatVector:
return list(value)
case BPT.BoolMatrix | BPT.IntMatrix | BPT.FloatMatrix:
rows = len(value)
cols = len(value[0])
return (
np.array(value, dtype=self.primitive_type)
.flatten()
.reshape([cols, rows])
).tolist()
# String
## -> NOTE: This will happily encode StrEnums->str if an enum isn't requested.
case BPT.Str if isinstance(value, str):
return value
# Path: Use Absolute-Resolved Stringified Path
## -> TODO: Watch out for OS-dependence.
case BPT.Path if isinstance(value, Path):
return str(value.resolve())
# Empty Enums
## -> Coerce None to 'NONE', since 'NONE' is injected by convention.
case (
BPT.SingleEnum
| BPT.SetEnum
| BPT.SingleDynEnum
| BPT.SetDynEnum
) if value is None:
return 'NONE'
# Single Enum: Coerce to str
## -> isinstance(StrEnum.Entry, str) always returns True; thus, a good sanity check.
## -> Explicit/Dynamic both encode to str; only decode() coersion differentiates.
case BPT.SingleEnum | BPT.SingleDynEnum if isinstance(value, str):
return str(value)
# Single Enum: Coerce to set[str]
case BPT.SetEnum | BPT.SetDynEnum if isinstance(value, set):
return {str(v) for v in value}
# BLPointer: Don't Alter
case BPT.BLPointer if value in BLIDStructs or value is None:
return value
# Serialized: Serialize To UTF-8
## -> TODO: Check serializability
case BPT.Serialized:
return serialize.encode(value).decode('utf-8')
msg = f'{self}: No encoder defined for argument {value}'
raise NotImplementedError(msg)
####################
# - Decode Value
####################
def decode(self, raw_value: typ.Any, obj_type: type) -> typ.Any: # noqa: PLR0911
"""Transform a raw value from a form read directly from the Blender property returned by `self.bl_type`, to its intended value of approximate type `obj_type`.
Notes:
`obj_type` is only a hint - for example, `obj_type = enum.StrEnum` is an indicator for a dynamic enum.
Its purpose is to guide ex. sizing and `StrEnum` coersion, not to guarantee a particular output type.
Parameters:
value: A value which should be transformed into a form that can be written to the Blender property returned by `self.bl_type`.
Returns:
A value that can be written directly to the Blender property returned by `self.bl_type`.
"""
BPT = BLPropType
match self:
# Scalars: Inverse Coerce (~Losslessly)
## -> We choose to be very strict, except for float.is_integer() -> int
case BPT.Bool if isinstance(raw_value, bool):
return raw_value
case BPT.Int if isinstance(raw_value, int):
return raw_value
case BPT.Int if isinstance(raw_value, float) and raw_value.is_integer():
return int(raw_value)
case BPT.Float if isinstance(raw_value, float):
return float(raw_value)
# Vectors | Matrices: tuple() to match declared type annotation.
## -> Part of the workaround for the Matrix Display Bug happens here.
case BPT.BoolVector | BPT.IntVector | BPT.FloatVector:
return tuple(raw_value)
case BPT.BoolMatrix | BPT.IntMatrix | BPT.FloatMatrix:
rows, cols = self.parse_size(obj_type)
return tuple(
map(tuple, np.array(raw_value).flatten().reshape([rows, cols]))
)
# String
## -> NOTE: This will happily decode StrEnums->str if an enum isn't requested.
case BPT.Str if isinstance(raw_value, str):
return raw_value
# Path: Use 'Path(abspath(*))'
## -> TODO: Watch out for OS-dependence.
case BPT.Path if isinstance(raw_value, str):
return Path(bpy.path.abspath(raw_value))
# Empty Enums
## -> Coerce 'NONE' to None, since 'NONE' is injected by convention.
## -> Using coerced 'NONE' as guaranteed len=0 element is extremely helpful.
case (
BPT.SingleEnum
| BPT.SetEnum
| BPT.SingleDynEnum
| BPT.SetDynEnum
) if raw_value in ['NONE']:
return None
# Explicit Enum: Coerce to predefined StrEnum
## -> This happens independent of whether there's a enum_cb.
case BPT.SingleEnum if isinstance(raw_value, str):
return obj_type(raw_value)
case BPT.SetEnum if isinstance(raw_value, set):
return {obj_type(v) for v in raw_value}
## Dynamic Enums: Nothing to coerce to.
## -> The critical distinction is that dynamic enums can't be coerced beyond str.
## -> All dynamic enums have an enum_cb, but this is merely a symptom of ^.
case BPT.SingleDynEnum if isinstance(raw_value, str):
return raw_value
case BPT.SetDynEnum if isinstance(raw_value, set):
return raw_value
# BLPointer
## -> None is always valid when it comes to BLPointers.
case BPT.BLPointer if raw_value in BLIDStructs or raw_value is None:
return raw_value
# Serialized: Deserialize the Argument
case BPT.Serialized:
return serialize.decode(obj_type, raw_value)
msg = f'{self}: No decoder defined for argument {raw_value}'
raise NotImplementedError(msg)
####################
# - Parse Type
####################
@staticmethod
def from_type(obj_type: type) -> typ.Self: # noqa: PLR0911, PLR0912, C901
"""Select an appropriate `BLPropType` to store objects of the given type.
Use of this method is especially handy when attempting to represent arbitrary, type-annotated objects using Blender properties.
For example, the ability of the `BLPropType` to be displayed in a UI is prioritized as much as possible in making this decision.
Parameters:
obj_type: A type like `bool`, `str`, or custom classes.
Returns:
A `BLPropType` capable of storing any object of `obj_type`.
"""
BPT = BLPropType
# Match Simple
match obj_type:
case builtins.bool:
return BPT.Bool
case builtins.int:
return BPT.Int
case builtins.float:
return BPT.Float
case builtins.str:
return BPT.Str
case pathlib.Path:
return BPT.Path
case enum.StrEnum:
return BPT.SingleDynEnum
case _:
pass
# Match Arrays
## -> This deconstructs generic statements like ex. tuple[int, int]
typ_origin = typ.get_origin(obj_type)
typ_args = typ.get_args(obj_type)
if typ_origin is tuple and len(typ_args) > 0:
# Match Vectors
## -> ONLY respect homogeneous types
if all(T is bool for T in typ_args):
return BPT.BoolVector
if all(T is int for T in typ_args):
return BPT.IntVector
if all(T is float for T in typ_args):
return BPT.FloatVector
# Match Matrices
## -> ONLY respect twice-nested homogeneous types
## -> TODO: Explicitly require regularized shape, as in _parse_matrix_size
typ_args_args = [typ_arg for T0 in typ_args for typ_arg in typ.get_args(T0)]
if typ_args_args:
if all(T is bool for T in typ_args_args):
return BPT.BoolMatrix
if all(T is int for T in typ_args_args):
return BPT.IntMatrix
if all(T is float for T in typ_args_args):
return BPT.FloatMatrix
# Match SetDynEnum
## -> We can't do this in the match statement
if obj_type == set[enum.StrEnum]:
return BPT.SetDynEnum
# Match Static Enums
## -> Match Single w/Helper Function
if _is_strenum(obj_type):
return BPT.SingleEnum
## -> Match Set w/Helper Function
if typ_origin is set and len(typ_args) == 1 and _is_strenum(typ_args[0]):
return BPT.SetEnum
# Match BLPointers
if obj_type in BLIDStructs:
return BPT.BLPointer
# Fallback: Serializable Object
## -> TODO: Check serializability.
return BPT.Serialized

View File

@ -0,0 +1,246 @@
# 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/>.
"""Implements various key caches on instances of Blender objects, especially nodes and sockets."""
import inspect
import typing as typ
from blender_maxwell.utils import bl_instance, logger, serialize
from .bl_prop import BLProp
from .bl_prop_type import BLPropType
from .signal import Signal
log = logger.get(__name__)
####################
# - Types
####################
PropGetMethod: typ.TypeAlias = typ.Callable[
[bl_instance.BLInstance], serialize.NaivelyEncodableType
]
PropSetMethod: typ.TypeAlias = typ.Callable[
[bl_instance.BLInstance, serialize.NaivelyEncodableType], None
]
####################
# - CachedBLProperty
####################
class CachedBLProperty:
"""A descriptor that caches a computed attribute of a Blender node/socket/... instance (`bl_instance`).
Generally used via the associated decorator, `cached_bl_property`.
Notes:
It's like `@cached_property`, but on each Blender Instance ID.
Attributes:
getter_method: Method of `bl_instance` that computes the value.
setter_method: Method of `bl_instance` that sets the value.
"""
def __init__(
self,
getter_method: PropGetMethod,
persist: bool = False,
depends_on: frozenset[str] = frozenset(),
):
"""Initialize the getter of the cached property.
Parameters:
getter_method: Method of `bl_instance` that computes the value.
"""
self.getter_method: PropGetMethod = getter_method
self.setter_method: PropSetMethod | None = None
self.persist: bool = persist
self.depends_on: frozenset[str] = depends_on
self.bl_prop: BLProp | None = None
self.decode_type: type = inspect.signature(getter_method).return_annotation
# Check Non-Empty Type Annotation
## For now, just presume that all types can be encoded/decoded.
if self.decode_type is inspect.Signature.empty:
msg = f'A CachedBLProperty was instantiated, but its getter method "{self.getter_method}" has no return type annotation'
raise TypeError(msg)
def __set_name__(self, owner: type[bl_instance.BLInstance], name: str) -> None:
"""Generates the property name from the name of the attribute that this descriptor is assigned to.
Notes:
- Run by Python when setting an instance of this class to an attribute.
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.
"""
self.bl_prop = BLProp(
name=name,
prop_info={'use_prop_update': True},
prop_type=self.decode_type,
bl_prop_type=BLPropType.Serialized,
)
self.bl_prop.init_bl_type(owner, depends_on=self.depends_on)
def __get__(
self,
bl_instance: bl_instance.BLInstance | None,
owner: type[bl_instance.BLInstance],
) -> typ.Any:
"""Retrieves the property from a cache, or computes it and fills the cache(s).
Parameters:
bl_instance: The Blender object this prop
"""
cached_value = self.bl_prop.read_nonpersist(bl_instance)
if cached_value is Signal.CacheNotReady or cached_value is Signal.CacheEmpty:
if bl_instance is not None:
if self.persist:
value = self.bl_prop.read(bl_instance)
else:
value = self.getter_method(bl_instance)
self.bl_prop.write_nonpersist(bl_instance, value)
return value
return Signal.CacheNotReady
return cached_value
def __set__(
self, bl_instance: bl_instance.BLInstance | None, value: typ.Any
) -> None:
"""Runs the user-provided setter, after invalidating the caches.
Notes:
- This invalidates all caches without re-filling them.
- The caches will be re-filled on the first `__get__` invocation, which may be slow due to having to run the getter method.
Parameters:
bl_instance: The Blender object this prop
"""
if value is Signal.DoUpdate:
bl_instance.on_prop_changed(self.bl_prop.name)
elif value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate:
# Invalidate Partner Non-Persistent Caches
## -> Only for the invalidation case do we also invalidate partners.
if bl_instance is not None:
# Fill Caches
## -> persist: Fill Persist and Non-Persist Cache
## -> else: Fill Non-Persist Cache
if self.persist:
self.bl_prop.write(bl_instance, self.getter_method(bl_instance))
else:
self.bl_prop.write_nonpersist(
bl_instance, self.getter_method(bl_instance)
)
if value == Signal.InvalidateCache:
bl_instance.on_prop_changed(self.bl_prop.name)
elif self.setter_method is not None:
# Run Setter
## -> The user-provided setter should do any updating of partners.
if self.setter_method is not None:
self.setter_method(bl_instance, value)
# Fill Non-Persistant (and maybe Persistent) Cache
if self.persist:
self.bl_prop.write(bl_instance, self.getter_method(bl_instance))
else:
self.bl_prop.write_nonpersist(
bl_instance, self.getter_method(bl_instance)
)
bl_instance.on_prop_changed(self.bl_prop.name)
else:
msg = f'Tried to set "{value}" to "{self.prop_name}" on "{bl_instance.bl_label}", but a setter was not defined'
raise NotImplementedError(msg)
def setter(self, setter_method: PropSetMethod) -> typ.Self:
"""Decorator to add a setter to the cached property.
Returns:
The same descriptor, so that use of the same method name for defining a setter won't change the semantics of the attribute.
Examples:
Without the decorator, it looks like this:
```python
class Test(bpy.types.Node):
bl_label = 'Default'
...
def method(self) -> str: return self.bl_label
attr = CachedBLProperty(getter_method=method)
@attr.setter
def attr(self, value: str) -> None:
self.bl_label = 'Altered'
```
"""
# Validate Setter Signature
setter_sig = inspect.signature(setter_method)
## Parameter Length
if (sig_len := len(setter_sig.parameters)) != 2: # noqa: PLR2004
msg = f'Setter method for "{self.prop_name}" should have 2 parameters, not "{sig_len}"'
raise TypeError(msg)
## Parameter Value Type
if (sig_ret_type := setter_sig.return_annotation) is not None:
msg = f'Setter method for "{self.prop_name}" return value type "{sig_ret_type}", but it should be "None" (omitting an annotation does not imply "None")'
raise TypeError(msg)
self.setter_method = setter_method
return self
####################
# - Decorator
####################
def cached_bl_property(
persist: bool = False,
depends_on: frozenset[str] = frozenset(),
):
"""Decorator creating a descriptor that caches a computed attribute of a Blender node/socket.
Many such `bl_instance`s rely on fast access to computed, cached properties, for example to ensure that `draw()` remains effectively non-blocking.
Notes:
- Unfortunately, `functools.cached_property` doesn't work.
- Use `cached_attribute` if not using a node/socket.
Examples:
```python
class CustomNode(bpy.types.Node):
@bl_cache.cached()
def computed_prop(self) -> ...: return ...
print(bl_instance.prop) ## Computes first time
print(bl_instance.prop) ## Cached (after restart, will recompute)
```
"""
def decorator(getter_method: typ.Callable[[bl_instance.BLInstance], None]) -> type:
return CachedBLProperty(
getter_method=getter_method, persist=persist, depends_on=depends_on
)
return decorator

View File

@ -0,0 +1,146 @@
# 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 functools
import inspect
import typing as typ
from blender_maxwell.utils import bl_instance, logger, serialize
log = logger.get(__name__)
class KeyedCache:
def __init__(
self,
func: typ.Callable,
exclude: set[str],
encode: set[str],
):
# Function Information
self.func: typ.Callable = func
self.func_sig: inspect.Signature = inspect.signature(self.func)
# Arg -> Key Information
self.exclude: set[str] = exclude
self.include: set[str] = set(self.func_sig.parameters.keys()) - exclude
self.encode: set[str] = encode
# Cache Information
self.key_schema: tuple[str, ...] = tuple(
[
arg_name
for arg_name in self.func_sig.parameters
if arg_name not in exclude
]
)
self.caches: dict[str | None, dict[tuple[typ.Any, ...], typ.Any]] = {}
@property
def is_method(self):
return 'self' in self.exclude
def cache(self, instance_id: str | None) -> dict[tuple[typ.Any, ...], typ.Any]:
if self.caches.get(instance_id) is None:
self.caches[instance_id] = {}
return self.caches[instance_id]
def _encode_key(self, arguments: dict[str, typ.Any]):
## WARNING: Order of arguments matters. Arguments may contain 'exclude'd elements.
return tuple(
[
(
arg_value
if arg_name not in self.encode
else serialize.encode(arg_value)
)
for arg_name, arg_value in arguments.items()
if arg_name in self.include
]
)
def __get__(
self,
bl_instance: bl_instance.BLInstance | None,
owner: type[bl_instance.BLInstance],
) -> typ.Callable:
_func = functools.partial(self, bl_instance)
_func.invalidate = functools.partial(
self.__class__.invalidate, self, bl_instance
)
return _func
def __call__(self, *args, **kwargs):
# Test Argument Bindability to Decorated Function
try:
bound_args = self.func_sig.bind(*args, **kwargs)
except TypeError as ex:
msg = f'Can\'t bind arguments (args={args}, kwargs={kwargs}) to @keyed_cache-decorated function "{self.func.__name__}" (signature: {self.func_sig})"'
raise ValueError(msg) from ex
# Check that Parameters for Keying the Cache are Available
bound_args.apply_defaults()
all_arg_keys = set(bound_args.arguments.keys())
if not self.include <= (all_arg_keys - self.exclude):
msg = f'Arguments spanning the keyed cached ({self.include}) are not available in the non-excluded arguments passed to "{self.func.__name__}": {all_arg_keys - self.exclude}'
raise ValueError(msg)
# Create Keyed Cache Entry
key = self._encode_key(bound_args.arguments)
cache = self.cache(args[0].instance_id if self.is_method else None)
if (value := cache.get(key)) is None:
value = self.func(*args, **kwargs)
cache[key] = value
return value
def invalidate(
self,
bl_instance: bl_instance.BLInstance | None,
**arguments: dict[str, typ.Any],
) -> dict[str, typ.Any]:
# Determine Wildcard Arguments
wildcard_arguments = {
arg_name for arg_name, arg_value in arguments.items() if arg_value is ...
}
# Compute Keys to Invalidate
arguments_hashable = {
arg_name: serialize.encode(arg_value)
if arg_name in self.encode and arg_name not in wildcard_arguments
else arg_value
for arg_name, arg_value in arguments.items()
}
cache = self.cache(bl_instance.instance_id if self.is_method else None)
for key in list(cache.keys()):
if all(
arguments_hashable.get(arg_name) == arg_value
for arg_name, arg_value in zip(self.key_schema, key, strict=True)
if arg_name not in wildcard_arguments
):
cache.pop(key)
def keyed_cache(exclude: set[str], encode: set[str] = frozenset()) -> typ.Callable:
def decorator(func: typ.Callable) -> typ.Callable:
return KeyedCache(
func,
exclude=exclude,
encode=encode,
)
return decorator

View File

@ -0,0 +1,171 @@
# 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/>.
"""Implements various key caches on instances of Blender objects, especially nodes and sockets."""
## TODO: Note that persist=True on cached_bl_property may cause a draw method to try and write to a Blender property, which Blender disallows.
import typing as typ
from blender_maxwell import contracts as ct
from blender_maxwell.utils import bl_instance, logger
from .signal import Signal
log = logger.get(__name__)
####################
# - Global Variables
####################
_CACHE_NONPERSIST: dict[bl_instance.InstanceID, dict[typ.Hashable, typ.Any]] = {}
####################
# - Create/Invalidate
####################
def bl_instance_nonpersist_cache(
bl_instance: bl_instance.BLInstance,
) -> dict[typ.Hashable, typ.Any]:
"""Retrieve the non-persistent cache of a BLInstance."""
# Create Non-Persistent Cache Entry
## Prefer explicit cache management to 'defaultdict'
if _CACHE_NONPERSIST.get(bl_instance.instance_id) is None:
_CACHE_NONPERSIST[bl_instance.instance_id] = {}
return _CACHE_NONPERSIST[bl_instance.instance_id]
def invalidate_nonpersist_instance_id(instance_id: bl_instance.InstanceID) -> None:
"""Invalidate any `instance_id` that might be utilizing cache space in `_CACHE_NONPERSIST`.
Notes:
This should be run by the `instance_id` owner in its `free()` method.
Parameters:
instance_id: The ID of the Blender object instance that's being freed.
"""
_CACHE_NONPERSIST.pop(instance_id, None)
####################
# - Access
####################
def read(
bl_instance: bl_instance.BLInstance | None,
key: typ.Hashable,
use_nonpersist: bool = True,
use_persist: bool = False,
) -> typ.Any | typ.Literal[Signal.CacheNotReady, Signal.CacheEmpty]:
"""Read the cache associated with a Blender Instance, without writing to it.
Attributes:
key: The name to read from the instance-specific cache.
use_nonpersist: If true, will always try the non-persistent cache first.
use_persist: If true, will always try accessing the attribute `bl_instance,key`, where `key` is the value of the same-named parameter.
Generally, such an attribute should be a `bpy.types.Property`.
Return:
The cache hit, if any; else `Signal.CacheEmpty`.
"""
# Check BLInstance Readiness
if bl_instance is None:
return Signal.CacheNotReady
# Try Hit on Persistent Cache
if use_persist:
value = getattr(bl_instance, key, Signal.CacheEmpty)
if value is not Signal.CacheEmpty:
return value
# Check if Instance ID is Available
if not bl_instance.instance_id:
log.debug(
"Can't Get CachedBLProperty: Instance ID not (yet) defined on bl_instance.BLInstance %s",
str(bl_instance),
)
return Signal.CacheNotReady
# Try Hit on Non-Persistent Cache
if use_nonpersist:
cache_nonpersist = bl_instance_nonpersist_cache(bl_instance)
value = cache_nonpersist.get(key, Signal.CacheEmpty)
if value is not Signal.CacheEmpty:
return value
return Signal.CacheEmpty
def write(
bl_instance: bl_instance.BLInstance,
key: typ.Hashable,
value: typ.Any, ## TODO: "Serializable" type
use_nonpersist: bool = True,
use_persist: bool = False,
) -> None:
"""Write to the cache associated with a Blender Instance.
Attributes:
key: The name to write to the instance-specific cache.
use_nonpersist: If true, will always write to the non-persistent cache first.
use_persist: If true, will always write to attribute `bl_instance.key`, where `key` is the value of the same-named parameter.
Generally, such an attribute should be a `bpy.types.Property`.
call_on_prop_changed: Whether to trigger `bl_instance.on_prop_changed()` with the
"""
# Check BLInstance Readiness
if bl_instance is None:
return
# Try Write on Persistent Cache
if use_persist:
# log.critical('%s: Writing %s to %s.', str(bl_instance), str(value), str(key))
setattr(bl_instance, key, value)
if not bl_instance.instance_id:
log.debug(
"Can't Get CachedBLProperty: Instance ID not (yet) defined on bl_instance.BLInstance %s",
str(bl_instance),
)
return
# Try Write on Non-Persistent Cache
if use_nonpersist:
cache_nonpersist = bl_instance_nonpersist_cache(bl_instance)
cache_nonpersist[key] = value
def invalidate_nonpersist(
bl_instance: bl_instance.BLInstance,
key: typ.Hashable,
) -> None:
"""Invalidate a particular key of the non-persistent cache.
**Persistent caches can't be invalidated without writing to them**.
To get the same effect, consider using `write()` to write its default value (which must be manually tracked).
"""
# Check BLInstance Readiness
if bl_instance is None:
return
if not bl_instance.instance_id:
log.debug(
"Can't Get CachedBLProperty: Instance ID not (yet) defined on bl_instance.BLInstance %s",
str(bl_instance),
)
return
# Retrieve Non-Persistent Cache
cache_nonpersist = bl_instance_nonpersist_cache(bl_instance)
cache_nonpersist.pop(key, None)

View File

@ -0,0 +1,61 @@
# 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 enum
import uuid
class Signal(enum.StrEnum):
"""A value used to signal the descriptor via its `__set__`.
Such a signal **must** be entirely unique: Even a well-thought-out string could conceivably produce a very nasty bug, where instead of setting a descriptor-managed attribute, the user would inadvertently signal the descriptor.
To make it effectively impossible to confuse any other object whatsoever with a signal, the enum values are set to per-session `uuid.uuid4()`.
Notes:
**Do not** use this enum for anything other than directly signalling a `bl_cache` descriptor via its setter.
**Do not** store this enum `Signal` in a variable or method binding that survives longer than the session.
**Do not** persist this enum; the values will change whenever `bl_cache` is (re)loaded.
Attributes:
CacheNotReady: The cache isn't yet ready to be used.
Generally, this is because the `BLInstance` isn't made yet.
CacheEmpty: The cache has no information to offer.
InvalidateCache: The cache should be invalidated.
InvalidateCacheNoUpdate: The cache should be invalidated, but no update method should be run.
DoUpdate: Any update method that the cache triggers on change should be run.
An update is **not guaranteeed** to be run, merely requested.
ResetEnumItems: Cached dynamic enum items should be recomputed on next use.
ResetStrSearch: Cached string-search items should be recomputed on next use.
"""
# Cache Management
CacheNotReady: str = str(uuid.uuid4())
CacheEmpty: str = str(uuid.uuid4())
# Invalidation
InvalidateCache: str = str(uuid.uuid4())
InvalidateCacheNoUpdate: str = str(uuid.uuid4())
DoUpdate: str = str(uuid.uuid4())
# Reset Signals
## -> Invalidates data adjascent to fields.
ResetEnumItems: str = str(uuid.uuid4())
ResetStrSearch: str = str(uuid.uuid4())

View File

@ -0,0 +1,299 @@
# 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

View File

@ -96,6 +96,26 @@ class MathType(enum.StrEnum):
}[self]
)
def coerce_compatible_pyobj(
self, pyobj: bool | int | Fraction | float | complex
) -> bool | int | Fraction | float | complex:
MT = MathType
match self:
case MT.Bool:
return pyobj
case MT.Integer:
return int(pyobj)
case MT.Rational if isinstance(pyobj, int):
return Fraction(pyobj, 1)
case MT.Rational if isinstance(pyobj, Fraction):
return pyobj
case MT.Real:
return float(pyobj)
case MT.Complex if isinstance(pyobj, int | Fraction):
return complex(float(pyobj), 0)
case MT.Complex if isinstance(pyobj, float):
return complex(pyobj, 0)
@staticmethod
def from_expr(sp_obj: SympyType) -> type:
if isinstance(sp_obj, sp.MatrixBase):
@ -124,21 +144,31 @@ class MathType(enum.StrEnum):
raise ValueError(msg)
@staticmethod
def from_pytype(dtype) -> type:
def from_pytype(dtype: type) -> type:
return {
bool: MathType.Bool,
int: MathType.Integer,
Fraction: MathType.Rational,
float: MathType.Real,
complex: MathType.Complex,
}[dtype]
@staticmethod
def has_mathtype(obj: typ.Any) -> typ.Literal['pytype', 'expr'] | None:
if isinstance(obj, bool | int | Fraction | float | complex):
return 'pytype'
if isinstance(obj, sp.Basic | sp.MatrixBase | sp.MutableDenseMatrix):
return 'expr'
return None
@property
def pytype(self) -> type:
MT = MathType
return {
MT.Bool: bool,
MT.Integer: int,
MT.Rational: float,
MT.Rational: Fraction,
MT.Real: float,
MT.Complex: complex,
}[self]
@ -209,8 +239,20 @@ class NumberSize1D(enum.StrEnum):
)
@staticmethod
def supports_shape(shape: tuple[int, ...] | None):
return shape is None or (len(shape) == 1 and shape[0] in [2, 3])
def has_shape(shape: tuple[int, ...] | None):
return shape in [None, (2,), (3,), (4,), (2, 1), (3, 1), (4, 1)]
def supports_shape(self, shape: tuple[int, ...] | None):
NS = NumberSize1D
match self:
case NS.Scalar:
return shape is None
case NS.Vec2:
return shape in ((2,), (2, 1))
case NS.Vec3:
return shape in ((3,), (3, 1))
case NS.Vec4:
return shape in ((4,), (4, 1))
@staticmethod
def from_shape(shape: tuple[typ.Literal[2, 3]] | None) -> typ.Self:
@ -220,6 +262,9 @@ class NumberSize1D(enum.StrEnum):
(2,): NS.Vec2,
(3,): NS.Vec3,
(4,): NS.Vec4,
(2, 1): NS.Vec2,
(3, 1): NS.Vec3,
(4, 1): NS.Vec4,
}[shape]
@property
@ -233,6 +278,14 @@ class NumberSize1D(enum.StrEnum):
}[self]
def symbol_range(sym: sp.Symbol) -> str:
return f'{sym.name}' + (
''
if sym.is_complex
else ('' if sym.is_real else ('' if sym.is_integer else '?'))
)
####################
# - Unit Dimensions
####################
@ -749,7 +802,7 @@ def scale_to_unit(sp_obj: SympyType, unit: spu.Quantity) -> Number:
Raises:
ValueError: If the result of unit-conversion and -stripping still has units, as determined by `uses_units()`.
"""
unitless_expr = spu.convert_to(sp_obj, unit) / unit
unitless_expr = spu.convert_to(sp_obj, unit) / unit if unit is not None else sp_obj
if not uses_units(unitless_expr):
return unitless_expr
@ -800,6 +853,9 @@ def unit_str_to_unit(unit_str: str) -> Unit | None:
class PhysicalType(enum.StrEnum):
"""Type identifiers for expressions with both `MathType` and a unit, aka a "physical" type."""
# Unitless
NonPhysical = enum.auto()
# Global
Time = enum.auto()
Angle = enum.auto()
@ -845,10 +901,11 @@ class PhysicalType(enum.StrEnum):
AngularWaveVector = enum.auto()
PoyntingVector = enum.auto()
@property
@functools.cached_property
def unit_dim(self):
PT = PhysicalType
return {
PT.NonPhysical: None,
# Global
PT.Time: Dims.time,
PT.Angle: Dims.angle,
@ -894,10 +951,11 @@ class PhysicalType(enum.StrEnum):
PT.PoyntingVector: Dims.power / Dims.length**2,
}[self]
@property
@functools.cached_property
def default_unit(self) -> list[Unit]:
PT = PhysicalType
return {
PT.NonPhysical: None,
# Global
PT.Time: spu.picosecond,
PT.Angle: spu.radian,
@ -942,10 +1000,11 @@ class PhysicalType(enum.StrEnum):
PT.AngularWaveVector: spu.radian * terahertz,
}[self]
@property
@functools.cached_property
def valid_units(self) -> list[Unit]:
PT = PhysicalType
return {
PT.NonPhysical: [None],
# Global
PT.Time: [
femtosecond,
@ -1101,12 +1160,13 @@ class PhysicalType(enum.StrEnum):
for physical_type in list(PhysicalType):
if unit in physical_type.valid_units:
return physical_type
## TODO: Optimize
msg = f'No PhysicalType found for unit {unit}'
msg = f'Could not determine PhysicalType for {unit}'
raise ValueError(msg)
@property
def valid_shapes(self):
@functools.cached_property
def valid_shapes(self) -> list[typ.Literal[(3,), (2,)] | None]:
PT = PhysicalType
overrides = {
# Cartesian
@ -1133,7 +1193,7 @@ class PhysicalType(enum.StrEnum):
return overrides.get(self, [None])
@property
@functools.cached_property
def valid_mathtypes(self) -> list[MathType]:
"""Returns a list of valid mathematical types, especially whether it can be real- or complex-valued.
@ -1157,6 +1217,7 @@ class PhysicalType(enum.StrEnum):
MT = MathType
PT = PhysicalType
overrides = {
PT.NonPhysical: list(MT), ## Support All
# Cartesian
PT.Freq: [MT.Real, MT.Complex], ## Im -> Growth/Damping
PT.AngFreq: [MT.Real, MT.Complex], ## Im -> Growth/Damping
@ -1187,6 +1248,8 @@ class PhysicalType(enum.StrEnum):
@staticmethod
def to_name(value: typ.Self) -> str:
if value is PhysicalType.NonPhysical:
return 'Unitless'
return PhysicalType(value).name
@staticmethod

View File

@ -21,7 +21,7 @@ class staticproperty(property): # noqa: N801
The decorated method must take no arguments whatsoever, including `self`/`cls`.
Examples:
Use as usual:
Exactly as you'd expect.
```python
class Spam:
@staticproperty