diff --git a/src/blender_maxwell/contracts/__init__.py b/src/blender_maxwell/contracts/__init__.py
index 164ec1e..8953491 100644
--- a/src/blender_maxwell/contracts/__init__.py
+++ b/src/blender_maxwell/contracts/__init__.py
@@ -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',
]
diff --git a/src/blender_maxwell/contracts/bl_types.py b/src/blender_maxwell/contracts/bl_types.py
new file mode 100644
index 0000000..466276a
--- /dev/null
+++ b/src/blender_maxwell/contracts/bl_types.py
@@ -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 .
+
+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: ...
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/bl_socket_map.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/bl_socket_map.py
index a975c74..ee62c9e 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/bl_socket_map.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/bl_socket_map.py
@@ -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],
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py
index fbc53a9..b672d2f 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py
@@ -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',
]
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/__init__.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/__init__.py
index 6fa20a0..bac21fb 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/__init__.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/__init__.py
@@ -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',
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/array.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/array.py
index c9428ab..bc49b12 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/array.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/array.py
@@ -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
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/flow_kinds.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/flow_kinds.py
index e713bd7..9baff92 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/flow_kinds.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/flow_kinds.py
@@ -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 ''
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_array_range.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_array_range.py
index a98900e..867fb1d 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_array_range.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_array_range.py
@@ -15,6 +15,7 @@
# along with this program. If not, see .
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'
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py
index 79d74a2..797915c 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py
@@ -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 = [
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py
index 1c7d914..196e3b4 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py
@@ -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(),
)
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py
index 281ed9d..d7d1bf3 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py
@@ -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,
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py
index 2866702..9f52c9e 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py
@@ -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),
}
####################
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/operate_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/operate_math.py
index 717d7c8..7a52579 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/operate_math.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/operate_math.py
@@ -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]:
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/transform_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/transform_math.py
index fdb5106..05a8027 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/transform_math.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/transform_math.py
@@ -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]:
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py
index c932da1..050bd80 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py
@@ -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
- }
- )
+ )
+ 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,
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py
index 9f51403..2cc41a6 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py
@@ -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.
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/absorbing_bound_cond.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/absorbing_bound_cond.py
index bb3b6ff..2bf8b6b 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/absorbing_bound_cond.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/absorbing_bound_cond.py
@@ -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,
),
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/pml_bound_cond.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/pml_bound_cond.py
index d973316..9609a7e 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/pml_bound_cond.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/pml_bound_cond.py
@@ -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,
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/physical_constant.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/physical_constant.py
index 6da242b..8897416 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/physical_constant.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/physical_constant.py
@@ -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)
]
####################
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/scene.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/scene.py
index 2978a2b..6239cb7 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/scene.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/scene.py
@@ -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)}
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/wave_constant.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/wave_constant.py
index bd38d9b..11ca6c9 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/wave_constant.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/wave_constant.py
@@ -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,
)
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/mediums/library_medium.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/mediums/library_medium.py
index bced2a4..121b053 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/mediums/library_medium.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/mediums/library_medium.py
@@ -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,
)
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/eh_field_monitor.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/eh_field_monitor.py
index 83c904c..af9469d 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/eh_field_monitor.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/eh_field_monitor.py
@@ -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]),
),
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/field_power_flux_monitor.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/field_power_flux_monitor.py
index 3672309..d0519ed 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/field_power_flux_monitor.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/field_power_flux_monitor.py
@@ -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]),
),
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/viewer.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/viewer.py
index 85d892e..00e19a4 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/viewer.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/viewer.py
@@ -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
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py
index 45f5782..3371d6a 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py
@@ -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
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/sim_domain.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/sim_domain.py
index e283b8e..1c060c6 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/sim_domain.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/sim_domain.py
@@ -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,
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/gaussian_beam_source.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/gaussian_beam_source.py
index fc72c76..cc584f1 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/gaussian_beam_source.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/gaussian_beam_source.py
@@ -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]),
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/plane_wave_source.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/plane_wave_source.py
index 7927836..994aca3 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/plane_wave_source.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/plane_wave_source.py
@@ -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]),
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/point_dipole_source.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/point_dipole_source.py
index 664f576..6ef0582 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/point_dipole_source.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/point_dipole_source.py
@@ -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]),
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/geonodes_structure.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/geonodes_structure.py
index 3a91fc5..e3a0ad2 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/geonodes_structure.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/geonodes_structure.py
@@ -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,
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/box_structure.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/box_structure.py
index e713b20..8ae14f8 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/box_structure.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/box_structure.py
@@ -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,
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/sphere_structure.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/sphere_structure.py
index ea7da53..2b18598 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/sphere_structure.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/sphere_structure.py
@@ -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,
),
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py
index 6d05d33..a657c34 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py
@@ -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,34 +394,32 @@ 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':
- for link in self.links:
- link.from_socket.trigger_event(event, socket_kinds=socket_kinds)
+ # 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':
- if event == ct.FlowEvent.LinkChanged:
- self.node.trigger_event(
- ct.FlowEvent.DataChanged,
- socket_name=self.name,
- socket_kinds=socket_kinds,
- )
- else:
+ case (False, 'output'):
+ if event == ct.FlowEvent.LinkChanged:
+ self.node.trigger_event(
+ ct.FlowEvent.DataChanged,
+ socket_name=self.name,
+ socket_kinds=socket_kinds,
+ )
+ else:
+ self.node.trigger_event(
+ event, socket_name=self.name, socket_kinds=socket_kinds
+ )
+
+ case (True, 'input'):
self.node.trigger_event(
event, socket_name=self.name, socket_kinds=socket_kinds
)
- # Output Socket | Input Flow
- if self.is_output and flow_direction == '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':
- for link in self.links:
- link.to_socket.trigger_event(event, socket_kinds=socket_kinds)
+ case (True, 'output'):
+ for link in self.links:
+ link.to_socket.trigger_event(event, socket_kinds=socket_kinds)
####################
# - FlowKind: Auxiliary
@@ -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
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/file_path.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/file_path.py
index 66a1355..a9738b0 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/file_path.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/file_path.py
@@ -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
####################
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/expr.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/expr.py
index 54d945a..0b3644c 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/expr.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/expr.py
@@ -14,12 +14,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+"""Implements the `ExprSocket` node socket."""
+
import enum
import typing as typ
import bpy
import pydantic as pyd
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
@@ -41,10 +44,18 @@ Float32: typ.TypeAlias = tuple[
]
-def unicode_superscript(n):
+####################
+# - Utilitives
+####################
+def unicode_superscript(n: int) -> str:
+ """Transform an integer into its unicode-based superscript character."""
return ''.join(['⁰¹²³⁴⁵⁶⁷⁸⁹'[ord(c) - ord('0')] for c in str(n)])
+def _check_sym_oo(sym):
+ return sym.is_real or sym.is_rational or sym.is_integer
+
+
class InfoDisplayCol(enum.StrEnum):
"""Valid columns for specifying displayed information from an `ct.InfoFlow`."""
@@ -71,114 +82,125 @@ class InfoDisplayCol(enum.StrEnum):
}[value]
+####################
+# - Socket
+####################
class ExprBLSocket(base.MaxwellSimSocket):
"""The `Expr` ("Expression") socket is an accessible approach to specifying any expression.
- - **Shape**: There is an intuitive UI for scalar, 2D, and 3D, but the `Expr` socket also supports parsing mathematical expressions of any shape (including matrices).
- - **Math Type**: Support integer, rational, real, and complex mathematical types, for which there is an intuitive UI for scalar, 2D, and 3D cases.
- - **Physical Type**: Supports the declaration of a physical unit dimension, for which a UI exists for the user to switch between long lists of valid units for that dimension, with automatic conversion of the value. This causes the expression to become unit-aware, which will be respected when using it for math.
- - **Symbols**: Supports the use of variables (each w/predefined `MathType`) to define arbitrary mathematical expressions, which can be used as part of a function composition chain and/or as a parameter realized at `Viz` / when generating batched simulations / when performing gradient-based optimization.
- - **Information UX**: All information encoded by the expression is presented using an intuitive UI, including filterable access to the shape of any data passing through a linked socket.
+ Attributes:
+ size: The dimensionality of the expression.
+ The socket can exposes a UI for scalar, 2D, and 3D.
+ Otherwise, a string-based `sympy` expression is the fallback.
+ mathtype: The mathematical identity of the expression.
+ Encompasses the usual suspects ex. integer, rational, real, complex, etc. .
+ Generally, there is a UI available for all of these.
+ The enum itself can be dynamically altered, ex. via its UI dropdown support.
+ physical_type: The physical identity of the expression.
+ The default indicator of a unitless (aka. non-physical) expression is `spux.PhysicalType.NonPhysical`.
+ When active, `self.active_unit` can be used via the UI to select valid unit of the given `self.physical_type`, and `self.unit` works.
+ The enum itself can be dynamically altered, ex. via its UI dropdown support.
+ symbols: The symbolic variables valid in the context of the expression.
+ Various features, including `LazyValueFunc` support, become available when symbols are in use.
+ The presence of symbols forces fallback to a string-based `sympy` expression UI.
+
+ active_unit: The currently active unit, as a dropdown.
+ Its values are always the valid units of the currently active `physical_type`.
"""
socket_type = ct.SocketType.Expr
bl_label = 'Expr'
- use_info_draw = True
####################
# - Properties
####################
- shape: tuple[int, ...] | None = bl_cache.BLField(None)
- mathtype: spux.MathType = bl_cache.BLField(spux.MathType.Real, prop_ui=True)
- physical_type: spux.PhysicalType | None = bl_cache.BLField(None)
+ size: spux.NumberSize1D = bl_cache.BLField(spux.NumberSize1D.Scalar)
+ mathtype: spux.MathType = bl_cache.BLField(spux.MathType.Real)
+ physical_type: spux.PhysicalType = bl_cache.BLField(spux.PhysicalType.NonPhysical)
symbols: frozenset[sp.Symbol] = bl_cache.BLField(frozenset())
- active_unit: enum.Enum = bl_cache.BLField(
- None, enum_cb=lambda self, _: self.search_units(), prop_ui=True
- )
-
- # UI: Value
- ## Expression
- raw_value_spstr: str = bl_cache.BLField('', prop_ui=True)
- ## 1D
- raw_value_int: int = bl_cache.BLField(0, prop_ui=True)
- raw_value_rat: Int2 = bl_cache.BLField((0, 1), prop_ui=True)
- raw_value_float: float = bl_cache.BLField(0.0, float_prec=4, prop_ui=True)
- raw_value_complex: Float2 = bl_cache.BLField((0.0, 0.0), float_prec=4, prop_ui=True)
- ## 2D
- raw_value_int2: Int2 = bl_cache.BLField((0, 0), prop_ui=True)
- raw_value_rat2: Int22 = bl_cache.BLField(((0, 1), (0, 1)), prop_ui=True)
- raw_value_float2: Float2 = bl_cache.BLField((0.0, 0.0), float_prec=4, prop_ui=True)
- raw_value_complex2: Float22 = bl_cache.BLField(
- ((0.0, 0.0), (0.0, 0.0)), float_prec=4, prop_ui=True
- )
- ## 3D
- raw_value_int3: Int3 = bl_cache.BLField((0, 0, 0), prop_ui=True)
- raw_value_rat3: Int32 = bl_cache.BLField(((0, 1), (0, 1), (0, 1)), prop_ui=True)
- raw_value_float3: Float3 = bl_cache.BLField(
- (0.0, 0.0, 0.0), float_prec=4, prop_ui=True
- )
- raw_value_complex3: Float32 = bl_cache.BLField(
- ((0.0, 0.0), (0.0, 0.0), (0.0, 0.0)), float_prec=4, prop_ui=True
- )
-
- # UI: LazyArrayRange
- steps: int = bl_cache.BLField(2, abs_min=2, prop_ui=True)
- ## Expression
- raw_min_spstr: str = bl_cache.BLField('', prop_ui=True)
- raw_max_spstr: str = bl_cache.BLField('', prop_ui=True)
- ## By MathType
- raw_range_int: Int2 = bl_cache.BLField((0, 1), prop_ui=True)
- raw_range_rat: Int22 = bl_cache.BLField(((0, 1), (1, 1)), prop_ui=True)
- raw_range_float: Float2 = bl_cache.BLField((0.0, 1.0), prop_ui=True)
- raw_range_complex: Float22 = bl_cache.BLField(
- ((0.0, 0.0), (1.0, 1.0)), float_prec=4, prop_ui=True
- )
-
- # UI: Info
- show_info_columns: bool = bl_cache.BLField(False, prop_ui=True)
- info_columns: InfoDisplayCol = bl_cache.BLField(
- {InfoDisplayCol.MathType, InfoDisplayCol.Unit},
- prop_ui=True,
- enum_many=True,
- )
-
- ####################
- # - Computed: Raw Expressions
- ####################
- @property
+ @bl_cache.cached_bl_property(depends_on={'symbols'})
def sorted_symbols(self) -> list[sp.Symbol]:
- """Retrieves all symbols and sorts them by name.
-
- Returns:
- Repeateably ordered list of symbols.
- """
+ """Name-sorted symbols."""
return sorted(self.symbols, key=lambda sym: sym.name)
- @property
- def raw_value_sp(self) -> spux.SympyExpr:
- return self._parse_expr_str(self.raw_value_spstr)
+ active_unit: enum.StrEnum = bl_cache.BLField(
+ enum_cb=lambda self, _: self.search_valid_units(),
+ use_prop_update=False,
+ cb_depends_on={'physical_type'},
+ )
- @property
- def raw_min_sp(self) -> spux.SympyExpr:
- return self._parse_expr_str(self.raw_min_spstr)
-
- @property
- def raw_max_sp(self) -> spux.SympyExpr:
- return self._parse_expr_str(self.raw_max_spstr)
-
- ####################
- # - Computed: Units
- ####################
- def search_units(self) -> list[ct.BLEnumElement]:
- if self.physical_type is not None:
+ def search_valid_units(self) -> list[ct.BLEnumElement]:
+ """Compute Blender enum elements of valid units for the current `physical_type`."""
+ if self.physical_type is not spux.PhysicalType.NonPhysical:
return [
(sp.sstr(unit), spux.sp_to_str(unit), sp.sstr(unit), '', i)
for i, unit in enumerate(self.physical_type.valid_units)
]
return []
- @bl_cache.cached_bl_property()
+ # UI: Value
+ ## Expression
+ raw_value_spstr: str = bl_cache.BLField('0.0')
+ ## 1D
+ raw_value_int: int = bl_cache.BLField(0)
+ raw_value_rat: Int2 = bl_cache.BLField((0, 1))
+ raw_value_float: float = bl_cache.BLField(0.0, float_prec=4)
+ raw_value_complex: Float2 = bl_cache.BLField((0.0, 0.0))
+ ## 2D
+ raw_value_int2: Int2 = bl_cache.BLField((0, 0))
+ raw_value_rat2: Int22 = bl_cache.BLField(((0, 1), (0, 1)))
+ raw_value_float2: Float2 = bl_cache.BLField((0.0, 0.0), float_prec=4)
+ raw_value_complex2: Float22 = bl_cache.BLField(
+ ((0.0, 0.0), (0.0, 0.0)), float_prec=4
+ )
+ ## 3D
+ raw_value_int3: Int3 = bl_cache.BLField((0, 0, 0))
+ raw_value_rat3: Int32 = bl_cache.BLField(((0, 1), (0, 1), (0, 1)))
+ raw_value_float3: Float3 = bl_cache.BLField((0.0, 0.0, 0.0), float_prec=4)
+ raw_value_complex3: Float32 = bl_cache.BLField(
+ ((0.0, 0.0), (0.0, 0.0), (0.0, 0.0)), float_prec=4
+ )
+
+ # UI: LazyArrayRange
+ steps: int = bl_cache.BLField(2, soft_min=2, abs_min=0)
+ scaling: ct.ScalingMode = bl_cache.BLField(ct.ScalingMode.Lin)
+ ## Expression
+ raw_min_spstr: str = bl_cache.BLField('0.0')
+ raw_max_spstr: str = bl_cache.BLField('1.0')
+ ## By MathType
+ raw_range_int: Int2 = bl_cache.BLField((0, 1))
+ raw_range_rat: Int22 = bl_cache.BLField(((0, 1), (1, 1)))
+ raw_range_float: Float2 = bl_cache.BLField((0.0, 1.0))
+ raw_range_complex: Float22 = bl_cache.BLField(
+ ((0.0, 0.0), (1.0, 1.0)), float_prec=4
+ )
+
+ # UI: Info
+ show_info_columns: bool = bl_cache.BLField(False)
+ info_columns: set[InfoDisplayCol] = bl_cache.BLField(
+ {InfoDisplayCol.MathType, InfoDisplayCol.Unit}
+ )
+
+ ####################
+ # - Computed String Expressions
+ ####################
+ @bl_cache.cached_bl_property(depends_on={'raw_value_spstr'})
+ def raw_value_sp(self) -> spux.SympyExpr:
+ return self._parse_expr_str(self.raw_value_spstr)
+
+ @bl_cache.cached_bl_property(depends_on={'raw_min_spstr'})
+ def raw_min_sp(self) -> spux.SympyExpr:
+ return self._parse_expr_str(self.raw_min_spstr)
+
+ @bl_cache.cached_bl_property(depends_on={'raw_max_spstr'})
+ def raw_max_sp(self) -> spux.SympyExpr:
+ return self._parse_expr_str(self.raw_max_spstr)
+
+ ####################
+ # - Computed Unit
+ ####################
+ @bl_cache.cached_bl_property(depends_on={'active_unit'})
def unit(self) -> spux.Unit | None:
"""Gets the current active unit.
@@ -192,56 +214,47 @@ class ExprBLSocket(base.MaxwellSimSocket):
return None
- @unit.setter
- def unit(self, 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 self.physical_type is not None:
- if unit in self.physical_type.valid_units:
- self.active_unit = sp.sstr(unit)
- else:
- msg = f'Tried to set invalid unit {unit} (physical type "{self.physical_type}" only supports "{self.physical_type.valid_units}")'
- raise ValueError(msg)
- elif unit is not None:
- msg = f'Tried to set invalid unit {unit} (physical type is {self.physical_type}, and has no unit support!)")'
- raise ValueError(msg)
-
- def convert_unit(self, unit_to: spux.Unit) -> None:
- current_value = self.value
- current_lazy_array_range = self.lazy_array_range
-
- # Old Unit Not in Physical Type
- ## -> This happens when dynamically altering self.physical_type
- if self.unit in self.physical_type.valid_units:
- self.unit = bl_cache.Signal.InvalidateCache
-
- self.value = current_value
- self.lazy_array_range = current_lazy_array_range
- else:
- self.unit = bl_cache.Signal.InvalidateCache
-
- # Workaround: Manually Jiggle FlowKind Invalidation
- self.value = self.value
- self.lazy_array_range = self.lazy_array_range
+ @bl_cache.cached_bl_property()
+ def prev_unit(self) -> spux.Unit | None:
+ return self.unit
####################
- # - Property Callback
+ # - Prop-Change Callback
####################
def on_socket_prop_changed(self, prop_name: str) -> None:
- if prop_name == 'physical_type':
- self.active_unit = bl_cache.Signal.ResetEnumItems
- if prop_name == 'active_unit' and self.active_unit is not None:
- self.convert_unit(spux.unit_str_to_unit(self.active_unit))
+ # Conditional Unit-Conversion
+ ## -> This is niche functionality, but the only way to convert units.
+ ## -> We can only catch 'unit' since it's at the end of a depschain.
+ if prop_name == 'unit':
+ # Check Unit Change
+ ## -> self.prev_unit only updates here; "lags" behind self.unit.
+ ## -> 1. "Laggy" unit must be different than new unit.
+ ## -> 2. Unit-conversion of value only within same physical_type
+ ## -> 3. Never unit-convert expressions w/symbolic variables
+ ## No matter what, prev_unit is always re-armed.
+ if (
+ self.prev_unit != self.unit
+ and self.prev_unit in self.physical_type.valid_units
+ and not self.symbols
+ ):
+ log.critical(self.value, self.prev_unit, self.unit)
+ self.value = spu.convert_to(self.value, self.prev_unit)
+ log.critical(self.value, self.prev_unit, self.unit)
+ self.lazy_array_range = self.lazy_array_range.rescale_to_unit(
+ self.prev_unit
+ )
+ self.prev_unit = bl_cache.Signal.InvalidateCache
####################
- # - Methods
+ # - Value Utilities
####################
def _parse_expr_info(
self, expr: spux.SympyExpr
) -> tuple[spux.MathType, tuple[int, ...] | None, spux.UnitDimension]:
+ """Parse a given expression for mathtype and size information.
+
+ Various compatibility checks are also performed, allowing this method to serve as a generic runtime validator/parser for any expressions that need to enter the socket.
+ """
# Parse MathType
mathtype = spux.MathType.from_expr(expr)
if not self.mathtype.is_compatible(mathtype):
@@ -255,18 +268,19 @@ class ExprBLSocket(base.MaxwellSimSocket):
# Parse Dimensions
shape = spux.parse_shape(expr)
- if shape != self.shape and not (
- shape is not None
- and self.shape is not None
- and len(self.shape) == 1
- and 1 in shape
- ):
- msg = f'Expr {expr} has shape {shape}, which is incompatible with the expr socket (shape {self.shape})'
+ if not self.size.supports_shape(shape):
+ msg = f'Expr {expr} has non-1D shape {shape}, which is incompatible with the expr socket (shape {self.shape})'
raise ValueError(msg)
- return mathtype, shape
+ size = spux.NumberSize1D.from_shape(shape)
+ if self.size != size:
+ msg = f'Expr {expr} has 1D size {size}, which is incompatible with the expr socket (size {self.size})'
+ raise ValueError(msg)
+
+ return mathtype, size
def _to_raw_value(self, expr: spux.SympyExpr, force_complex: bool = False):
+ """Cast the given expression to the appropriate raw value, with scaling guided by `self.unit`."""
if self.unit is not None:
pyvalue = spux.sympy_to_python(spux.scale_to_unit(expr, self.unit))
else:
@@ -286,13 +300,20 @@ class ExprBLSocket(base.MaxwellSimSocket):
return pyvalue
- def _parse_expr_str(self, expr_spstr: str) -> None:
+ def _parse_expr_str(self, expr_spstr: str) -> spux.SympyExpr | None:
+ """Parse an expression string by choosing opinionated options for `sp.sympify`.
+
+ Uses `self._parse_expr_info()` to validate the parsed result.
+
+ Returns:
+ The parsed expression, if it manages to validate; else None.
+ """
expr = sp.sympify(
expr_spstr,
locals={sym.name: sym for sym in self.symbols},
strict=False,
convert_xor=True,
- ).subs(spux.UNIT_BY_SYMBOL) * (self.unit if self.unit is not None else 1)
+ ).subs(spux.UNIT_BY_SYMBOL)
# Try Parsing and Returning the Expression
try:
@@ -312,7 +333,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
####################
@property
def value(self) -> spux.SympyExpr:
- """Return the expression defined by the socket.
+ """Return the expression defined by the socket as `FlowKind.Value`.
- **Num Dims**: Determine which property dimensionality to pull data from.
- **MathType**: Determine which property type to pull data from.
@@ -324,12 +345,21 @@ class ExprBLSocket(base.MaxwellSimSocket):
Return:
The expression defined by the socket, in the socket's unit.
+
+ When the string expression `self.raw_value_spstr` fails to parse,the property returns `FlowPending`.
"""
- if self.symbols or self.shape not in [None, (2,), (3,)]:
+ if self.symbols:
expr = self.raw_value_sp
if expr is None:
return ct.FlowSignal.FlowPending
- return expr
+ return expr * (self.unit if self.unit is not None else 1)
+
+ # Vec4 -> FlowPending
+ ## -> ExprSocket doesn't support Vec4 (yet?).
+ ## -> I mean, have you _seen_ that mess of attributes up top?
+ NS = spux.NumberSize1D
+ if self.size == NS.Vec4:
+ return ct.Flow
MT_Z = spux.MathType.Integer
MT_Q = spux.MathType.Rational
@@ -339,7 +369,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
Q = sp.Rational
R = sp.RealNumber
return {
- None: {
+ NS.Scalar: {
MT_Z: lambda: Z(self.raw_value_int),
MT_Q: lambda: Q(self.raw_value_rat[0], self.raw_value_rat[1]),
MT_R: lambda: R(self.raw_value_float),
@@ -347,7 +377,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
self.raw_value_complex[0] + sp.I * self.raw_value_complex[1]
),
},
- (2,): {
+ NS.Vec2: {
MT_Z: lambda: sp.Matrix([Z(i) for i in self.raw_value_int2]),
MT_Q: lambda: sp.Matrix([Q(q[0], q[1]) for q in self.raw_value_rat2]),
MT_R: lambda: sp.Matrix([R(r) for r in self.raw_value_float2]),
@@ -355,7 +385,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
[c[0] + sp.I * c[1] for c in self.raw_value_complex2]
),
},
- (3,): {
+ NS.Vec3: {
MT_Z: lambda: sp.Matrix([Z(i) for i in self.raw_value_int3]),
MT_Q: lambda: sp.Matrix([Q(q[0], q[1]) for q in self.raw_value_rat3]),
MT_R: lambda: sp.Matrix([R(r) for r in self.raw_value_float3]),
@@ -363,54 +393,51 @@ class ExprBLSocket(base.MaxwellSimSocket):
[c[0] + sp.I * c[1] for c in self.raw_value_complex3]
),
},
- }[self.shape][self.mathtype]() * (self.unit if self.unit is not None else 1)
+ }[self.size][self.mathtype]() * (self.unit if self.unit is not None else 1)
@value.setter
def value(self, expr: spux.SympyExpr) -> None:
- """Set the expression defined by the socket.
+ """Set the expression defined by the socket to a compatible `expr`.
Notes:
Called to set the internal `FlowKind.Value` of this socket.
"""
- _mathtype, _shape = self._parse_expr_info(expr)
- if self.symbols or self.shape not in [None, (2,), (3,)]:
+ _mathtype, _size = self._parse_expr_info(expr)
+ if self.symbols:
self.raw_value_spstr = sp.sstr(expr)
-
else:
- MT_Z = spux.MathType.Integer
- MT_Q = spux.MathType.Rational
- MT_R = spux.MathType.Real
- MT_C = spux.MathType.Complex
- if self.shape is None:
- if self.mathtype == MT_Z:
+ NS = spux.NumberSize1D
+ MT = spux.MathType
+ match (self.size, self.mathtype):
+ case (NS.Scalar, MT.Integer):
self.raw_value_int = self._to_raw_value(expr)
- elif self.mathtype == MT_Q:
+ case (NS.Scalar, MT.Rational):
self.raw_value_rat = self._to_raw_value(expr)
- elif self.mathtype == MT_R:
+ case (NS.Scalar, MT.Real):
self.raw_value_float = self._to_raw_value(expr)
- elif self.mathtype == MT_C:
+ case (NS.Scalar, MT.Complex):
self.raw_value_complex = self._to_raw_value(
expr, force_complex=True
)
- elif self.shape == (2,):
- if self.mathtype == MT_Z:
+
+ case (NS.Vec2, MT.Integer):
self.raw_value_int2 = self._to_raw_value(expr)
- elif self.mathtype == MT_Q:
+ case (NS.Vec2, MT.Rational):
self.raw_value_rat2 = self._to_raw_value(expr)
- elif self.mathtype == MT_R:
+ case (NS.Vec2, MT.Real):
self.raw_value_float2 = self._to_raw_value(expr)
- elif self.mathtype == MT_C:
+ case (NS.Vec2, MT.Complex):
self.raw_value_complex2 = self._to_raw_value(
expr, force_complex=True
)
- elif self.shape == (3,):
- if self.mathtype == MT_Z:
+
+ case (NS.Vec3, MT.Integer):
self.raw_value_int3 = self._to_raw_value(expr)
- elif self.mathtype == MT_Q:
+ case (NS.Vec3, MT.Rational):
self.raw_value_rat3 = self._to_raw_value(expr)
- elif self.mathtype == MT_R:
+ case (NS.Vec3, MT.Real):
self.raw_value_float3 = self._to_raw_value(expr)
- elif self.mathtype == MT_C:
+ case (NS.Vec3, MT.Complex):
self.raw_value_complex3 = self._to_raw_value(
expr, force_complex=True
)
@@ -433,7 +460,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
start=self.raw_min_sp,
stop=self.raw_max_sp,
steps=self.steps,
- scaling='lin',
+ scaling=self.scaling,
unit=self.unit,
symbols=self.symbols,
)
@@ -445,6 +472,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
Z = sp.Integer
Q = sp.Rational
R = sp.RealNumber
+
min_bound, max_bound = {
MT_Z: lambda: [Z(bound) for bound in self.raw_range_int],
MT_Q: lambda: [Q(bound[0], bound[1]) for bound in self.raw_range_rat],
@@ -458,7 +486,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
start=min_bound,
stop=max_bound,
steps=self.steps,
- scaling='lin',
+ scaling=self.scaling,
unit=self.unit,
)
@@ -470,6 +498,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
Called to compute the internal `FlowKind.LazyArrayRange` of this socket.
"""
self.steps = value.steps
+ self.scaling = value.scaling
if self.symbols:
self.raw_min_spstr = sp.sstr(value.start)
@@ -482,22 +511,22 @@ class ExprBLSocket(base.MaxwellSimSocket):
MT_C = spux.MathType.Complex
unit = value.unit if value.unit is not None else 1
- if value.mathtype == MT_Z:
+ if self.mathtype == MT_Z:
self.raw_range_int = [
self._to_raw_value(bound * unit)
for bound in [value.start, value.stop]
]
- elif value.mathtype == MT_Q:
+ elif self.mathtype == MT_Q:
self.raw_range_rat = [
self._to_raw_value(bound * unit)
for bound in [value.start, value.stop]
]
- elif value.mathtype == MT_R:
+ elif self.mathtype == MT_R:
self.raw_range_float = [
self._to_raw_value(bound * unit)
for bound in [value.start, value.stop]
]
- elif value.mathtype == MT_C:
+ elif self.mathtype == MT_C:
self.raw_range_complex = [
self._to_raw_value(bound * unit, force_complex=True)
for bound in [value.start, value.stop]
@@ -508,18 +537,30 @@ class ExprBLSocket(base.MaxwellSimSocket):
####################
@property
def lazy_value_func(self) -> ct.LazyValueFuncFlow:
- # Lazy Value: Arbitrary Expression
- if self.symbols or self.shape not in [None, (2,), (3,)]:
+ """Returns a lazy value that computes the expression returned by `self.value`.
+
+ If `self.value` has unknown symbols (as indicated by `self.symbols`), then these will be the arguments of the `LazyValueFuncFlow`.
+ Otherwise, the returned lazy value function will be a simple excuse for `self.params` to pass the verbatim `self.value`.
+ """
+ # Symbolic
+ ## -> `self.value` is guaranteed to be an expression with unknowns.
+ ## -> The function computes `self.value` with unknowns as arguments.
+ if self.symbols:
return ct.LazyValueFuncFlow(
- func=sp.lambdify(self.sorted_symbols, self.value, 'jax'),
+ func=sp.lambdify(
+ self.sorted_symbols,
+ spux.scale_to_unit(self.value, self.unit),
+ 'jax',
+ ),
func_args=[spux.MathType.from_expr(sym) for sym in self.sorted_symbols],
supports_jax=True,
)
- # Lazy Value: Constant
- ## -> A very simple function, which takes a single argument.
- ## -> What will be passed is a unit-scaled/stripped, pytype-converted Expr:Value.
- ## -> Until then, the user can utilize this LVF in a function composition chain.
+ # Constant
+ ## -> When a `self.value` has no unknowns, use a dummy function.
+ ## -> ("Dummy" as in returns the same argument that it takes).
+ ## -> This is an excuse to let `ParamsFlow` pass `self.value` verbatim.
+ ## -> Generally only useful for operations with other expressions.
return ct.LazyValueFuncFlow(
func=lambda v: v,
func_args=[
@@ -530,38 +571,65 @@ class ExprBLSocket(base.MaxwellSimSocket):
@property
def params(self) -> ct.ParamsFlow:
- # Params Value: Symbolic
+ """Returns parameter symbols/values to accompany `self.lazy_value_func`.
+
+ If `self.value` has unknown symbols (as indicated by `self.symbols`), then these will be passed into `ParamsFlow`, which will thus be parameterized (and require realization before use).
+ Otherwise, `self.value` is passed verbatim as the only `ParamsFlow.func_arg`.
+ """
+ # Symbolic
## -> The Expr socket does not declare actual values for the symbols.
- ## -> Those values must come from elsewhere.
- ## -> If someone tries to load them anyway, tell them 'NoFlow'.
- if self.symbols or self.shape not in [None, (2,), (3,)]:
- return ct.FlowSignal.NoFlow
-
- # Params Value: Constant
- ## -> Simply pass the Expr:Value as parameter.
- return ct.ParamsFlow(func_args=[self.value])
-
- ####################
- # - FlowKind: Array
- ####################
- @property
- def array(self) -> ct.ArrayFlow:
- if not self.symbols:
- return ct.ArrayFlow(
- values=self.lazy_value_func.func_jax(),
- unit=self.unit,
+ ## -> They should be realized later, ex. in a Viz node.
+ ## -> Therefore, we just dump the symbols. Easy!
+ ## -> NOTE: func_args must have the same symbol order as was lambdified.
+ if self.symbols:
+ return ct.ParamsFlow(
+ func_args=self.sorted_symbols,
+ symbols=self.symbols,
)
- return ct.FlowSignal.NoFlow
+ # Constant
+ ## -> Simply pass self.value verbatim as a function argument.
+ ## -> Easy dice, easy life!
+ return ct.ParamsFlow(func_args=[self.value])
- ####################
- # - FlowKind: Info
- ####################
@property
def info(self) -> ct.ArrayFlow:
+ r"""Returns parameter symbols/values to accompany `self.lazy_value_func`.
+
+ The output name/size/mathtype/unit corresponds directly the `ExprSocket`.
+
+ If `self.symbols` has entries, then these will propagate as dimensions with unresolvable `LazyArrayRangeFlow` index descriptions.
+ The index range will be $(-\infty,\infty)$, with $0$ steps and no unit.
+ The order/naming matches `self.params` and `self.lazy_value_func`.
+
+ Otherwise, only the output name/size/mathtype/unit corresponding to the socket is passed along.
+ """
+ if self.symbols:
+ return ct.InfoFlow(
+ dim_names=[sym.name for sym in self.sorted_symbols],
+ dim_idx={
+ sym.name: ct.LazyArrayRangeFlow(
+ start=-sp.oo if _check_sym_oo(sym) else -sp.zoo,
+ stop=sp.oo if _check_sym_oo(sym) else sp.zoo,
+ steps=0,
+ unit=None, ## Symbols alone are unitless.
+ )
+ ## TODO: PhysicalTypes for symbols? Or nah?
+ ## TODO: Can we parse some sp.Interval for explicit domains?
+ ## -> We investigated sp.Symbol(..., domain=...).
+ ## -> It's no good. We can't re-extract the interval given to domain.
+ for sym in self.sorted_symbols
+ },
+ output_name='_', ## Use node:socket name? Or something? Ahh
+ output_shape=self.size.shape,
+ output_mathtype=self.mathtype,
+ output_unit=self.unit,
+ )
+
+ # Constant
return ct.InfoFlow(
- output_name='_',
- output_shape=self.shape,
+ output_name='_', ## Use node:socket name? Or something? Ahh
+ output_shape=self.size.shape,
output_mathtype=self.mathtype,
output_unit=self.unit,
)
@@ -577,9 +645,11 @@ class ExprBLSocket(base.MaxwellSimSocket):
)
####################
- # - UI
+ # - UI: Label Row
####################
def draw_label_row(self, row: bpy.types.UILayout, text) -> None:
+ """Draw the unlinked input label row, with a unit dropdown (if `self.active_unit`)."""
+ # Has Unit: Draw Label and Unit Dropdown
if self.active_unit is not None:
split = row.split(factor=0.6, align=True)
@@ -588,83 +658,17 @@ class ExprBLSocket(base.MaxwellSimSocket):
_col = split.column(align=True)
_col.prop(self, self.blfields['active_unit'], text='')
+
+ # No Unit: Draw Label
else:
row.label(text=text)
- def draw_value(self, col: bpy.types.UILayout) -> None:
- if self.symbols:
- col.prop(self, self.blfields['raw_value_spstr'], text='')
-
- else:
- MT_Z = spux.MathType.Integer
- MT_Q = spux.MathType.Rational
- MT_R = spux.MathType.Real
- MT_C = spux.MathType.Complex
- if self.shape is None:
- if self.mathtype == MT_Z:
- col.prop(self, self.blfields['raw_value_int'], text='')
- elif self.mathtype == MT_Q:
- col.prop(self, self.blfields['raw_value_rat'], text='')
- elif self.mathtype == MT_R:
- col.prop(self, self.blfields['raw_value_float'], text='')
- elif self.mathtype == MT_C:
- col.prop(self, self.blfields['raw_value_complex'], text='')
- elif self.shape == (2,):
- if self.mathtype == MT_Z:
- col.prop(self, self.blfields['raw_value_int2'], text='')
- elif self.mathtype == MT_Q:
- col.prop(self, self.blfields['raw_value_rat2'], text='')
- elif self.mathtype == MT_R:
- col.prop(self, self.blfields['raw_value_float2'], text='')
- elif self.mathtype == MT_C:
- col.prop(self, self.blfields['raw_value_complex2'], text='')
- elif self.shape == (3,):
- if self.mathtype == MT_Z:
- col.prop(self, self.blfields['raw_value_int3'], text='')
- elif self.mathtype == MT_Q:
- col.prop(self, self.blfields['raw_value_rat3'], text='')
- elif self.mathtype == MT_R:
- col.prop(self, self.blfields['raw_value_float3'], text='')
- elif self.mathtype == MT_C:
- col.prop(self, self.blfields['raw_value_complex3'], text='')
-
- # Symbol Information
- if self.symbols:
- box = col.box()
- split = box.split(factor=0.3)
-
- # Left Col
- col = split.column()
- col.label(text='Let:')
-
- # Right Col
- col = split.column()
- col.alignment = 'RIGHT'
- for sym in self.symbols:
- col.label(text=spux.pretty_symbol(sym))
-
- def draw_lazy_array_range(self, col: bpy.types.UILayout) -> None:
- if self.symbols:
- col.prop(self, self.blfields['raw_min_spstr'], text='')
- col.prop(self, self.blfields['raw_max_spstr'], text='')
-
- else:
- MT_Z = spux.MathType.Integer
- MT_Q = spux.MathType.Rational
- MT_R = spux.MathType.Real
- MT_C = spux.MathType.Complex
- if self.mathtype == MT_Z:
- col.prop(self, self.blfields['raw_range_int'], text='')
- elif self.mathtype == MT_Q:
- col.prop(self, self.blfields['raw_range_rat'], text='')
- elif self.mathtype == MT_R:
- col.prop(self, self.blfields['raw_range_float'], text='')
- elif self.mathtype == MT_C:
- col.prop(self, self.blfields['raw_range_complex'], text='')
-
- col.prop(self, self.blfields['steps'], text='')
-
def draw_input_label_row(self, row: bpy.types.UILayout, text) -> None:
+ """Provide a dropdown for enabling the `InfoFlow` UI in the linked input label row.
+
+ Notes:
+ Whether information about the expression passing through a linked socket is shown is governed by `self.show_info_columns`.
+ """
info = self.compute_data(kind=ct.FlowKind.Info)
has_dims = not ct.FlowSignal.check(info) and info.dim_names
@@ -690,6 +694,13 @@ class ExprBLSocket(base.MaxwellSimSocket):
)
def draw_output_label_row(self, row: bpy.types.UILayout, text) -> None:
+ """Provide a dropdown for enabling the `InfoFlow` UI in the linked output label row.
+
+ Extremely similar to `draw_input_label_row`, except for some tricky right-alignment.
+
+ Notes:
+ Whether information about the expression passing through a linked socket is shown is governed by `self.show_info_columns`.
+ """
info = self.compute_data(kind=ct.FlowKind.Info)
has_info = not ct.FlowSignal.check(info)
@@ -718,6 +729,109 @@ class ExprBLSocket(base.MaxwellSimSocket):
_row.label(text=text)
+ ####################
+ # - UI: Active FlowKind
+ ####################
+ def draw_value(self, col: bpy.types.UILayout) -> None:
+ """Draw the socket body for a single values/expression.
+
+ Drawn when `self.active_kind == FlowKind.Value`.
+ """
+ if self.symbols:
+ col.prop(self, self.blfields['raw_value_spstr'], text='')
+
+ else:
+ NS = spux.NumberSize1D
+ MT = spux.MathType
+ match (self.size, self.mathtype):
+ case (NS.Scalar, MT.Integer):
+ col.prop(self, self.blfields['raw_value_int'], text='')
+ case (NS.Scalar, MT.Rational):
+ col.prop(self, self.blfields['raw_value_rat'], text='')
+ case (NS.Scalar, MT.Real):
+ col.prop(self, self.blfields['raw_value_float'], text='')
+ case (NS.Scalar, MT.Complex):
+ col.prop(self, self.blfields['raw_value_complex'], text='')
+
+ case (NS.Vec2, MT.Integer):
+ col.prop(self, self.blfields['raw_value_int2'], text='')
+ case (NS.Vec2, MT.Rational):
+ col.prop(self, self.blfields['raw_value_rat2'], text='')
+ case (NS.Vec2, MT.Real):
+ col.prop(self, self.blfields['raw_value_float2'], text='')
+ case (NS.Vec2, MT.Complex):
+ col.prop(self, self.blfields['raw_value_complex2'], text='')
+
+ case (NS.Vec3, MT.Integer):
+ col.prop(self, self.blfields['raw_value_int3'], text='')
+ case (NS.Vec3, MT.Rational):
+ col.prop(self, self.blfields['raw_value_rat3'], text='')
+ case (NS.Vec3, MT.Real):
+ col.prop(self, self.blfields['raw_value_float3'], text='')
+ case (NS.Vec3, MT.Complex):
+ col.prop(self, self.blfields['raw_value_complex3'], text='')
+
+ # Symbol Information
+ if self.symbols:
+ box = col.box()
+ split = box.split(factor=0.3)
+
+ # Left Col
+ col = split.column()
+ col.label(text='Let:')
+
+ # Right Col
+ col = split.column()
+ col.alignment = 'RIGHT'
+ for sym in self.symbols:
+ col.label(text=spux.pretty_symbol(sym))
+
+ def draw_lazy_array_range(self, col: bpy.types.UILayout) -> None:
+ """Draw the socket body for a simple, uniform range of values between two values/expressions.
+
+ Drawn when `self.active_kind == FlowKind.LazyArrayRange`.
+
+ Notes:
+ If `self.steps == 0`, then the `LazyArrayRange` is considered to have a to-be-determined number of steps.
+ As such, `self.steps` won't be exposed in the UI.
+ """
+ if self.symbols:
+ col.prop(self, self.blfields['raw_min_spstr'], text='')
+ col.prop(self, self.blfields['raw_max_spstr'], text='')
+
+ else:
+ MT_Z = spux.MathType.Integer
+ MT_Q = spux.MathType.Rational
+ MT_R = spux.MathType.Real
+ MT_C = spux.MathType.Complex
+ if self.mathtype == MT_Z:
+ col.prop(self, self.blfields['raw_range_int'], text='')
+ elif self.mathtype == MT_Q:
+ col.prop(self, self.blfields['raw_range_rat'], text='')
+ elif self.mathtype == MT_R:
+ col.prop(self, self.blfields['raw_range_float'], text='')
+ elif self.mathtype == MT_C:
+ col.prop(self, self.blfields['raw_range_complex'], text='')
+
+ if self.steps != 0:
+ col.prop(self, self.blfields['steps'], text='')
+
+ def draw_lazy_value_func(self, col: bpy.types.UILayout) -> None:
+ """Draw the socket body for a value/expression meant for use in a lazy function composition chain.
+
+ Drawn when `self.active_kind == FlowKind.LazyValueFunc`.
+ """
+ col.prop(self, self.blfields['physical_type'], text='')
+ if not self.symbols:
+ row = col.row(align=True)
+ row.prop(self, self.blfields['size'], text='')
+ row.prop(self, self.blfields['mathtype'], text='')
+
+ self.draw_value(col)
+
+ ####################
+ # - UI: InfoFlow
+ ####################
def draw_info(self, info: ct.InfoFlow, col: bpy.types.UILayout) -> None:
if self.active_kind == ct.FlowKind.Array and self.show_info_columns:
row = col.row()
@@ -771,50 +885,259 @@ class ExprBLSocket(base.MaxwellSimSocket):
class ExprSocketDef(base.SocketDef):
socket_type: ct.SocketType = ct.SocketType.Expr
active_kind: typ.Literal[
- ct.FlowKind.Value, ct.FlowKind.LazyArrayRange, ct.FlowKind.Array
+ ct.FlowKind.Value,
+ ct.FlowKind.LazyArrayRange,
+ ct.FlowKind.Array,
+ ct.FlowKind.LazyValueFunc,
] = ct.FlowKind.Value
# Socket Interface
- shape: tuple[int, ...] | None = None
+ size: spux.NumberSize1D = spux.NumberSize1D.Scalar
mathtype: spux.MathType = spux.MathType.Real
- physical_type: spux.PhysicalType | None = None
+ physical_type: spux.PhysicalType = spux.PhysicalType.NonPhysical
+
+ default_unit: spux.Unit | None = None
symbols: frozenset[spux.Symbol] = frozenset()
- # Socket Units
- default_unit: spux.Unit | None = None
-
# FlowKind: Value
- default_value: spux.SympyExpr = sp.S(0)
+ default_value: spux.SympyExpr = 0
abs_min: spux.SympyExpr | None = None
abs_max: spux.SympyExpr | None = None
# FlowKind: LazyArrayRange
- default_min: spux.SympyExpr = sp.S(0)
- default_max: spux.SympyExpr = sp.S(1)
+ default_min: spux.SympyExpr = 0
+ default_max: spux.SympyExpr = 1
default_steps: int = 2
+ default_scaling: ct.ScalingMode = ct.ScalingMode.Lin
# UI
show_info_columns: bool = False
####################
- # - Validators - Coersion
+ # - Parse Unit and/or Physical Type
####################
@pyd.model_validator(mode='after')
- def shape_value_coersion(self) -> str:
- if self.shape is not None and not isinstance(self.default_value, sp.MatrixBase):
- if len(self.shape) == 1:
- self.default_value = self.default_value * sp.Matrix.ones(
- self.shape[0], 1
- )
- if len(self.shape) == 2:
- self.default_value = self.default_value * sp.Matrix.ones(*self.shape)
+ def parse_default_unit(self) -> typ.Self:
+ """Guarantees that a valid default unit is defined, with respect to a given `self.physical_type`.
+
+ If no `self.default_unit` is given, then the physical type's builtin default unit is inserted.
+ """
+ if (
+ self.physical_type is not spux.PhysicalType.NonPhysical
+ and self.default_unit is None
+ ):
+ self.default_unit = self.physical_type.default_unit
return self
@pyd.model_validator(mode='after')
- def unit_coersion(self) -> str:
- if self.physical_type is not None and self.default_unit is None:
- self.default_unit = self.physical_type.default_unit
+ def parse_physical_type_from_unit(self) -> typ.Self:
+ """Guarantees that a valid physical type is defined based on the unit.
+
+ If no `self.physical_type` is given, but a unit is defined, then `spux.PhysicalType.from_unit()` is used to find an appropriate PhysicalType.
+
+ Raises:
+ ValueError: If `self.default_unit` has no obvious physical type.
+ This might happen if `self.default_unit` isn't a unit at all!
+ """
+ if (
+ self.physical_type is spux.PhysicalType.NonPhysical
+ and self.default_unit is not None
+ ):
+ physical_type = spux.PhysicalType.from_unit(self.default_unit)
+ if physical_type is spux.PhysicalType.NonPhysical:
+ msg = f'ExprSocket: Defined unit {self.default_unit} has no obvious physical type defined for it.'
+ raise ValueError(msg)
+
+ self.physical_type = physical_type
+ return self
+
+ @pyd.model_validator(mode='after')
+ def assert_physical_type_mathtype_compatibility(self) -> typ.Self:
+ """Guarantees that the physical type is compatible with `self.mathtype`.
+
+ The `self.physical_type.valid_mathtypes` method is used to perform this check.
+
+ Raises:
+ ValueError: If `self.default_unit` has no obvious physical type.
+ This might happen if `self.default_unit` isn't a unit at all!
+ """
+ # Check MathType-PhysicalType Compatibility
+ ## -> NOTE: NonPhysical has a valid_mathtypes list.
+ if self.mathtype not in self.physical_type.valid_mathtypes:
+ msg = f'ExprSocket: Defined unit {self.default_unit} has no obvious physical type defined for it.'
+ raise ValueError(msg)
+
+ return self
+
+ @pyd.model_validator(mode='after')
+ def assert_unit_is_valid_in_physical_type(self) -> str:
+ """Guarantees that the given unit is a valid unit within the given `spux.PhysicalType`.
+
+ This is implemented by checking `self.physical_type.valid_units`.
+
+ Raises:
+ ValueError: If `self.default_unit` has no obvious physical type.
+ This might happen if `self.default_unit` isn't a unit at all!
+ """
+ if (
+ self.default_unit is not None
+ and self.default_unit not in self.physical_type.valid_units
+ ):
+ msg = f'ExprSocket: Defined unit {self.default_unit} is not a valid unit of {self.physical_type} (valid units = {self.physical_type.valid_units})'
+ raise ValueError(msg)
+
+ return self
+
+ ####################
+ # - Parse FlowKind.Value
+ ####################
+ @pyd.model_validator(mode='after')
+ def parse_default_value_size(self) -> typ.Self:
+ """Guarantees that the default value is correctly shaped.
+
+ If a single number for `self.default_value` is given, then it will be broadcast into the given `self.size.shape`.
+
+ Raises:
+ ValueError: If `self.default_value` is shaped, but with a shape not identical to `self.size`.
+ """
+ # Default Value is sp.Matrix
+ ## -> Let the user take responsibility for shape
+ if isinstance(self.default_value, sp.MatrixBase):
+ if self.size.supports_shape(self.default_value.shape):
+ return self
+
+ msg = f"ExprSocket: Default value {self.default_value} is shaped, but its shape {self.default_value.shape} doesn't match the shape of the ExprSocket {self.size.shape}"
+ raise ValueError(msg)
+
+ if self.size.shape is not None:
+ # Coerce Number -> Column 0-Vector
+ ## -> TODO: We don't strictly know if default_value is a number.
+ if len(self.size.shape) == 1:
+ self.default_value = self.default_value * sp.Matrix.ones(
+ self.size.shape[0], 1
+ )
+
+ # Coerce Number -> 0-Matrix
+ ## -> TODO: We don't strictly know if default_value is a number.
+ if len(self.size.shape) > 1:
+ self.default_value = self.default_value * sp.Matrix.ones(
+ *self.size.shape
+ )
+
+ return self
+
+ @pyd.model_validator(mode='after')
+ def parse_default_value_number(self) -> typ.Self:
+ """Guarantees that the default value is a sympy expression w/valid (possibly pre-coerced) MathType.
+
+ If `self.default_value` is a scalar Python type, it will be coerced into the corresponding Sympy type using `sp.S`, after coersion to the correct Python type using `self.mathtype.coerce_compatible_pyobj()`.
+
+ Raises:
+ ValueError: If `self.default_value` has no obvious, coerceable `spux.MathType` compatible with `self.mathtype`, as determined by `spux.MathType.has_mathtype`.
+ """
+ mathtype_guide = spux.MathType.has_mathtype(self.default_value)
+
+ # None: No Obvious Mathtype
+ if mathtype_guide is None:
+ msg = f'ExprSocket: Type of default value {self.default_value} (type {type(self.default_value)})'
+ raise ValueError(msg)
+
+ # PyType: Coerce from PyType
+ if mathtype_guide == 'pytype':
+ dv_mathtype = spux.MathType.from_pytype(type(self.default_value))
+ if self.mathtype.is_compatible(dv_mathtype):
+ self.default_value = sp.S(
+ self.mathtype.coerce_compatible_pyobj(self.default_value)
+ )
+ else:
+ msg = f'ExprSocket: Mathtype {dv_mathtype} of default value {self.default_value} (type {type(self.default_value)}) is incompatible with socket MathType {self.mathtype}'
+ raise ValueError(msg)
+
+ # Expr: Merely Check MathType Compatibility
+ if mathtype_guide == 'expr':
+ dv_mathtype = spux.MathType.from_expr(self.default_value)
+ if not self.mathtype.is_compatible(dv_mathtype):
+ msg = f'ExprSocket: Mathtype {dv_mathtype} of default value expression {self.default_value} (type {type(self.default_value)}) is incompatible with socket MathType {self.mathtype}'
+ raise ValueError(msg)
+
+ return self
+
+ ####################
+ # - Parse FlowKind.LazyArrayRange
+ ####################
+ @pyd.field_validator('default_steps')
+ @classmethod
+ def steps_must_be_0_or_gte_2(cls, v: int) -> int:
+ r"""Checks that steps is either 0 (not currently set), or $\ge 2$."""
+ if not (v >= 2 or v == 0): # noqa: PLR2004
+ msg = f'Default steps {v} must either be greater than or equal to 2, or 0 (denoting that no steps are currently given)'
+ raise ValueError(msg)
+
+ return v
+
+ @pyd.model_validator(mode='after')
+ def parse_default_lazy_array_range_numbers(self) -> typ.Self:
+ """Guarantees that the default `ct.LazyArrayRange` bounds are sympy expressions.
+
+ If `self.default_value` is a scalar Python type, it will be coerced into the corresponding Sympy type using `sp.S`.
+
+ Raises:
+ ValueError: If `self.default_value` has no obvious `spux.MathType`, as determined by `spux.MathType.has_mathtype`.
+ """
+ new_bounds = [None, None]
+ for i, bound in enumerate([self.default_min, self.default_max]):
+ mathtype_guide = spux.MathType.has_mathtype(bound)
+
+ # None: No Obvious Mathtype
+ if mathtype_guide is None:
+ msg = f'ExprSocket: A default bound {bound} (type {type(bound)}) has no MathType.'
+ raise ValueError(msg)
+
+ # PyType: Coerce from PyType
+ if mathtype_guide == 'pytype':
+ dv_mathtype = spux.MathType.from_pytype(type(bound))
+ if self.mathtype.is_compatible(dv_mathtype):
+ new_bounds[i] = sp.S(self.mathtype.coerce_compatible_pyobj(bound))
+ else:
+ msg = f'ExprSocket: Mathtype {dv_mathtype} of a bound {bound} (type {type(bound)}) is incompatible with socket MathType {self.mathtype}'
+ raise ValueError(msg)
+
+ # Expr: Merely Check MathType Compatibility
+ if mathtype_guide == 'expr':
+ dv_mathtype = spux.MathType.from_expr(bound)
+ if not self.mathtype.is_compatible(dv_mathtype):
+ msg = f'ExprSocket: Mathtype {dv_mathtype} of a default LazyArrayRange min or max expression {bound} (type {type(self.default_value)}) is incompatible with socket MathType {self.mathtype}'
+ raise ValueError(msg)
+
+ if new_bounds[0] is not None:
+ self.default_min = new_bounds[0]
+ if new_bounds[1] is not None:
+ self.default_max = new_bounds[1]
+
+ return self
+
+ @pyd.model_validator(mode='after')
+ def parse_default_lazy_array_range_size(self) -> typ.Self:
+ """Guarantees that the default `ct.LazyArrayRange` bounds are unshaped.
+
+ Raises:
+ ValueError: If `self.default_min` or `self.default_max` are shaped.
+ """
+ # Check ActiveKind and Size
+ ## -> NOTE: This doesn't protect against dynamic changes to either.
+ if (
+ self.active_kind == ct.FlowKind.LazyArrayRange
+ and self.size is not spux.NumberSize1D.Scalar
+ ):
+ msg = "Can't have a non-Scalar size when LazyArrayRange is set as the active kind."
+ raise ValueError(msg)
+
+ # Check that Bounds are Shapeless
+ for bound in [self.default_min, self.default_max]:
+ if hasattr(bound, 'shape'):
+ msg = f'ExprSocket: A default bound {bound} (type {type(bound)}) has a shape, but LazyArrayRange supports no shape in ExprSockets.'
+ raise ValueError(msg)
return self
@@ -822,24 +1145,7 @@ class ExprSocketDef(base.SocketDef):
# - Validators - Assertion
####################
@pyd.model_validator(mode='after')
- def valid_shapes(self) -> str:
- if self.active_kind == ct.FlowKind.LazyArrayRange and self.shape is not None:
- msg = "Can't have a non-None shape when LazyArrayRange is set as the active kind."
- raise ValueError(msg)
-
- return self
-
- @pyd.model_validator(mode='after')
- def mathtype_value(self) -> str:
- default_value_mathtype = spux.MathType.from_expr(self.default_value)
- if not self.mathtype.is_compatible(default_value_mathtype):
- msg = f'MathType is {self.mathtype}, but tried to set default value {self.default_value} with mathtype {default_value_mathtype}'
- raise ValueError(msg)
-
- return self
-
- @pyd.model_validator(mode='after')
- def symbols_value(self) -> str:
+ def symbols_value(self) -> typ.Self:
if (
self.default_value.free_symbols
and not self.default_value.free_symbols.issubset(self.symbols)
@@ -850,15 +1156,15 @@ class ExprSocketDef(base.SocketDef):
return self
@pyd.model_validator(mode='after')
- def shape_value(self) -> str:
+ def shape_value(self) -> typ.Self:
shape = spux.parse_shape(self.default_value)
- if shape != self.shape and not (
- shape is not None
- and self.shape is not None
- and len(self.shape) == 1
- and 1 in shape
- ):
- msg = f'Default value {self.default_value} has shape {shape}, which is incompatible with the expr socket (shape {self.shape})'
+ if not self.size.supports_shape(shape):
+ msg = f'Default expr {self.default_value} has non-1D shape {shape}, which is incompatible with the expr socket def (size {self.size})'
+ raise ValueError(msg)
+
+ size = spux.NumberSize1D.from_shape(shape)
+ if self.size != size:
+ msg = f'Default expr size {size} is incompatible with the expr socket (size {self.size})'
raise ValueError(msg)
return self
@@ -870,30 +1176,36 @@ class ExprSocketDef(base.SocketDef):
bl_socket.active_kind = self.active_kind
# Socket Interface
- bl_socket.shape = self.shape
+ ## -> Recall that auto-updates are turned off during init()
+ bl_socket.size = self.size
bl_socket.mathtype = self.mathtype
bl_socket.physical_type = self.physical_type
bl_socket.symbols = self.symbols
- # Socket Units & FlowKind.Value
- if self.physical_type is not None:
- bl_socket.unit = self.default_unit
+ # FlowKind.Value
+ ## -> We must take units into account when setting bl_socket.value
+ if self.physical_type is not spux.PhysicalType.NonPhysical:
+ self.active_unit = sp.sstr(self.default_unit)
bl_socket.value = self.default_value * self.default_unit
else:
bl_socket.value = self.default_value
- # FlowKind: LazyArrayRange
+ # FlowKind.LazyArrayRange
+ ## -> We can directly pass None to unit.
bl_socket.lazy_array_range = ct.LazyArrayRangeFlow(
start=self.default_min,
stop=self.default_max,
steps=self.default_steps,
- scaling='lin',
+ scaling=self.default_scaling,
unit=self.default_unit,
)
# UI
bl_socket.show_info_columns = self.show_info_columns
+ # Info Draw
+ bl_socket.use_info_draw = True
+
####################
# - Blender Registration
diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py
index 04160ee..ad6d08b 100644
--- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py
+++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py
@@ -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
####################
diff --git a/src/blender_maxwell/utils/bl_cache.py b/src/blender_maxwell/utils/bl_cache.py
deleted file mode 100644
index 7b97ae1..0000000
--- a/src/blender_maxwell/utils/bl_cache.py
+++ /dev/null
@@ -1,1016 +0,0 @@
-# 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 .
-
-"""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 enum
-import functools
-import inspect
-import typing as typ
-import uuid
-from pathlib import Path
-
-import bpy
-import numpy as np
-
-from blender_maxwell import contracts as ct
-from blender_maxwell.utils import logger, serialize
-
-log = logger.get(__name__)
-
-InstanceID: typ.TypeAlias = str ## Stringified UUID4
-
-
-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.
- """
-
- InvalidateCache: str = str(uuid.uuid4())
- ResetEnumItems: str = str(uuid.uuid4())
- ResetStrSearch: str = str(uuid.uuid4())
-
-
-class BLInstance(typ.Protocol):
- """An instance of a blender object, ex. nodes/sockets.
-
- Attributes:
- instance_id: Stringified UUID4 that uniquely identifies an instance, among all active instances on all active classes.
- """
-
- instance_id: InstanceID
-
- def reset_instance_id(self) -> None: ...
-
- @classmethod
- def declare_blfield(
- cls, attr_name: str, bl_attr_name: str, prop_ui: bool = False
- ) -> None: ...
-
- @classmethod
- def set_prop(
- cls,
- prop_name: str,
- prop: bpy.types.Property,
- no_update: bool = False,
- update_with_name: str | None = None,
- **kwargs,
- ) -> None: ...
-
-
-class BLEnumStrEnum(typ.Protocol):
- @staticmethod
- def to_name(value: typ.Self) -> str: ...
-
- @staticmethod
- def to_icon(value: typ.Self) -> ct.BLIcon: ...
-
-
-StringPropSubType: typ.TypeAlias = typ.Literal[
- 'FILE_PATH', 'DIR_PATH', 'FILE_NAME', 'BYTE_STRING', 'PASSWORD', 'NONE'
-]
-
-StrMethod: typ.TypeAlias = typ.Callable[
- [BLInstance, bpy.types.Context, str], list[tuple[str, str]]
-]
-EnumMethod: typ.TypeAlias = typ.Callable[
- [BLInstance, bpy.types.Context], list[ct.BLEnumElement]
-]
-
-PropGetMethod: typ.TypeAlias = typ.Callable[
- [BLInstance], serialize.NaivelyEncodableType
-]
-PropSetMethod: typ.TypeAlias = typ.Callable[
- [BLInstance, serialize.NaivelyEncodableType], None
-]
-
-
-####################
-# - Cache: Non-Persistent
-####################
-CACHE_NOPERSIST: dict[InstanceID, dict[typ.Any, typ.Any]] = {}
-
-
-def invalidate_nonpersist_instance_id(instance_id: InstanceID) -> None:
- """Invalidate any `instance_id` that might be utilizing cache space in `CACHE_NOPERSIST`.
-
- 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_NOPERSIST.pop(instance_id, None)
-
-
-####################
-# - Property Descriptor
-####################
-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: BLInstance | None, owner: type[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: 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
-
-
-####################
-# - Property Descriptor
-####################
-class CachedBLProperty:
- """A descriptor that caches a computed attribute of a Blender node/socket/... instance (`bl_instance`), with optional cache persistence.
-
- Notes:
- **Accessing the internal `_*` attributes is likely an anti-pattern**.
-
- `CachedBLProperty` does not own the data; it only provides a convenient interface of running user-provided getter/setters.
- This also applies to the `bpy.types.Property` entry created by `CachedBLProperty`, which should not be accessed directly.
-
- Attributes:
- _getter_method: Method of `bl_instance` that computes the value.
- _setter_method: Method of `bl_instance` that sets the value.
- _persist: Whether to persist the value on a `bpy.types.Property` defined on `bl_instance`.
- The name of this `bpy.types.Property` will be `cache__`.
- _type: The type of the value, used by the persistent decoder.
- """
-
- def __init__(self, getter_method: PropGetMethod, persist: bool):
- """Initialize the getter (and persistance) of the cached property.
-
- Notes:
- - When `persist` is true, the return annotation of the getter mathod will be used to guide deserialization.
-
- Parameters:
- getter_method: Method of `bl_instance` that computes the value.
- persist: Whether to persist the value on a `bpy.types.Property` defined on `bl_instance`.
- The name of this `bpy.types.Property` will be `cache__`.
- """
- self._getter_method: PropGetMethod = getter_method
- self._setter_method: PropSetMethod | None = None
-
- # Persistance
- self._persist: bool = persist
- self._type: type | None = (
- inspect.signature(getter_method).return_annotation if persist else None
- )
-
- # Check Non-Empty Type Annotation
- ## For now, just presume that all types can be encoded/decoded.
- if self._type is not None and self._type is inspect.Signature.empty:
- msg = f'A CachedBLProperty was instantiated with "persist={persist}", but its getter method "{self._getter_method}" has no return type annotation'
- raise TypeError(msg)
-
- def __set_name__(self, owner: type[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.prop_name: str = name
- self._bl_prop_name: str = f'blcache__{name}'
-
- # Define Blender Property (w/Update Sync)
- owner.set_prop(
- self._bl_prop_name,
- bpy.props.StringProperty,
- name=f'DO NOT USE: Cache for {self.prop_name}',
- default='',
- no_update=True,
- )
-
- def __get__(
- self, bl_instance: BLInstance | None, owner: type[BLInstance]
- ) -> typ.Any:
- """Retrieves the property from a cache, or computes it and fills the cache(s).
-
- If `self._persist` is `True`, the persistent cache will be checked and filled after the non-persistent cache.
-
- Notes:
- - The non-persistent cache keeps the object in memory.
- - The persistent cache serializes the object and stores it as a string on the BLInstance. This is often fast enough, and has decent compatibility (courtesy `msgspec`), it isn't nearly as fast as the non-persistent cache, and there are gotchas.
-
- Parameters:
- bl_instance: The Blender object this prop
- """
- if bl_instance is None:
- return None
- if not bl_instance.instance_id:
- log.debug(
- "Can't Get CachedBLProperty: Instance ID not (yet) defined on BLInstance %s",
- str(bl_instance),
- )
- return None
-
- # Create Non-Persistent Cache Entry
- ## Prefer explicit cache management to 'defaultdict'
- if CACHE_NOPERSIST.get(bl_instance.instance_id) is None:
- CACHE_NOPERSIST[bl_instance.instance_id] = {}
- cache_nopersist = CACHE_NOPERSIST[bl_instance.instance_id]
-
- # Try Hit on Non-Persistent Cache
- if (value := cache_nopersist.get(self._bl_prop_name)) is not None:
- return value
-
- # Try Hit on Persistent Cache
- ## Hit: Fill Non-Persistent Cache
- if (
- self._persist
- and (encoded_value := getattr(bl_instance, self._bl_prop_name)) != ''
- ):
- value = serialize.decode(self._type, encoded_value)
- cache_nopersist[self._bl_prop_name] = value
- return value
-
- # Compute Value
- ## Fill Non-Persistent Cache
- ## Fill Persistent Cache (maybe)
- value = self._getter_method(bl_instance)
- cache_nopersist[self._bl_prop_name] = value
- if self._persist:
- setattr(
- bl_instance, self._bl_prop_name, serialize.encode(value).decode('utf-8')
- )
- return value
-
- def __set__(self, 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 bl_instance is None:
- return
- if not bl_instance.instance_id:
- log.debug(
- "Can't Set CachedBLProperty: Instance ID not (yet) defined on BLInstance %s",
- str(bl_instance),
- )
- return
-
- if value == Signal.InvalidateCache:
- self._invalidate_cache(bl_instance)
- return
-
- if self._setter_method is None:
- msg = f'Tried to set "{value}" to "{self.prop_name}" on "{bl_instance.bl_label}", but a setter was not defined'
- raise NotImplementedError(msg)
-
- # Invalidate Caches
- self._invalidate_cache(bl_instance)
-
- # Set the Value
- self._setter_method(bl_instance, value)
-
- 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 decor
- ```python
- class Test(bpy.types.Node):
- bl_label = 'Default'
- ...
- def method(self) -> str: return self.bl_label
- attr = CachedBLProperty(getter_method=method, persist=False)
-
- @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
-
- def _invalidate_cache(self, bl_instance: BLInstance) -> None:
- """Invalidates all caches that might be storing the computed property value.
-
- This is invoked by `__set__`.
-
- Notes:
- Will not delete the `bpy.props.StringProperty`; instead, it will be set to ''.
-
- Parameters:
- bl_instance: The instance of the Blender object that contains this property.
- """
- # Invalidate Non-Persistent Cache
- if CACHE_NOPERSIST.get(bl_instance.instance_id) is not None:
- CACHE_NOPERSIST[bl_instance.instance_id].pop(self._bl_prop_name, None)
-
- # Invalidate Persistent Cache
- if self._persist and getattr(bl_instance, self._bl_prop_name) != '':
- setattr(bl_instance, self._bl_prop_name, '')
-
-
-####################
-# - Property Decorators
-####################
-def cached_bl_property(persist: bool = False):
- """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.
- It is also sometimes desired that this cache persist on `bl_instance`, ex. in the case of loose sockets or cached web data.
-
- Notes:
- - Unfortunately, `functools.cached_property` doesn't work, and can't handle persistance.
- - Use `cached_attribute` instead if merely persisting the value is desired.
-
- Parameters:
- persist: Whether or not to persist the cache value in the Blender object.
- This should be used when the **source(s) of the computed value also persists with the Blender object**.
- For example, this is especially helpful when caching information for use in `draw()` methods, so that reloading the file won't alter the cache.
-
- Examples:
- ```python
- class CustomNode(bpy.types.Node):
- @bl_cache.cached(persist=True)
- def computed_prop(self) -> ...: return ...
-
- print(bl_instance.prop) ## Computes first time
- print(bl_instance.prop) ## Cached (after restart, will read from persistent cache)
- ```
- """
-
- def decorator(getter_method: typ.Callable[[BLInstance], None]) -> type:
- return CachedBLProperty(getter_method=getter_method, persist=persist)
-
- return decorator
-
-
-####################
-# - Attribute Descriptor
-####################
-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,
- prop_flags: set[ct.BLPropFlag] | None = None,
- abs_min: int | float | None = None,
- abs_max: int | float | None = None,
- soft_min: int | float | None = None,
- soft_max: int | float | None = None,
- float_step: int | None = None,
- float_prec: int | None = None,
- str_secret: bool | None = None,
- path_type: typ.Literal['dir', 'file'] | None = None,
- ## Static / Dynamic
- enum_many: bool | None = None,
- ## Dynamic
- str_cb: StrMethod | None = None,
- enum_cb: EnumMethod | None = None,
- ) -> typ.Self:
- """Initializes and sets the attribute to a given default value.
-
- The attribute **must** declare a type annotation, and it **must** match the type of `default_value`.
-
- Parameters:
- default_value: The default value to use if the value is read before it's set.
- use_prop_update: Configures the BLField to run `bl_instance.on_prop_changed(attr_name)` whenever value is set.
- This is done by setting the `update` method.
- enum_cb: Method used to generate new enum elements whenever `Signal.ResetEnum` is presented.
- matrix_rowmajor: Blender's UI stores matrices flattened,
-
- """
- log.debug(
- 'Initializing BLField (default_value=%s, use_prop_update=%s)',
- str(default_value),
- str(use_prop_update),
- )
- self._default_value: typ.Any = default_value
- self._use_prop_update: bool = use_prop_update
-
- ## Static
- self._prop_ui = prop_ui
- self._prop_flags = prop_flags
- self._abs_min = abs_min
- self._abs_max = abs_max
- self._soft_min = soft_min
- self._soft_max = soft_max
- self._float_step = float_step
- self._float_prec = float_prec
- self._str_secret = str_secret
- self._path_type = path_type
-
- ## Static / Dynamic
- self._enum_many = enum_many
-
- ## Dynamic
- self._set_ser_default = False
- self._str_cb = str_cb
- self._enum_cb = enum_cb
-
- ## Type Coercion
- self._coerce_output_to = None
-
- ## Vector/Matrix Identity
- ## -> Matrix Shape assists in the workaround for Matrix Display Bug
- self._is_vector = False
- self._is_matrix = False
- self._matrix_shape = None
-
- ## HUGE TODO: Persist these
- self._str_cb_cache = {}
- self._enum_cb_cache = {}
-
- ####################
- # - Safe Callbacks
- ####################
- def _safe_str_cb(
- self, _self: BLInstance, context: bpy.types.Context, edit_text: str
- ):
- """Wrapper around StringProperty.search which **guarantees** that returned strings will not be garbage collected.
-
- Regenerate by passing `Signal.ResetStrSearch`.
- """
- if self._str_cb_cache.get(_self.instance_id) is None:
- self._str_cb_cache[_self.instance_id] = self._str_cb(
- _self, context, edit_text
- )
-
- return self._str_cb_cache[_self.instance_id]
-
- def _safe_enum_cb(self, _self: BLInstance, context: bpy.types.Context):
- """Wrapper around EnumProperty.items callback, which **guarantees** that returned strings will not be garbage collected.
-
- The mechanism is simple: The user-generated callback is run once, then cached in the descriptor instance for subsequent use.
- This guarantees that the user won't crash Blender by returning dynamically generated strings in the user-provided callback.
-
- The cost, however, is that user-provided callback won't run eagerly anymore.
- Thus, whenever the user wants the items in the enum to update, they must manually set the descriptor attribute to the value `Signal.ResetEnumItems`.
- """
- if self._enum_cb_cache.get(_self.instance_id) is None:
- # Retrieve Dynamic Enum Items
- enum_items = self._enum_cb(_self, context)
-
- # Ensure len(enum_items) >= 1
- ## There must always be one element to prevent invalid usage.
- if len(enum_items) == 0:
- self._enum_cb_cache[_self.instance_id] = [
- (
- 'NONE',
- 'None',
- 'No items...',
- '',
- 0 if not self._enum_many else 2**0,
- )
- ]
- else:
- self._enum_cb_cache[_self.instance_id] = enum_items
-
- return self._enum_cb_cache[_self.instance_id]
-
- def __set_name__(self, owner: type[BLInstance], name: str) -> None:
- """Sets up the descriptor on the class level, preparing it for per-instance use.
-
- - The type annotation of the attribute is noted, as it might later guide (de)serialization of the field.
- - An appropriate `bpy.props.Property` is chosen for the type annotaiton, with a default-case fallback of `bpy.props.StringProperty` containing serialized data.
-
- Our getter/setter essentially reads/writes to a `bpy.props.StringProperty`, with
-
- and use them as user-provided getter/setter to internally define a normal non-persistent `CachedBLProperty`.
- As a result, we can reuse almost all of the logic in `CachedBLProperty`
-
- Notes:
- Run by Python when setting an instance of this class to an attribute.
-
- For StringProperty subtypes, see:
-
- Parameters:
- owner: The class that contains an attribute assigned to an instance of this descriptor.
- name: The name of the attribute that an instance of descriptor was assigned to.
- """
- # Compute Name of Property
- ## Internal name uses 'blfield__' to avoid unfortunate overlaps.
- attr_name = name
- bl_attr_name = f'blfield__{name}'
-
- owner.declare_blfield(attr_name, bl_attr_name, prop_ui=self._prop_ui)
-
- # Compute Type of Property
- ## The type annotation of the BLField guides (de)serialization.
- if (AttrType := inspect.get_annotations(owner).get(name)) is None:
- msg = f'BLField "{self.prop_name}" must define a type annotation, but doesn\'t'
- raise TypeError(msg)
-
- # Define Blender Property (w/Update Sync)
- default_value = None
- no_default_value = False
- prop_is_serialized = False
- kwargs_prop = {}
-
- ## Reusable Snippets
- def _add_min_max_kwargs():
- nonlocal kwargs_prop ## I've heard legends of needing this!
- kwargs_prop |= {'min': self._abs_min} if self._abs_min is not None else {}
- kwargs_prop |= {'max': self._abs_max} if self._abs_max is not None else {}
- kwargs_prop |= (
- {'soft_min': self._soft_min} if self._soft_min is not None else {}
- )
- kwargs_prop |= (
- {'soft_max': self._soft_max} if self._soft_max is not None else {}
- )
-
- def _add_float_kwargs():
- nonlocal kwargs_prop
- kwargs_prop |= (
- {'step': self._float_step} if self._float_step is not None else {}
- )
- kwargs_prop |= (
- {'precision': self._float_prec} if self._float_prec is not None else {}
- )
-
- ## Property Flags
- kwargs_prop |= {
- 'options': self._prop_flags if self._prop_flags is not None else set()
- }
-
- ## Scalar Bool
- if AttrType is bool:
- default_value = self._default_value
- BLProp = bpy.props.BoolProperty
-
- ## Scalar Int
- elif AttrType is int:
- default_value = self._default_value
- BLProp = bpy.props.IntProperty
- _add_min_max_kwargs()
-
- ## Scalar Float
- elif AttrType is float:
- default_value = self._default_value
- BLProp = bpy.props.FloatProperty
- _add_min_max_kwargs()
- _add_float_kwargs()
-
- ## Vector Bool
- elif typ.get_origin(AttrType) is tuple and all(
- T is bool for T in typ.get_args(AttrType)
- ):
- default_value = self._default_value
- BLProp = bpy.props.BoolVectorProperty
- kwargs_prop |= {'size': len(typ.get_args(AttrType))}
- self._is_vector = True
-
- ## Vector Int
- elif typ.get_origin(AttrType) is tuple and all(
- T is int for T in typ.get_args(AttrType)
- ):
- default_value = self._default_value
- BLProp = bpy.props.IntVectorProperty
- _add_min_max_kwargs()
- kwargs_prop |= {'size': len(typ.get_args(AttrType))}
- self._is_vector = True
-
- ## Vector Float
- elif typ.get_origin(AttrType) is tuple and all(
- T is float for T in typ.get_args(AttrType)
- ):
- default_value = self._default_value
- BLProp = bpy.props.FloatVectorProperty
- _add_min_max_kwargs()
- _add_float_kwargs()
- kwargs_prop |= {'size': len(typ.get_args(AttrType))}
- self._is_vector = True
-
- ## Matrix Bool
- elif typ.get_origin(AttrType) is tuple and all(
- all(V is bool for V in typ.get_args(T)) for T in typ.get_args(AttrType)
- ):
- # Workaround for Matrix Display Bug
- ## - Also requires __get__ support to read consistently.
- rows = len(typ.get_args(AttrType))
- cols = len(typ.get_args(typ.get_args(AttrType)[0]))
- default_value = (
- np.array(self._default_value, dtype=bool)
- .flatten()
- .reshape([cols, rows])
- ).tolist()
- BLProp = bpy.props.BoolVectorProperty
- kwargs_prop |= {'size': (cols, rows), 'subtype': 'MATRIX'}
- ## 'size' has column-major ordering (Matrix Display Bug).
- self._is_matrix = True
- self._matrix_shape = (rows, cols)
-
- ## Matrix Int
- elif typ.get_origin(AttrType) is tuple and all(
- all(V is int for V in typ.get_args(T)) for T in typ.get_args(AttrType)
- ):
- _add_min_max_kwargs()
- rows = len(typ.get_args(AttrType))
- cols = len(typ.get_args(typ.get_args(AttrType)[0]))
- default_value = (
- np.array(self._default_value, dtype=int).flatten().reshape([cols, rows])
- ).tolist()
- BLProp = bpy.props.IntVectorProperty
- kwargs_prop |= {'size': (cols, rows), 'subtype': 'MATRIX'}
- self._is_matrix = True
- self._matrix_shape = (rows, cols)
-
- ## Matrix Float
- elif typ.get_origin(AttrType) is tuple and all(
- all(V is float for V in typ.get_args(T)) for T in typ.get_args(AttrType)
- ):
- _add_min_max_kwargs()
- _add_float_kwargs()
- rows = len(typ.get_args(AttrType))
- cols = len(typ.get_args(typ.get_args(AttrType)[0]))
- default_value = (
- np.array(self._default_value, dtype=float)
- .flatten()
- .reshape([cols, rows])
- ).tolist()
- BLProp = bpy.props.FloatVectorProperty
- kwargs_prop |= {'size': (cols, rows), 'subtype': 'MATRIX'}
- self._is_matrix = True
- self._matrix_shape = (rows, cols)
-
- ## Generic String
- elif AttrType is str:
- default_value = self._default_value
- BLProp = bpy.props.StringProperty
- if self._str_secret:
- kwargs_prop |= {'subtype': 'PASSWORD'}
- kwargs_prop['options'].add('SKIP_SAVE')
-
- if self._str_cb is not None:
- kwargs_prop |= {
- 'search': lambda _self, context, edit_text: self._safe_str_cb(
- _self, context, edit_text
- )
- }
-
- ## Path
- elif AttrType is Path:
- if self._path_type is None:
- msg = 'Path BLField must define "path_type"'
- raise ValueError(msg)
-
- default_value = self._default_value
- BLProp = bpy.props.StringProperty
- kwargs_prop |= {
- 'subtype': 'FILE_PATH' if self._path_type == 'file' else 'DIR_PATH'
- }
-
- ## StrEnum
- elif (
- inspect.isclass(AttrType)
- and issubclass(AttrType, enum.StrEnum)
- and self._enum_cb is None
- ):
- default_value = self._default_value
- BLProp = bpy.props.EnumProperty
- kwargs_prop |= {
- 'items': [
- (
- str(value),
- AttrType.to_name(value),
- AttrType.to_name(value), ## TODO: From AttrType.__doc__
- AttrType.to_icon(value),
- i if not self._enum_many else 2**i,
- )
- for i, value in enumerate(list(AttrType))
- ]
- }
- if self._enum_many:
- kwargs_prop['options'].add('ENUM_FLAG')
- self._coerce_output_to = AttrType
-
- ## Dynamic Enum
- elif (
- AttrType is enum.Enum
- or (inspect.isclass(AttrType) and issubclass(AttrType, enum.StrEnum))
- and self._enum_cb is not None
- ):
- if self._default_value is not None:
- msg = 'When using dynamic enum, default value must be None'
- raise ValueError(msg)
- no_default_value = True
-
- BLProp = bpy.props.EnumProperty
- kwargs_prop |= {
- 'items': lambda _self, context: self._safe_enum_cb(_self, context),
- }
- if self._enum_many:
- kwargs_prop['options'].add('ENUM_FLAG')
- if AttrType is not enum.Enum:
- self._coerce_output_to = AttrType
-
- ## BL Reference
- elif AttrType in typ.get_args(ct.BLIDStruct):
- default_value = self._default_value
- BLProp = bpy.props.PointerProperty
-
- ## Serializable Object
- else:
- default_value = serialize.encode(self._default_value).decode('utf-8')
- BLProp = bpy.props.StringProperty
- prop_is_serialized = True
-
- # Set Default Value (probably)
- if not no_default_value:
- kwargs_prop |= {'default': default_value}
-
- # Set Blender Property on Class __annotations__
- owner.set_prop(
- bl_attr_name,
- BLProp,
- # Update Callback Options
- no_update=not self._use_prop_update,
- update_with_name=attr_name,
- # Property Options
- name=('[JSON] ' if prop_is_serialized else '') + f'BLField: {attr_name}',
- **kwargs_prop,
- ) ## TODO: Mine description from owner class __doc__
-
- # Define Property Getter
- ## Serialized properties need to deserialize in the getter.
- if prop_is_serialized:
-
- def getter(_self: BLInstance) -> AttrType:
- return serialize.decode(AttrType, getattr(_self, bl_attr_name))
- else:
-
- def getter(_self: BLInstance) -> AttrType:
- return getattr(_self, bl_attr_name)
-
- # Define Property Setter
- ## Serialized properties need to serialize in the setter.
- if prop_is_serialized:
-
- def setter(_self: BLInstance, value: AttrType) -> None:
- encoded_value = serialize.encode(value).decode('utf-8')
- setattr(_self, bl_attr_name, encoded_value)
- else:
-
- def setter(_self: BLInstance, value: AttrType) -> None:
- setattr(_self, bl_attr_name, value)
-
- # Initialize CachedBLProperty w/Getter and Setter
- ## This is the usual descriptor assignment procedure.
- self._cached_bl_property = CachedBLProperty(getter_method=getter, persist=False)
- self._cached_bl_property.__set_name__(owner, name)
- self._cached_bl_property.setter(setter)
-
- def __get__(
- self, bl_instance: BLInstance | None, owner: type[BLInstance]
- ) -> typ.Any:
- if bl_instance is None:
- return None
-
- value = self._cached_bl_property.__get__(bl_instance, owner)
-
- # enum.Enum: Cast Auto-Injected Dynamic Enum 'NONE' -> None
- ## As far a Blender is concerned, dynamic enum props can't be empty.
- ## -> Well, they can... But bad things happen. So they can't.
- ## So in the interest of the user's sanity, we always ensure one entry.
- ## -> This one entry always has the one, same, id: 'NONE'.
- ## Of course, we often want to check for this "there was nothing" case.
- ## -> Aka, we want to do a `None` check, semantically speaking.
- ## -> ...But because it's a special thingy, we must check 'NONE'?
- ## Nonsense. Let the user just check `None`, as Guido intended.
- if self._enum_cb is not None and value == 'NONE':
- ## TODO: Perhaps check if the unsafe callback was actually [].
- ## -> In case the user themselves want to return 'NONE'.
- ## -> Why would they do this? Because they are users!
- return None
-
- # Sized Vectors/Matrices
- ## Why not just yeet back a np.array?
- ## -> Type-annotating a shaped numpy array is... "rough".
- ## -> Type-annotation tuple[] of known shape is super easy.
- ## -> Even list[] won't do; its size varies, after all!
- ## -> Reject modernity. Return to tuple[].
- if self._is_vector:
- ## -> tuple()ify the np.array to respect tuple[] type annotation.
- return tuple(value)
-
- if self._is_matrix:
- # Matrix Display Bug: Correctly Read Row-Major Values w/Reshape
- return tuple(
- map(tuple, np.array(value).flatten().reshape(self._matrix_shape))
- )
-
- # Coerce Output
- ## -> Mainly useful for getting the "real" StrEnum back.
- if self._coerce_output_to is not None and value is not None:
- if self._enum_many:
- return {self._coerce_output_to(v) for v in value}
- return self._coerce_output_to(value)
-
- return value
-
- def __set__(self, bl_instance: BLInstance | None, value: typ.Any) -> None:
- if value == Signal.ResetEnumItems:
- old_items = self._safe_enum_cb(bl_instance, None)
- current_items = self._enum_cb(bl_instance, None)
-
- # Only Change if Changes Need Making
- if old_items != current_items:
- # Set Enum to First Item
- ## Prevents the seemingly "missing" enum element bug.
- ## -> Caused by the old int still trying to hang on after.
- ## -> We can mitigate this by preemptively setting the enum.
- ## -> Infinite recursion if we don't check current value.
- ## -> May cause a hiccup (chains will trigger twice)
- ## To work, there **must** be a guaranteed-available string at 0,0.
- first_old_value = old_items[0][0]
- current_value = self._cached_bl_property.__get__(
- bl_instance, bl_instance.__class__
- )
- if current_value != first_old_value:
- self._cached_bl_property.__set__(bl_instance, first_old_value)
-
- # Pop the Cached Enum Items
- ## The next time Blender asks for the enum items, it'll update.
- self._enum_cb_cache.pop(bl_instance.instance_id, None)
-
- # Invalidate the Getter Cache
- ## The next time the user runs __get__, they'll get the new value.
- self._cached_bl_property.__set__(bl_instance, Signal.InvalidateCache)
-
- elif value == Signal.ResetStrSearch:
- old_items = self._safe_str_cb(bl_instance, None)
- current_items = self._str_cb(bl_instance, None)
-
- # Only Change if Changes Need Making
- if old_items != current_items:
- # Set String to ''
- ## Prevents the presence of an invalid value not in the new search.
- ## -> Infinite recursion if we don't check current value for ''.
- ## -> May cause a hiccup (chains will trigger twice)
- current_value = self._cached_bl_property.__get__(
- bl_instance, bl_instance.__class__
- )
- if current_value != '':
- self._cached_bl_property.__set__(bl_instance, '')
-
- # Pop the Cached String Search Items
- ## The next time Blender does a str search, it'll update.
- self._str_cb_cache.pop(bl_instance.instance_id, None)
-
- else:
- self._cached_bl_property.__set__(bl_instance, value)
diff --git a/src/blender_maxwell/utils/bl_cache/__init__.py b/src/blender_maxwell/utils/bl_cache/__init__.py
new file mode 100644
index 0000000..fa81d92
--- /dev/null
+++ b/src/blender_maxwell/utils/bl_cache/__init__.py
@@ -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 .
+
+"""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',
+]
diff --git a/src/blender_maxwell/utils/bl_cache/bl_field.py b/src/blender_maxwell/utils/bl_cache/bl_field.py
new file mode 100644
index 0000000..1735c89
--- /dev/null
+++ b/src/blender_maxwell/utils/bl_cache/bl_field.py
@@ -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 .
+
+"""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
diff --git a/src/blender_maxwell/utils/bl_cache/bl_prop.py b/src/blender_maxwell/utils/bl_cache/bl_prop.py
new file mode 100644
index 0000000..00c73c8
--- /dev/null
+++ b/src/blender_maxwell/utils/bl_cache/bl_prop.py
@@ -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 .
+
+"""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,
+ )
diff --git a/src/blender_maxwell/utils/bl_cache/bl_prop_type.py b/src/blender_maxwell/utils/bl_cache/bl_prop_type.py
new file mode 100644
index 0000000..dd23ee1
--- /dev/null
+++ b/src/blender_maxwell/utils/bl_cache/bl_prop_type.py
@@ -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 .
+
+"""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
diff --git a/src/blender_maxwell/utils/bl_cache/cached_bl_property.py b/src/blender_maxwell/utils/bl_cache/cached_bl_property.py
new file mode 100644
index 0000000..cc60a5d
--- /dev/null
+++ b/src/blender_maxwell/utils/bl_cache/cached_bl_property.py
@@ -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 .
+
+"""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
diff --git a/src/blender_maxwell/utils/bl_cache/keyed_cache.py b/src/blender_maxwell/utils/bl_cache/keyed_cache.py
new file mode 100644
index 0000000..d1b8758
--- /dev/null
+++ b/src/blender_maxwell/utils/bl_cache/keyed_cache.py
@@ -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 .
+
+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
diff --git a/src/blender_maxwell/utils/bl_cache/managed_cache.py b/src/blender_maxwell/utils/bl_cache/managed_cache.py
new file mode 100644
index 0000000..ae9f6ea
--- /dev/null
+++ b/src/blender_maxwell/utils/bl_cache/managed_cache.py
@@ -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 .
+
+"""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)
diff --git a/src/blender_maxwell/utils/bl_cache/signal.py b/src/blender_maxwell/utils/bl_cache/signal.py
new file mode 100644
index 0000000..08c337d
--- /dev/null
+++ b/src/blender_maxwell/utils/bl_cache/signal.py
@@ -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 .
+
+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())
diff --git a/src/blender_maxwell/utils/bl_instance.py b/src/blender_maxwell/utils/bl_instance.py
new file mode 100644
index 0000000..2c8e40d
--- /dev/null
+++ b/src/blender_maxwell/utils/bl_instance.py
@@ -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 .
+
+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
diff --git a/src/blender_maxwell/utils/extra_sympy_units.py b/src/blender_maxwell/utils/extra_sympy_units.py
index 1011a2a..13106e1 100644
--- a/src/blender_maxwell/utils/extra_sympy_units.py
+++ b/src/blender_maxwell/utils/extra_sympy_units.py
@@ -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
diff --git a/src/blender_maxwell/utils/staticproperty.py b/src/blender_maxwell/utils/staticproperty.py
index 2618f12..8f34634 100644
--- a/src/blender_maxwell/utils/staticproperty.py
+++ b/src/blender_maxwell/utils/staticproperty.py
@@ -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