feat: big refactor to fight fundamental crashes
Some rather foundational things were fundamentally broken, especially related to the initialization procedures of fields / cached properties. - We completely revamped `bl_cache`, fixing many to-be-discovered bugs. - We completely streamlined `BLField` property logic into reusable `bl_cache.BLProp` and `bl_cache.BLPropType`. - We implemented `BLInstance` superclass to handle ex. deterministic persistance of dynamic enum items, and other nuanced common functionality that was being duplicated raw. - We implemented inter `cached_bl_property` / `BLField` dependency logic, including the ability to invalidate dynamic enums without @on_value_changed logic. This **greatly** simplifies a vast quantity of nodes that were traditionally very difficult to get working due to the sharp edges imposed by needing manual invalidation logic. - We gave `ExprSocket` a significant usability upgrade, including thorough parsing logic in the `SocketDef`. It's not that existing nodes are as such broken, but their existing bugs are now going to cause problems a lot faster. Which is a good thing. BREAKING CHANGE: Closes #13. Closes #16. Big work on #64. Work on #37.main
parent
929fb2dae9
commit
c9936b8942
|
@ -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',
|
||||
]
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
# blender_maxwell
|
||||
# Copyright (C) 2024 blender_maxwell Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import typing as typ
|
||||
|
||||
|
||||
####################
|
||||
# - Blender Enum (w/EnumProperty support)
|
||||
####################
|
||||
class BLEnumStrEnum(typ.Protocol):
|
||||
@staticmethod
|
||||
def to_name(value: typ.Self) -> str: ...
|
||||
|
||||
@staticmethod
|
||||
def to_icon(value: typ.Self) -> str: ...
|
|
@ -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],
|
||||
|
|
|
@ -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',
|
||||
]
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ''
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import dataclasses
|
||||
import enum
|
||||
import functools
|
||||
import typing as typ
|
||||
from types import MappingProxyType
|
||||
|
@ -33,6 +34,25 @@ from .lazy_value_func import LazyValueFuncFlow
|
|||
log = logger.get(__name__)
|
||||
|
||||
|
||||
class ScalingMode(enum.StrEnum):
|
||||
Lin = enum.auto()
|
||||
Geom = enum.auto()
|
||||
Log = enum.auto()
|
||||
|
||||
@staticmethod
|
||||
def to_name(v: typ.Self) -> str:
|
||||
SM = ScalingMode
|
||||
return {
|
||||
SM.Lin: 'Linear',
|
||||
SM.Geom: 'Geometric',
|
||||
SM.Log: 'Logarithmic',
|
||||
}[v]
|
||||
|
||||
@staticmethod
|
||||
def to_icon(_: typ.Self) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, kw_only=True)
|
||||
class LazyArrayRangeFlow:
|
||||
r"""Represents a linearly/logarithmically spaced array using symbolic boundary expressions, with support for units and lazy evaluation.
|
||||
|
@ -84,7 +104,7 @@ class LazyArrayRangeFlow:
|
|||
start: spux.ScalarUnitlessComplexExpr
|
||||
stop: spux.ScalarUnitlessComplexExpr
|
||||
steps: int
|
||||
scaling: typ.Literal['lin', 'geom', 'log'] = 'lin'
|
||||
scaling: ScalingMode = ScalingMode.Lin
|
||||
|
||||
unit: spux.Unit | None = None
|
||||
|
||||
|
@ -295,9 +315,9 @@ class LazyArrayRangeFlow:
|
|||
A `jax` function that takes a valid `start`, `stop`, and `steps`, and returns a 1D `jax` array.
|
||||
"""
|
||||
jnp_nspace = {
|
||||
'lin': jnp.linspace,
|
||||
'geom': jnp.geomspace,
|
||||
'log': jnp.logspace,
|
||||
ScalingMode.Lin: jnp.linspace,
|
||||
ScalingMode.Geom: jnp.geomspace,
|
||||
ScalingMode.Log: jnp.logspace,
|
||||
}.get(self.scaling)
|
||||
if jnp_nspace is None:
|
||||
msg = f'ArrayFlow scaling method {self.scaling} is unsupported'
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
||||
####################
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -209,7 +209,7 @@ class VizNode(base.MaxwellSimNode):
|
|||
####################
|
||||
input_sockets: typ.ClassVar = {
|
||||
'Expr': sockets.ExprSocketDef(
|
||||
active_kind=ct.FlowKind.Array,
|
||||
active_kind=ct.FlowKind.LazyValueFunc,
|
||||
symbols={_x := sp.Symbol('x', real=True)},
|
||||
default_value=2 * _x,
|
||||
),
|
||||
|
@ -225,31 +225,33 @@ class VizNode(base.MaxwellSimNode):
|
|||
#####################
|
||||
## - Properties
|
||||
#####################
|
||||
viz_mode: enum.Enum = bl_cache.BLField(
|
||||
prop_ui=True, enum_cb=lambda self, _: self.search_viz_modes()
|
||||
)
|
||||
viz_target: enum.Enum = bl_cache.BLField(
|
||||
prop_ui=True, enum_cb=lambda self, _: self.search_targets()
|
||||
)
|
||||
|
||||
# Mode-Dependent Properties
|
||||
colormap: image_ops.Colormap = bl_cache.BLField(
|
||||
image_ops.Colormap.Viridis, prop_ui=True
|
||||
)
|
||||
|
||||
#####################
|
||||
## - Mode Searcher
|
||||
#####################
|
||||
@property
|
||||
def data_info(self) -> ct.InfoFlow | None:
|
||||
@bl_cache.cached_bl_property()
|
||||
def input_info(self) -> ct.InfoFlow | None:
|
||||
info = self._compute_input('Expr', kind=ct.FlowKind.Info)
|
||||
if not ct.FlowSignal.check(info):
|
||||
return info
|
||||
|
||||
return None
|
||||
|
||||
viz_mode: enum.StrEnum = bl_cache.BLField(
|
||||
enum_cb=lambda self, _: self.search_viz_modes(),
|
||||
cb_depends_on={'input_info'},
|
||||
)
|
||||
viz_target: enum.StrEnum = bl_cache.BLField(
|
||||
enum_cb=lambda self, _: self.search_targets(),
|
||||
cb_depends_on={'viz_mode'},
|
||||
)
|
||||
|
||||
# Mode-Dependent Properties
|
||||
colormap: image_ops.Colormap = bl_cache.BLField(
|
||||
image_ops.Colormap.Viridis,
|
||||
)
|
||||
|
||||
#####################
|
||||
## - Searchers
|
||||
#####################
|
||||
def search_viz_modes(self) -> list[ct.BLEnumElement]:
|
||||
if self.data_info is not None:
|
||||
if self.input_info is not None:
|
||||
return [
|
||||
(
|
||||
viz_mode,
|
||||
|
@ -258,14 +260,11 @@ class VizNode(base.MaxwellSimNode):
|
|||
VizMode.to_icon(viz_mode),
|
||||
i,
|
||||
)
|
||||
for i, viz_mode in enumerate(VizMode.valid_modes_for(self.data_info))
|
||||
for i, viz_mode in enumerate(VizMode.valid_modes_for(self.input_info))
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
#####################
|
||||
## - Target Searcher
|
||||
#####################
|
||||
def search_targets(self) -> list[ct.BLEnumElement]:
|
||||
if self.viz_mode is not None:
|
||||
return [
|
||||
|
@ -302,20 +301,14 @@ class VizNode(base.MaxwellSimNode):
|
|||
input_sockets_optional={'Expr': True},
|
||||
)
|
||||
def on_any_changed(self, input_sockets: dict):
|
||||
self.input_info = bl_cache.Signal.InvalidateCache
|
||||
|
||||
info = input_sockets['Expr'][ct.FlowKind.Info]
|
||||
params = input_sockets['Expr'][ct.FlowKind.Params]
|
||||
|
||||
has_info = not ct.FlowSignal.check(info)
|
||||
has_params = not ct.FlowSignal.check(params)
|
||||
|
||||
# Reset Viz Mode/Target
|
||||
has_nonpending_info = not ct.FlowSignal.check_single(
|
||||
info, ct.FlowSignal.FlowPending
|
||||
)
|
||||
if has_nonpending_info:
|
||||
self.viz_mode = bl_cache.Signal.ResetEnumItems
|
||||
self.viz_target = bl_cache.Signal.ResetEnumItems
|
||||
|
||||
# Provide Sockets for Symbol Realization
|
||||
## -> This happens if Params contains not-yet-realized symbols.
|
||||
if has_info and has_params and params.symbols:
|
||||
|
@ -325,7 +318,7 @@ class VizNode(base.MaxwellSimNode):
|
|||
self.loose_input_sockets = {
|
||||
sym.name: sockets.ExprSocketDef(
|
||||
active_kind=ct.FlowKind.LazyArrayRange,
|
||||
shape=None,
|
||||
size=spux.NumberSize1D.Scalar,
|
||||
mathtype=info.dim_mathtypes[sym.name],
|
||||
physical_type=info.dim_physical_types[sym.name],
|
||||
default_min=(
|
||||
|
@ -340,22 +333,13 @@ class VizNode(base.MaxwellSimNode):
|
|||
),
|
||||
default_steps=50,
|
||||
)
|
||||
for sym in sorted(
|
||||
params.symbols, key=lambda el: info.dim_names.index(el.name)
|
||||
)
|
||||
for sym in params.sorted_symbols
|
||||
if sym.name in info.dim_names
|
||||
}
|
||||
|
||||
elif self.loose_input_sockets:
|
||||
self.loose_input_sockets = {}
|
||||
|
||||
@events.on_value_changed(
|
||||
prop_name='viz_mode',
|
||||
run_on_init=True,
|
||||
)
|
||||
def on_viz_mode_changed(self):
|
||||
self.viz_target = bl_cache.Signal.ResetEnumItems
|
||||
|
||||
#####################
|
||||
## - Plotting
|
||||
#####################
|
||||
|
@ -374,12 +358,15 @@ class VizNode(base.MaxwellSimNode):
|
|||
self, managed_objs, props, input_sockets, loose_input_sockets, unit_systems
|
||||
):
|
||||
# Retrieve Inputs
|
||||
lazy_value_func = input_sockets['Expr'][ct.FlowKind.LazyValueFunc]
|
||||
info = input_sockets['Expr'][ct.FlowKind.Info]
|
||||
params = input_sockets['Expr'][ct.FlowKind.Params]
|
||||
|
||||
has_info = not ct.FlowSignal.check(info)
|
||||
has_params = not ct.FlowSignal.check(params)
|
||||
|
||||
# Invalid Mode | Target
|
||||
## -> To limit branching, return now if things aren't right.
|
||||
if (
|
||||
not has_info
|
||||
or not has_params
|
||||
|
@ -388,18 +375,21 @@ class VizNode(base.MaxwellSimNode):
|
|||
):
|
||||
return
|
||||
|
||||
# Compute Data
|
||||
lazy_value_func = input_sockets['Expr'][ct.FlowKind.LazyValueFunc]
|
||||
symbol_values = (
|
||||
loose_input_sockets
|
||||
if not params.symbols
|
||||
else {
|
||||
sym: loose_input_sockets[sym.name]
|
||||
# Compute LazyArrayRanges for Symbols from Loose Sockets
|
||||
## -> These are the concrete values of the symbol for plotting.
|
||||
## -> In a quite nice turn of events, all this is cached lookups.
|
||||
## -> ...Unless something changed, in which case, well. It changed.
|
||||
symbol_values = {
|
||||
sym: (
|
||||
loose_input_sockets[sym.name]
|
||||
.realize_array.rescale_to_unit(info.dim_units[sym.name])
|
||||
.values
|
||||
)
|
||||
for sym in params.sorted_symbols
|
||||
}
|
||||
)
|
||||
|
||||
# Realize LazyValueFunc w/Symbolic Values, Unit System
|
||||
## -> This gives us the actual plot data!
|
||||
data = lazy_value_func.func_jax(
|
||||
*params.scaled_func_args(
|
||||
unit_systems['BlenderUnits'], symbol_values=symbol_values
|
||||
|
@ -408,6 +398,9 @@ class VizNode(base.MaxwellSimNode):
|
|||
unit_systems['BlenderUnits'], symbol_values=symbol_values
|
||||
),
|
||||
)
|
||||
|
||||
# Replace InfoFlow Indices w/Realized Symbolic Ranges
|
||||
## -> This ensures correct axis scaling.
|
||||
if params.symbols:
|
||||
info = info.rescale_dim_idxs(loose_input_sockets)
|
||||
|
||||
|
@ -417,6 +410,7 @@ class VizNode(base.MaxwellSimNode):
|
|||
lambda ax: VizMode.to_plotter(props['viz_mode'])(data, info, ax),
|
||||
bl_select=True,
|
||||
)
|
||||
|
||||
if props['viz_target'] == VizTarget.Pixels:
|
||||
managed_objs['plot'].map_2d_to_image(
|
||||
data,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
]
|
||||
|
||||
####################
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
@ -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]),
|
||||
),
|
||||
|
|
|
@ -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]),
|
||||
),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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]),
|
||||
|
|
|
@ -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]),
|
||||
|
|
|
@ -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]),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -16,13 +16,11 @@
|
|||
|
||||
import abc
|
||||
import typing as typ
|
||||
import uuid
|
||||
from types import MappingProxyType
|
||||
|
||||
import bpy
|
||||
import pydantic as pyd
|
||||
|
||||
from blender_maxwell.utils import bl_cache, logger, serialize
|
||||
from blender_maxwell.utils import bl_cache, bl_instance, logger, serialize
|
||||
|
||||
from .. import contracts as ct
|
||||
|
||||
|
@ -60,7 +58,7 @@ class SocketDef(pyd.BaseModel, abc.ABC):
|
|||
Parameters:
|
||||
bl_socket: The Blender node socket to alter using data from this SocketDef.
|
||||
"""
|
||||
bl_socket.initializing = False
|
||||
bl_socket.is_initializing = False
|
||||
bl_socket.on_active_kind_changed()
|
||||
|
||||
@abc.abstractmethod
|
||||
|
@ -116,9 +114,12 @@ class SocketDef(pyd.BaseModel, abc.ABC):
|
|||
|
||||
|
||||
####################
|
||||
# - SocketDef
|
||||
# - Socket
|
||||
####################
|
||||
class MaxwellSimSocket(bpy.types.NodeSocket):
|
||||
MANDATORY_PROPS: set[str] = {'socket_type', 'bl_label'}
|
||||
|
||||
|
||||
class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
|
||||
"""A specialized Blender socket for nodes in a Maxwell simulation.
|
||||
|
||||
Attributes:
|
||||
|
@ -128,112 +129,45 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
|
|||
locked: The lock-state of a particular socket, which determines the socket's user editability
|
||||
"""
|
||||
|
||||
# Fundamentals
|
||||
# Properties
|
||||
## Class
|
||||
socket_type: ct.SocketType
|
||||
bl_label: str
|
||||
|
||||
# Style
|
||||
display_shape: typ.Literal[
|
||||
'CIRCLE',
|
||||
'SQUARE',
|
||||
'DIAMOND',
|
||||
'CIRCLE_DOT',
|
||||
'SQUARE_DOT',
|
||||
'DIAMOND_DOT',
|
||||
]
|
||||
## We use the following conventions for shapes:
|
||||
## - CIRCLE: Single Value.
|
||||
## - SQUARE: Container of Value.
|
||||
## - DIAMOND: Pointer Value.
|
||||
## - +DOT: Uses Units
|
||||
socket_color: tuple
|
||||
|
||||
# Options
|
||||
use_prelock: bool = False
|
||||
use_info_draw: bool = False
|
||||
|
||||
# Computed
|
||||
## Computed by Subclass
|
||||
bl_idname: str
|
||||
|
||||
# BLFields
|
||||
blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({})
|
||||
ui_blfields: typ.ClassVar[set[str]] = frozenset()
|
||||
## Identifying
|
||||
is_initializing: bool = bl_cache.BLField(True, use_prop_update=False)
|
||||
|
||||
active_kind: ct.FlowKind = bl_cache.BLField(ct.FlowKind.Value)
|
||||
|
||||
## UI
|
||||
use_info_draw: bool = bl_cache.BLField(False, use_prop_update=False)
|
||||
use_prelock: bool = bl_cache.BLField(False, use_prop_update=False)
|
||||
|
||||
locked: bool = bl_cache.BLField(False, use_prop_update=False)
|
||||
|
||||
use_socket_color: bool = bl_cache.BLField(False, use_prop_update=False)
|
||||
socket_color: tuple[float, float, float, float] = bl_cache.BLField(
|
||||
(0, 0, 0, 0), use_prop_update=False
|
||||
)
|
||||
|
||||
####################
|
||||
# - Initialization
|
||||
####################
|
||||
## TODO: Common implementation of this for both sockets and nodes - perhaps a BLInstance base class?
|
||||
def reset_instance_id(self) -> None:
|
||||
self.instance_id = str(uuid.uuid4())
|
||||
|
||||
@classmethod
|
||||
def declare_blfield(
|
||||
cls, attr_name: str, bl_attr_name: str, prop_ui: bool = False
|
||||
) -> None:
|
||||
cls.blfields = cls.blfields | {attr_name: bl_attr_name}
|
||||
|
||||
if prop_ui:
|
||||
cls.ui_blfields = cls.ui_blfields | {attr_name}
|
||||
|
||||
@classmethod
|
||||
def set_prop(
|
||||
cls,
|
||||
prop_name: str,
|
||||
prop: bpy.types.Property,
|
||||
no_update: bool = False,
|
||||
update_with_name: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Adds a Blender property to a class via `__annotations__`, so it initializes with any subclass.
|
||||
def __init_subclass__(cls, **kwargs: typ.Any):
|
||||
"""Initializes socket properties and attributes for use.
|
||||
|
||||
Notes:
|
||||
- Blender properties can't be set within `__init_subclass__` simply by adding attributes to the class; they must be added as type annotations.
|
||||
- Must be called **within** `__init_subclass__`.
|
||||
|
||||
Parameters:
|
||||
name: The name of the property to set.
|
||||
prop: The `bpy.types.Property` to instantiate and attach..
|
||||
no_update: Don't attach a `self.on_prop_changed()` callback to the property's `update`.
|
||||
Run when initializing any subclass of MaxwellSimSocket.
|
||||
"""
|
||||
_update_with_name = prop_name if update_with_name is None else update_with_name
|
||||
extra_kwargs = (
|
||||
{
|
||||
'update': lambda self, context: self.on_prop_changed(
|
||||
_update_with_name, context
|
||||
),
|
||||
}
|
||||
if not no_update
|
||||
else {}
|
||||
)
|
||||
cls.__annotations__[prop_name] = prop(
|
||||
**kwargs,
|
||||
**extra_kwargs,
|
||||
)
|
||||
|
||||
def __init_subclass__(cls, **kwargs: typ.Any):
|
||||
log.debug('Initializing Socket: %s', cls.socket_type)
|
||||
super().__init_subclass__(**kwargs)
|
||||
# cls._assert_attrs_valid()
|
||||
## TODO: Implement this :)
|
||||
cls.assert_attrs_valid(MANDATORY_PROPS)
|
||||
|
||||
# Socket Properties
|
||||
## Identifiers
|
||||
cls.bl_idname: str = str(cls.socket_type.value)
|
||||
cls.set_prop('instance_id', bpy.props.StringProperty, no_update=True)
|
||||
cls.set_prop(
|
||||
'initializing', bpy.props.BoolProperty, default=True, no_update=True
|
||||
)
|
||||
|
||||
## Special States
|
||||
cls.set_prop('locked', bpy.props.BoolProperty, no_update=True, default=False)
|
||||
|
||||
# Setup Style
|
||||
cls.socket_color = ct.SOCKET_COLORS[cls.socket_type]
|
||||
|
||||
# Setup List
|
||||
cls.set_prop(
|
||||
'active_kind', bpy.props.StringProperty, default=str(ct.FlowKind.Value)
|
||||
)
|
||||
|
||||
####################
|
||||
# - Property Event: On Update
|
||||
|
@ -244,12 +178,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
|
|||
Notes:
|
||||
Called by `self.on_prop_changed()` when `self.active_kind` was changed.
|
||||
"""
|
||||
self.display_shape = {
|
||||
ct.FlowKind.Value: 'CIRCLE',
|
||||
ct.FlowKind.Array: 'SQUARE',
|
||||
ct.FlowKind.LazyArrayRange: 'SQUARE',
|
||||
ct.FlowKind.LazyValueFunc: 'DIAMOND',
|
||||
}[self.active_kind]
|
||||
self.display_shape = self.active_kind.socket_shape
|
||||
|
||||
def on_socket_prop_changed(self, prop_name: str) -> None:
|
||||
"""Called when a property has been updated.
|
||||
|
@ -264,7 +193,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
|
|||
prop_name: The name of the property that was changed.
|
||||
"""
|
||||
|
||||
def on_prop_changed(self, prop_name: str, _: bpy.types.Context) -> None:
|
||||
def on_prop_changed(self, prop_name: str) -> None:
|
||||
"""Called when a property has been updated.
|
||||
|
||||
Contrary to `node.on_prop_changed()`, socket-specific callbacks are baked into this function:
|
||||
|
@ -275,17 +204,13 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
|
|||
prop_name: The name of the property that was changed.
|
||||
"""
|
||||
## TODO: Evaluate this properly
|
||||
if self.initializing:
|
||||
if self.is_initializing:
|
||||
log.debug(
|
||||
'%s: Rejected on_prop_changed("%s") while initializing',
|
||||
self.bl_label,
|
||||
prop_name,
|
||||
)
|
||||
elif hasattr(self, prop_name):
|
||||
# Invalidate UI BLField Caches
|
||||
if prop_name in self.ui_blfields:
|
||||
setattr(self, prop_name, bl_cache.Signal.InvalidateCache)
|
||||
|
||||
# Property Callbacks: Active Kind
|
||||
if prop_name == 'active_kind':
|
||||
self.on_active_kind_changed()
|
||||
|
@ -469,13 +394,13 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
|
|||
if event in [ct.FlowEvent.EnableLock, ct.FlowEvent.DisableLock]:
|
||||
self.locked = event == ct.FlowEvent.EnableLock
|
||||
|
||||
# Input Socket | Input Flow
|
||||
if not self.is_output and flow_direction == 'input':
|
||||
# Event by Socket Orientation | Flow Direction
|
||||
match (self.is_output, flow_direction):
|
||||
case (False, 'input'):
|
||||
for link in self.links:
|
||||
link.from_socket.trigger_event(event, socket_kinds=socket_kinds)
|
||||
|
||||
# Input Socket | Output Flow
|
||||
if not self.is_output and flow_direction == 'output':
|
||||
case (False, 'output'):
|
||||
if event == ct.FlowEvent.LinkChanged:
|
||||
self.node.trigger_event(
|
||||
ct.FlowEvent.DataChanged,
|
||||
|
@ -487,14 +412,12 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
|
|||
event, socket_name=self.name, socket_kinds=socket_kinds
|
||||
)
|
||||
|
||||
# Output Socket | Input Flow
|
||||
if self.is_output and flow_direction == 'input':
|
||||
case (True, 'input'):
|
||||
self.node.trigger_event(
|
||||
event, socket_name=self.name, socket_kinds=socket_kinds
|
||||
)
|
||||
|
||||
# Output Socket | Output Flow
|
||||
if self.is_output and flow_direction == 'output':
|
||||
case (True, 'output'):
|
||||
for link in self.links:
|
||||
link.to_socket.trigger_event(event, socket_kinds=socket_kinds)
|
||||
|
||||
|
@ -729,19 +652,24 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
|
|||
raise NotImplementedError(msg)
|
||||
|
||||
####################
|
||||
# - Theme
|
||||
# - UI - Color
|
||||
####################
|
||||
@classmethod
|
||||
def draw_color_simple(cls) -> ct.BLColorRGBA:
|
||||
"""Sets the socket's color to `cls.socket_color`.
|
||||
def draw_color(
|
||||
self,
|
||||
_: bpy.types.Context,
|
||||
node: bpy.types.Node, # noqa: ARG002
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Draw the socket color depending on context.
|
||||
|
||||
When `self.use_socket_color` is set, the property `socket_color` can be used to control the socket color directly.
|
||||
Otherwise, a default based on `self.socket_type` will be used.
|
||||
|
||||
Notes:
|
||||
Blender calls this method to determine the socket color.
|
||||
|
||||
Returns:
|
||||
A Blender-compatible RGBA value, with no explicit color space.
|
||||
Called by Blender to call the socket color.
|
||||
"""
|
||||
return cls.socket_color
|
||||
if self.use_socket_color:
|
||||
return self.socket_color
|
||||
return ct.SOCKET_COLORS[self.socket_type]
|
||||
|
||||
####################
|
||||
# - UI
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
####################
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -99,18 +99,16 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
|
|||
socket_type = ct.SocketType.Tidy3DCloudTask
|
||||
bl_label = 'Tidy3D Cloud Task'
|
||||
|
||||
use_prelock = True
|
||||
|
||||
####################
|
||||
# - Properties
|
||||
####################
|
||||
api_key: str = bl_cache.BLField('', prop_ui=True, str_secret=True)
|
||||
should_exist: bool = bl_cache.BLField(False)
|
||||
|
||||
existing_folder_id: enum.Enum = bl_cache.BLField(
|
||||
existing_folder_id: enum.StrEnum = bl_cache.BLField(
|
||||
prop_ui=True, enum_cb=lambda self, _: self.search_cloud_folders()
|
||||
)
|
||||
existing_task_id: enum.Enum = bl_cache.BLField(
|
||||
existing_task_id: enum.StrEnum = bl_cache.BLField(
|
||||
prop_ui=True, enum_cb=lambda self, _: self.search_cloud_tasks()
|
||||
)
|
||||
|
||||
|
@ -299,6 +297,7 @@ class Tidy3DCloudTaskSocketDef(base.SocketDef):
|
|||
|
||||
def init(self, bl_socket: Tidy3DCloudTaskBLSocket) -> None:
|
||||
bl_socket.should_exist = self.should_exist
|
||||
bl_socket.use_prelock = True
|
||||
|
||||
|
||||
####################
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,36 @@
|
|||
# blender_maxwell
|
||||
# Copyright (C) 2024 blender_maxwell Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Package providing various tools to handle cached data on Blender objects, especially nodes and node socket classes."""
|
||||
|
||||
from .bl_field import BLField
|
||||
from .bl_prop import BLProp, BLPropType
|
||||
from .cached_bl_property import CachedBLProperty, cached_bl_property
|
||||
from .keyed_cache import KeyedCache, keyed_cache
|
||||
from .managed_cache import invalidate_nonpersist_instance_id
|
||||
from .signal import Signal
|
||||
|
||||
__all__ = [
|
||||
'BLField',
|
||||
'BLProp',
|
||||
'BLPropType',
|
||||
'CachedBLProperty',
|
||||
'cached_bl_property',
|
||||
'KeyedCache',
|
||||
'keyed_cache',
|
||||
'invalidate_nonpersist_instance_id',
|
||||
'Signal',
|
||||
]
|
|
@ -0,0 +1,424 @@
|
|||
# blender_maxwell
|
||||
# Copyright (C) 2024 blender_maxwell Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Implements various key caches on instances of Blender objects, especially nodes and sockets."""
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import typing as typ
|
||||
|
||||
import bpy
|
||||
|
||||
from blender_maxwell import contracts as ct
|
||||
from blender_maxwell.utils import bl_instance, logger
|
||||
|
||||
from .bl_prop import BLProp
|
||||
from .bl_prop_type import BLPropType
|
||||
from .signal import Signal
|
||||
|
||||
log = logger.get(__name__)
|
||||
|
||||
|
||||
StringPropSubType: typ.TypeAlias = typ.Literal[
|
||||
'FILE_PATH', 'DIR_PATH', 'FILE_NAME', 'BYTE_STRING', 'PASSWORD', 'NONE'
|
||||
]
|
||||
|
||||
StrMethod: typ.TypeAlias = typ.Callable[
|
||||
[bl_instance.BLInstance, bpy.types.Context, str], list[tuple[str, str]]
|
||||
]
|
||||
EnumMethod: typ.TypeAlias = typ.Callable[
|
||||
[bl_instance.BLInstance, bpy.types.Context], list[ct.BLEnumElement]
|
||||
]
|
||||
|
||||
DEFAULT_ENUM_ITEMS_SINGLE = [('NONE', 'None', 'No items...', '', 0)]
|
||||
DEFAULT_ENUM_ITEMS_MANY = [('NONE', 'None', 'No items...', '', 2**0)]
|
||||
|
||||
|
||||
@functools.cache
|
||||
def default_enum_items(enum_many: bool) -> list[ct.BLEnumElement]:
|
||||
return DEFAULT_ENUM_ITEMS_MANY if enum_many else DEFAULT_ENUM_ITEMS_SINGLE
|
||||
|
||||
|
||||
####################
|
||||
# - BLField
|
||||
####################
|
||||
class BLField:
|
||||
"""A descriptor that allows persisting arbitrary types in Blender objects, with cached reads."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default_value: typ.Any = None,
|
||||
use_prop_update: bool = True,
|
||||
## Static
|
||||
prop_ui: bool = False, ## TODO: Remove
|
||||
abs_min: int | float | None = None,
|
||||
abs_max: int | float | None = None,
|
||||
soft_min: int | float | None = None,
|
||||
soft_max: int | float | None = None,
|
||||
float_step: int | None = None,
|
||||
float_prec: int | None = None,
|
||||
str_secret: bool | None = None,
|
||||
path_type: typ.Literal['dir', 'file'] | None = None,
|
||||
# blptr_type: typ.Any | None = None, ## A Blender ID type
|
||||
## TODO: Test/Implement
|
||||
## Dynamic
|
||||
str_cb: StrMethod | None = None,
|
||||
enum_cb: EnumMethod | None = None,
|
||||
cb_depends_on: set[str] | None = None,
|
||||
) -> typ.Self:
|
||||
"""Initializes and sets the attribute to a given default value.
|
||||
|
||||
The attribute **must** declare a type annotation, and it **must** match the type of `default_value`.
|
||||
|
||||
Parameters:
|
||||
default_value: The default value to use if the value is read before it's set.
|
||||
use_prop_update: If True, `BLField` will consent to `bl_instance.on_prop_changed(attr_name)` being run whenever the field is changed.
|
||||
UI changes done to the property via Blender **always** trigger `bl_instance.on_bl_prop_changed`; however, the `BLField` decides whether `on_prop_changed` should be run as well.
|
||||
That control is offered through `use_prop_update`.
|
||||
abs_min: Sets the absolute minimum value of the property.
|
||||
Only meaningful for numerical properties.
|
||||
abs_max: Sets the absolute maximum value of the property.
|
||||
Only meaningful for numerical properties.
|
||||
soft_min: Sets a value which will feel like a minimum in the UI, but which can be overridden by setting a value directly.
|
||||
In practice, "scrolling" through values will stop here.
|
||||
Only meaningful for numerical properties.
|
||||
soft_max: Sets a value which will feel like a maximum in the UI, but which can be overridden by setting a value directly.
|
||||
In practice, "scrolling" through values will stop here.
|
||||
Only meaningful for numerical properties.
|
||||
float_step: Sets the interval (/100) of each step when "scrolling" through the values of a float property, aka. the speed.
|
||||
Only meaningful for float-like properties.
|
||||
float_step: Sets the decimal places of precision to display.
|
||||
Only meaningful for float-like properties.
|
||||
str_secret: Marks the string as "secret", which prevents its save-persistance, and causes the UI to display dots instead of characters.
|
||||
**DO NOT** rely on this property for "real" security.
|
||||
_If in doubt, this isn't good enough._
|
||||
Only meaningful for `str` properties.
|
||||
path_type: Makes the path as pointing to a folder or to a file.
|
||||
Only meaningful for `pathlib.Path` properties.
|
||||
**NOTE**: No effort is made to make paths portable between operating systems.
|
||||
Use with care.
|
||||
str_cb: Method used to determine all valid strings, which presents to the user as a fuzzy-style search dropdown.
|
||||
Only meaningful for `str` properties.
|
||||
Results are not persisted, and must therefore re-run when reloading the file.
|
||||
Otherwise, it is cached, but is re-run whenever `Signal.ResetStrSearch` is set.
|
||||
enum_cb: Method used to determine all valid enum elements, which presents to the user as a dropdown.
|
||||
The caveats with dynamic `bpy.props.EnumProperty`s are **absurdly sharp**.
|
||||
Those caveats are entirely mitigated when using this callback, at the cost of manual resets.
|
||||
Is re-run whenever `Signal.ResetEnumItems` is set, and otherwise cached both persistently and non-persistently.
|
||||
cb_depends_on: Declares that `str_cb` / `enum_cb` should be regenerated whenever any of the given property names change.
|
||||
This allows fully automating the invocation of `Signal.ResetEnumItems` / `Signal.ResetStrSearch` in common cases.
|
||||
"""
|
||||
log.debug(
|
||||
'Initializing BLField (default_value=%s, use_prop_update=%s)',
|
||||
str(default_value),
|
||||
str(use_prop_update),
|
||||
)
|
||||
|
||||
self.use_dynamic_enum = enum_cb is not None
|
||||
self.use_str_search = str_cb is not None
|
||||
|
||||
## TODO: Use prop_flags
|
||||
self.prop_info = {
|
||||
'default': default_value,
|
||||
'use_prop_update': use_prop_update,
|
||||
# Int* | Float*: Bounds
|
||||
'min': abs_min,
|
||||
'max': abs_max,
|
||||
'soft_min': soft_min,
|
||||
'soft_max': soft_max,
|
||||
# Float*: UI
|
||||
'step': float_step,
|
||||
'precision': float_prec,
|
||||
# BLPointer: ID Type
|
||||
#'blptr_type': blptr_type,
|
||||
# Str | Path | Enum: Flag Setters
|
||||
'str_secret': str_secret,
|
||||
'path_type': path_type,
|
||||
# Search: Str
|
||||
'str_search': self.use_str_search,
|
||||
'safe_str_cb': lambda _self, context, edit_text: self.safe_str_cb(
|
||||
_self, context, edit_text
|
||||
),
|
||||
# Search: Enum
|
||||
'enum_dynamic': self.use_dynamic_enum,
|
||||
'safe_enum_cb': lambda _self, context: self.safe_enum_cb(_self, context),
|
||||
}
|
||||
|
||||
# BLProp
|
||||
self.bl_prop: BLProp | None = None
|
||||
self.bl_prop_enum_items: BLProp | None = None
|
||||
self.bl_prop_str_search: BLProp | None = None
|
||||
|
||||
self.enum_cb = enum_cb
|
||||
self.str_cb = str_cb
|
||||
|
||||
self.cb_depends_on: set[str] | None = cb_depends_on
|
||||
|
||||
# Update Suppressing
|
||||
self.suppress_update: dict[str, bool] = {}
|
||||
|
||||
####################
|
||||
# - Descriptor Setup
|
||||
####################
|
||||
def __set_name__(self, owner: type[bl_instance.BLInstance], name: str) -> None:
|
||||
"""Sets up this descriptor on the class, preparing it for per-instance use.
|
||||
|
||||
A `BLProp` is constructed using this descriptor's attribute name on `owner`, and the `self.prop_info` previously created during `self.__init__()`.
|
||||
Then, a corresponding / underlying `bpy.types.Property` is initialized on `owner` using `self.bl_prop.init_bl_type(owner)`
|
||||
|
||||
Notes:
|
||||
Run by Python when setting an instance of a "descriptor" class, to an attribute of another class (denoted `owner`).
|
||||
For more, search for "Python descriptor protocol".
|
||||
|
||||
Parameters:
|
||||
owner: The class that contains an attribute assigned to an instance of this descriptor.
|
||||
name: The name of the attribute that an instance of descriptor was assigned to.
|
||||
"""
|
||||
prop_type = inspect.get_annotations(owner).get(name)
|
||||
self.bl_prop = BLProp(
|
||||
name=name,
|
||||
prop_info=self.prop_info,
|
||||
prop_type=prop_type,
|
||||
bl_prop_type=BLPropType.from_type(prop_type),
|
||||
)
|
||||
|
||||
# Initialize Field on BLClass
|
||||
self.bl_prop.init_bl_type(
|
||||
owner,
|
||||
enum_depends_on=self.cb_depends_on,
|
||||
strsearch_depends_on=self.cb_depends_on,
|
||||
)
|
||||
|
||||
# Dynamic Enum: Initialize Persistent Enum Items
|
||||
if self.prop_info['enum_dynamic']:
|
||||
self.bl_prop_enum_items = BLProp(
|
||||
name=self.bl_prop.enum_cache_key,
|
||||
prop_info={'default': [], 'use_prop_update': False},
|
||||
prop_type=list[ct.BLEnumElement],
|
||||
bl_prop_type=BLPropType.Serialized,
|
||||
)
|
||||
self.bl_prop_enum_items.init_bl_type(owner)
|
||||
|
||||
# Searched Str: Initialize Persistent Str List
|
||||
if self.prop_info['str_search']:
|
||||
self.bl_prop_str_search = BLProp(
|
||||
name=self.bl_prop.str_cache_key,
|
||||
prop_info={'default': [], 'use_prop_update': False},
|
||||
prop_type=list[str],
|
||||
bl_prop_type=BLPropType.Serialized,
|
||||
)
|
||||
self.bl_prop_str_search.init_bl_type(owner)
|
||||
|
||||
def __get__(
|
||||
self,
|
||||
bl_instance: bl_instance.BLInstance | None,
|
||||
owner: type[bl_instance.BLInstance],
|
||||
) -> typ.Any:
|
||||
"""Retrieves the value described by the BLField.
|
||||
|
||||
Notes:
|
||||
Run by Python when the attribute described by the descriptor is accessed.
|
||||
For more, search for "Python descriptor protocol".
|
||||
|
||||
Parameters:
|
||||
bl_instance: Instance that is accessing the attribute.
|
||||
owner: The class that owns the instance.
|
||||
"""
|
||||
# Compute Value (if available)
|
||||
cached_value = self.bl_prop.read_nonpersist(bl_instance)
|
||||
if cached_value is Signal.CacheNotReady or cached_value is Signal.CacheEmpty:
|
||||
if bl_instance is not None:
|
||||
persisted_value = self.bl_prop.read(bl_instance)
|
||||
self.bl_prop.write_nonpersist(bl_instance, persisted_value)
|
||||
return persisted_value
|
||||
return self.bl_prop.default_value ## TODO: Good idea?
|
||||
return cached_value
|
||||
|
||||
def suppress_next_update(self, bl_instance) -> None:
|
||||
self.suppress_update[bl_instance.instance_id] = True
|
||||
## TODO: Make it a context manager to prevent the worst of surprises
|
||||
|
||||
def __set__(
|
||||
self, bl_instance: bl_instance.BLInstance | None, value: typ.Any
|
||||
) -> None:
|
||||
"""Sets the value described by the BLField.
|
||||
|
||||
In general, any BLField modified in the UI will set `InvalidateCache` on this descriptor.
|
||||
If `self.prop_info['use_prop_update']` is set, the method `bl_instance.on_prop_changed(self.bl_prop.name)` will then be called and start a `FlowKind.DataChanged` event chain.
|
||||
|
||||
Notes:
|
||||
Run by Python when the attribute described by the descriptor is set.
|
||||
For more, search for "Python descriptor protocol".
|
||||
|
||||
Parameters:
|
||||
bl_instance: Instance that is accessing the attribute.
|
||||
owner: The class that owns the instance.
|
||||
"""
|
||||
# Perform Update Chain
|
||||
## -> We still respect 'use_prop_update', since it is user-sourced.
|
||||
if value is Signal.DoUpdate:
|
||||
if self.prop_info['use_prop_update']:
|
||||
bl_instance.on_prop_changed(self.bl_prop.name)
|
||||
|
||||
# Invalidate Cache
|
||||
## -> This empties the non-persistent cache.
|
||||
## -> As a result, the value must be reloaded from the property.
|
||||
## The 'on_prop_changed' method on the bl_instance might also be called.
|
||||
elif value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate:
|
||||
self.bl_prop.invalidate_nonpersist(bl_instance)
|
||||
|
||||
# Update Suppression
|
||||
if self.suppress_update.get(bl_instance.instance_id):
|
||||
self.suppress_update[bl_instance.instance_id] = False
|
||||
|
||||
# ELSE: Trigger Update Chain
|
||||
elif self.prop_info['use_prop_update'] and value is Signal.InvalidateCache:
|
||||
bl_instance.on_prop_changed(self.bl_prop.name)
|
||||
|
||||
# Reset Enum Items
|
||||
elif value is Signal.ResetEnumItems:
|
||||
# Retrieve Old Items
|
||||
## -> This is verbatim what is being persisted, currently.
|
||||
## -> len(0): Manually replaced w/fallback to guarantee >=len(1)
|
||||
## -> Fallback element is 'NONE'.
|
||||
_old_items: list[ct.BLEnumElement] = self.bl_prop_enum_items.read(
|
||||
bl_instance
|
||||
)
|
||||
old_items = (
|
||||
_old_items
|
||||
if _old_items
|
||||
else default_enum_items(self.bl_prop.is_enum_many)
|
||||
)
|
||||
|
||||
# Retrieve Current Items
|
||||
## -> len(0): Manually replaced w/fallback to guarantee >=len(1)
|
||||
## -> Manually replaced fallback element is 'NONE'.
|
||||
_current_items: list[ct.BLEnumElement] = self.enum_cb(bl_instance, None)
|
||||
current_items = (
|
||||
_current_items
|
||||
if _current_items
|
||||
else default_enum_items(self.bl_prop.is_enum_many)
|
||||
)
|
||||
|
||||
# Compare Old | Current
|
||||
## -> We don't involve non-persistent caches (they lie!)
|
||||
## -> Since we persist the user callback directly, we can compare.
|
||||
if old_items != current_items:
|
||||
# Retrieve Old Enum Item
|
||||
## -> This is verbatim what is being used.
|
||||
## -> De-Coerce None -> 'NONE' to avoid special-cased search.
|
||||
_old_item = self.bl_prop.read(bl_instance)
|
||||
old_item = 'NONE' if _old_item is None else _old_item
|
||||
|
||||
# Swap Enum Items
|
||||
## -> This is the hot stuff - the enum elements are overwritten.
|
||||
## -> The safe_enum_cb will pick up on this immediately.
|
||||
self.suppress_next_update(bl_instance)
|
||||
self.bl_prop_enum_items.write(bl_instance, current_items)
|
||||
|
||||
# Old Item in Current Items
|
||||
## -> It's possible that the old enum key is in the new enum.
|
||||
## -> If so, the user will expect it to "remain".
|
||||
## -> Thus, we set it - Blender sees a change, user doesn't.
|
||||
## -> DO NOT trigger on_prop_changed (since "nothing changed").
|
||||
if any(old_item == item[0] for item in current_items):
|
||||
self.suppress_next_update(bl_instance)
|
||||
self.bl_prop.write(bl_instance, old_item)
|
||||
## -> TODO: Don't write if not needed.
|
||||
|
||||
# Old Item Not in Current Items
|
||||
## -> In this case, fallback to the first current item.
|
||||
## -> DO trigger on_prop_changed (since it changed!)
|
||||
else:
|
||||
_first_current_item = current_items[0][0]
|
||||
first_current_item = (
|
||||
_first_current_item if _first_current_item != 'NONE' else None
|
||||
)
|
||||
|
||||
self.suppress_next_update(bl_instance)
|
||||
self.bl_prop.write(bl_instance, first_current_item)
|
||||
|
||||
if self.prop_info['use_prop_update']:
|
||||
bl_instance.on_prop_changed(self.bl_prop.name)
|
||||
|
||||
# Reset Str Search
|
||||
elif value is Signal.ResetStrSearch:
|
||||
self.bl_prop_str_search.invalidate_nonpersist(bl_instance)
|
||||
|
||||
# General __set__
|
||||
else:
|
||||
self.bl_prop.write(bl_instance, value)
|
||||
|
||||
# Update Semantics
|
||||
if self.suppress_update.get(bl_instance.instance_id):
|
||||
self.suppress_update[bl_instance.instance_id] = False
|
||||
|
||||
elif self.prop_info['use_prop_update']:
|
||||
bl_instance.on_prop_changed(self.bl_prop.name)
|
||||
|
||||
####################
|
||||
# - Safe Callbacks
|
||||
####################
|
||||
def safe_str_cb(
|
||||
self, _self: bl_instance.BLInstance, context: bpy.types.Context, edit_text: str
|
||||
):
|
||||
"""Wrapper around `StringProperty.search` which keeps a non-persistent cache around search results.
|
||||
|
||||
Reset by setting the descriptor to `Signal.ResetStrSearch`.
|
||||
"""
|
||||
cached_items = self.bl_prop_str_search.read_nonpersist(_self)
|
||||
if cached_items is not Signal.CacheNotReady:
|
||||
if cached_items is Signal.CacheEmpty:
|
||||
computed_items = self.str_cb(_self, context, edit_text)
|
||||
self.bl_prop_str_search.write_nonpersist(_self, computed_items)
|
||||
return computed_items
|
||||
return cached_items
|
||||
return []
|
||||
|
||||
def safe_enum_cb(
|
||||
self, _self: bl_instance.BLInstance, context: bpy.types.Context
|
||||
) -> list[ct.BLEnumElement]:
|
||||
"""Wrapper around `EnumProperty.items` callback, which **guarantees** that returned strings will not be GCed by keeping a persistent + non-persistent cache.
|
||||
|
||||
When a persistent cache exists, then the non-persistent cache will be filled at-will, since this is always guaranteed possible.
|
||||
Otherwise, the persistent cache will only be regenerated when `Signal.ResetEnumItems` is run.
|
||||
The original callback won't ever run other than then.
|
||||
|
||||
Until then, `DEFAULT_ENUM_ITEMS_MANY` or `DEFAULT_ENUM_ITEMS_SINGLE` will be used as defaults (guaranteed to not dereference so long as the module is loaded).
|
||||
"""
|
||||
# Compute Value (if available)
|
||||
cached_items = self.bl_prop_enum_items.read_nonpersist(_self)
|
||||
if cached_items is Signal.CacheNotReady or cached_items is Signal.CacheEmpty:
|
||||
if _self is not None:
|
||||
persisted_items = self.bl_prop_enum_items.read(_self)
|
||||
if not persisted_items:
|
||||
computed_items = self.enum_cb(_self, context)
|
||||
_items = computed_items
|
||||
else:
|
||||
_items = persisted_items
|
||||
else:
|
||||
computed_items = self.enum_cb(_self, context)
|
||||
_items = computed_items
|
||||
|
||||
# Fallback for Empty Persisted Items
|
||||
## -> Use [('NONE', ...)]
|
||||
## -> This guarantees that the enum items always has >=len(1)
|
||||
items = _items if _items else default_enum_items(self.bl_prop.is_enum_many)
|
||||
|
||||
# Write Items -> Non-Persistent Cache
|
||||
self.bl_prop_enum_items.write_nonpersist(_self, items)
|
||||
return items
|
||||
return cached_items
|
|
@ -0,0 +1,235 @@
|
|||
# blender_maxwell
|
||||
# Copyright (C) 2024 blender_maxwell Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Defines `BLProp`, a high-level wrapper for interacting with Blender properties."""
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
import typing as typ
|
||||
|
||||
from blender_maxwell.utils import bl_instance, logger
|
||||
|
||||
from . import managed_cache
|
||||
from .bl_prop_type import BLPropInfo, BLPropType
|
||||
from .signal import Signal
|
||||
|
||||
log = logger.get(__name__)
|
||||
|
||||
|
||||
####################
|
||||
# - Blender Property (Abstraction)
|
||||
####################
|
||||
@dataclasses.dataclass(kw_only=True, frozen=True)
|
||||
class BLProp:
|
||||
"""A high-level wrapper encapsulating access to a Blender property.
|
||||
|
||||
Attributes:
|
||||
name: The name of the Blender property, as one uses it.
|
||||
prop_info: Specifies the property's particular behavior, including subtype and UI.
|
||||
prop_type: The type to associate with the property.
|
||||
Especially relevant for structured deserialization.
|
||||
bl_prop_type: Identifier encapsulating which Blender property used for data storage, and how.
|
||||
"""
|
||||
|
||||
name: str
|
||||
prop_info: BLPropInfo ## TODO: Validate / Typing
|
||||
prop_type: type
|
||||
bl_prop_type: BLPropType
|
||||
|
||||
####################
|
||||
# - Computed
|
||||
####################
|
||||
@functools.cached_property
|
||||
def bl_name(self):
|
||||
"""Deduces the actual attribute name at which the Blender property will be available."""
|
||||
return f'blfield__{self.name}'
|
||||
|
||||
@functools.cached_property
|
||||
def enum_cache_key(self):
|
||||
"""Deduces an attribute name for use by the persistent cache component of `EnumProperty.items`.
|
||||
|
||||
For dynamic enums, a persistent cache is not enough - a non-persistent cache must also be used to guarantee that returned strings will not dereference.
|
||||
**Letting dynamic enum strings dereference causes Blender to crash**.
|
||||
|
||||
Use of a non-persistent cache alone introduces a massive startup burden, as _all_ of the potentially expensive `EnumProperty.items` methods must re-run.
|
||||
Should any depend on ex. internet connectivity, which is no longer available, elaborate failure modes may trigger.
|
||||
|
||||
By using this key, we can persist `items` for re-caching on startup, to reap the benefits of both schemes and make dynamic `EnumProperty` usable in practice.
|
||||
"""
|
||||
return self.name + '__enum_cache'
|
||||
|
||||
@functools.cached_property
|
||||
def str_cache_key(self):
|
||||
"""Deduce an internal name for string-search names distinct from the property name.
|
||||
|
||||
Compared to dynamic enums, string-search names are very gentle.
|
||||
However, the mechanism is otherwise almost same, so similar logic makes a lot of sense.
|
||||
"""
|
||||
return self.name + '__str_cache'
|
||||
|
||||
@functools.cached_property
|
||||
def display_name(self):
|
||||
"""Deduce a display name for the Blender property, assigned to the `name=` attribute."""
|
||||
return (
|
||||
'[JSON] ' if self.bl_prop_type == BLPropType.Serialized else ''
|
||||
) + f'BLField: {self.name}'
|
||||
|
||||
@functools.cached_property
|
||||
def is_enum_many(self):
|
||||
return self.bl_prop_type in [BLPropType.SetEnum, BLPropType.SetDynEnum]
|
||||
|
||||
####################
|
||||
# - Low-Level Methods
|
||||
####################
|
||||
def encode(self, value: typ.Any):
|
||||
"""Encode a value for compatibility with this Blender property, using the encapsulated types.
|
||||
|
||||
A convenience method for `BLPropType.encode()`.
|
||||
"""
|
||||
return self.bl_prop_type.encode(value)
|
||||
|
||||
@functools.cached_property
|
||||
def default_value(self) -> typ.Any:
|
||||
return self.prop_info.get('default')
|
||||
|
||||
def decode(self, value: typ.Any):
|
||||
"""Encode a value for compatibility with this Blender property, using the encapsulated types.
|
||||
|
||||
A convenience method for `BLPropType.decode()`.
|
||||
"""
|
||||
return self.bl_prop_type.decode(value, self.prop_type)
|
||||
|
||||
####################
|
||||
# - Initialization
|
||||
####################
|
||||
def init_bl_type(
|
||||
self,
|
||||
bl_type: type[bl_instance.BLInstance],
|
||||
depends_on: frozenset[str] = frozenset(),
|
||||
enum_depends_on: frozenset[str] | None = None,
|
||||
strsearch_depends_on: frozenset[str] | None = None,
|
||||
) -> None:
|
||||
"""Declare the Blender property on a Blender class, ensuring that the property will be available to all `bl_instance.BLInstance` respecting instances of that class.
|
||||
|
||||
- **Declare BLField**: Runs `bl_type.declare_blfield()` to ensure that `on_prop_changed` will invalidate the cache for this property.
|
||||
- **Set Property**: Runs `bl_type.set_prop()` to ensure that the Blender property will be available on instances of `bl_type`.
|
||||
|
||||
Parameters:
|
||||
obj_type: The exact object type that will be stored in the Blender property.
|
||||
**Must** be chosen such that `BLPropType.from_type(obj_type) == self`.
|
||||
"""
|
||||
# Parse KWArgs for Blender Property
|
||||
kwargs_prop = self.bl_prop_type.parse_kwargs(
|
||||
self.prop_type,
|
||||
self.prop_info,
|
||||
)
|
||||
|
||||
# Set Blender Property
|
||||
bl_type.declare_blfield(
|
||||
self.name,
|
||||
self.bl_name,
|
||||
use_dynamic_enum=self.prop_info.get('enum_dynamic', False),
|
||||
use_str_search=self.prop_info.get('str_search', False),
|
||||
)
|
||||
bl_type.set_prop(
|
||||
self.bl_name,
|
||||
self.bl_prop_type.bl_prop,
|
||||
# Property Options
|
||||
name=self.display_name,
|
||||
**kwargs_prop,
|
||||
) ## TODO: Parse __doc__ for property descs
|
||||
|
||||
for src_prop_name in depends_on:
|
||||
bl_type.declare_blfield_dep(src_prop_name, self.name)
|
||||
|
||||
if self.prop_info.get('enum_dynamic', False) and enum_depends_on is not None:
|
||||
for src_prop_name in enum_depends_on:
|
||||
bl_type.declare_blfield_dep(
|
||||
src_prop_name, self.name, method='reset_enum'
|
||||
)
|
||||
|
||||
if self.prop_info.get('str_search', False) and strsearch_depends_on is not None:
|
||||
for src_prop_name in strsearch_depends_on:
|
||||
bl_type.declare_blfield_dep(
|
||||
src_prop_name, self.name, method='reset_strsearch'
|
||||
)
|
||||
|
||||
####################
|
||||
# - Instance Methods
|
||||
####################
|
||||
def read_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> typ.Any:
|
||||
"""Read the non-persistent cache value for this property.
|
||||
|
||||
Returns:
|
||||
Generally, the cache value, with two exceptions.
|
||||
|
||||
- `Signal.CacheNotReady`: When either `bl_instance` is None, or it doesn't yet have a unique `bl_instance.instance_id`.
|
||||
Indicates that the instance is not yet ready for use.
|
||||
For nodes, `init()` has not yet run.
|
||||
For sockets, `preinit()` has not yet run.
|
||||
|
||||
- `Signal.CacheEmpty`: When the cache has no entry.
|
||||
A good idea might be to fill it immediately with `self.write_nonpersist(bl_instance)`.
|
||||
"""
|
||||
return managed_cache.read(
|
||||
bl_instance,
|
||||
self.bl_name,
|
||||
use_nonpersist=True,
|
||||
use_persist=False,
|
||||
)
|
||||
|
||||
def read(self, bl_instance: bl_instance.BLInstance) -> typ.Any:
|
||||
"""Read the Blender property's particular value on the given `bl_instance`."""
|
||||
persisted_value = self.decode(
|
||||
managed_cache.read(
|
||||
bl_instance,
|
||||
self.bl_name,
|
||||
use_nonpersist=False,
|
||||
use_persist=True,
|
||||
)
|
||||
)
|
||||
if persisted_value is not Signal.CacheEmpty:
|
||||
return persisted_value
|
||||
|
||||
msg = f"{self.name}: Can't read BLProp from instance {bl_instance}"
|
||||
raise ValueError(msg)
|
||||
|
||||
def write(self, bl_instance: bl_instance.BLInstance, value: typ.Any) -> None:
|
||||
managed_cache.write(
|
||||
bl_instance,
|
||||
self.bl_name,
|
||||
self.encode(value),
|
||||
use_nonpersist=False,
|
||||
use_persist=True,
|
||||
)
|
||||
self.write_nonpersist(bl_instance, value)
|
||||
|
||||
def write_nonpersist(
|
||||
self, bl_instance: bl_instance.BLInstance, value: typ.Any
|
||||
) -> None:
|
||||
managed_cache.write(
|
||||
bl_instance,
|
||||
self.bl_name,
|
||||
value,
|
||||
use_nonpersist=True,
|
||||
use_persist=False,
|
||||
)
|
||||
|
||||
def invalidate_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> None:
|
||||
managed_cache.invalidate_nonpersist(
|
||||
bl_instance,
|
||||
self.bl_name,
|
||||
)
|
|
@ -0,0 +1,755 @@
|
|||
# blender_maxwell
|
||||
# Copyright (C) 2024 blender_maxwell Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Defines `BLPropType`, which provides stronger lower-level interfaces for interacting with data that can be conformed to work with Blender properties."""
|
||||
|
||||
import builtins
|
||||
import enum
|
||||
import functools
|
||||
import inspect
|
||||
import pathlib
|
||||
import typing as typ
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
from blender_maxwell import contracts as ct
|
||||
from blender_maxwell.utils import logger, serialize
|
||||
from blender_maxwell.utils.staticproperty import staticproperty
|
||||
|
||||
from .signal import Signal
|
||||
|
||||
log = logger.get(__name__)
|
||||
|
||||
####################
|
||||
# - Types
|
||||
####################
|
||||
BLIDStructs = typ.get_args(ct.BLIDStruct)
|
||||
Shape: typ.TypeAlias = None | tuple[int, ...]
|
||||
BLPropInfo: typ.TypeAlias = dict[str, typ.Any]
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _parse_vector_size(obj_type: type[tuple[int, ...]]) -> int:
|
||||
"""Parse the size of an arbitrarily sized generic tuple type, which is representing a vector.
|
||||
|
||||
Parameters:
|
||||
obj_type: The type of a flat, generic tuple integer, representing a static vector shape.
|
||||
|
||||
Returns:
|
||||
The length of any object that has the given type.
|
||||
"""
|
||||
return len(typ.get_args(obj_type))
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _parse_matrix_size(obj_type: type[tuple[int, ...], ...]) -> tuple[int, int]:
|
||||
"""Parse the rows and columns of an arbitrarily sized generic tuple-of-tuple type, which is representing a row-major matrix.
|
||||
|
||||
Parameters:
|
||||
obj_type: The type of a singly-nested, generic tuple integer, representing a static matrix shape.
|
||||
|
||||
Returns:
|
||||
The rows and columns of any object that has the given type.
|
||||
"""
|
||||
rows = len(typ.get_args(obj_type))
|
||||
cols = len(typ.get_args(typ.get_args(obj_type)[0]))
|
||||
|
||||
for i, col_generic in enumerate(typ.get_args(obj_type)):
|
||||
col_els = typ.get_args(col_generic)
|
||||
if len(col_els) != cols:
|
||||
msg = f'Value {obj_type} has mismatching column length {i} (to column 0)'
|
||||
raise ValueError(msg)
|
||||
|
||||
return (rows, cols)
|
||||
|
||||
|
||||
def _is_strenum(T: type) -> bool: # noqa: N803
|
||||
return inspect.isclass(T) and issubclass(T, enum.StrEnum)
|
||||
|
||||
|
||||
####################
|
||||
# - Blender Property Type
|
||||
####################
|
||||
class BLPropType(enum.StrEnum):
|
||||
"""A type identifier which can be directly associated with a Blender property.
|
||||
|
||||
For general use, the higher-level interface `BLProp` is more appropriate.
|
||||
|
||||
This is a low-level interface to Blender properties, allowing for directly identifying and utilizing a subset of types that are trivially representable using Blender's property system.
|
||||
This hard-coded approach is especially required when managing the nuances of UI methods.
|
||||
|
||||
`BLPropType` should generally be treated as a "dumb" enum identifying the low-level representation of an object in a Blender property.
|
||||
Use of `BLPropType.from_type` is encouraged; use of other methods is generally discouraged outside of higher-level encapsulating interfaces.
|
||||
|
||||
|
||||
Attributes:
|
||||
Bool: A boolean.
|
||||
Int: An integer.
|
||||
Float: A floating point number.
|
||||
BoolVector: Between 2 and 32 booleans.
|
||||
IntVector: Between 2 and 32 integers.
|
||||
FloatVector: Between 2 and 32 floats.
|
||||
BoolVector: 2D booleans of 2 - 32 elements per axis.
|
||||
IntVector: 2D integers of 2 - 32 elements per axis.
|
||||
FloatVector: 2D floats of 2 - 32 elements per axis.
|
||||
Str: A simple string.
|
||||
Path: A particular filesystem path.
|
||||
SingleEnum: A single string value from a statically known `StrEnum`.
|
||||
SetEnum: A set of string values, each from a statically known `StrEnum`.
|
||||
SingleDynEnum: A single string value from a dynamically computed set of string values.
|
||||
SetDynEnum: A set of string value, each from a dynamically computed set of string values.
|
||||
BLPointer: A reference to a Blender object.
|
||||
Blender manages correctly reconstructing this reference on startup, and the underlying pointer value is not stable.
|
||||
Serialized: An arbitrary, serialized representation of an object.
|
||||
"""
|
||||
|
||||
# Scalar
|
||||
Bool = enum.auto()
|
||||
Int = enum.auto()
|
||||
Float = enum.auto()
|
||||
## TODO: Support complex
|
||||
|
||||
# Vector
|
||||
BoolVector = enum.auto()
|
||||
IntVector = enum.auto()
|
||||
FloatVector = enum.auto()
|
||||
|
||||
# Matrix
|
||||
BoolMatrix = enum.auto()
|
||||
IntMatrix = enum.auto()
|
||||
FloatMatrix = enum.auto()
|
||||
|
||||
## TODO: Support jaxtyping JAX arrays (as serialized) directly?
|
||||
|
||||
# String
|
||||
Str = enum.auto()
|
||||
Path = enum.auto()
|
||||
## TODO: OS checks for Path
|
||||
|
||||
# Enums
|
||||
SingleEnum = enum.auto()
|
||||
SetEnum = enum.auto()
|
||||
|
||||
SingleDynEnum = enum.auto()
|
||||
SetDynEnum = enum.auto()
|
||||
|
||||
# Special
|
||||
BLPointer = enum.auto()
|
||||
Serialized = enum.auto()
|
||||
|
||||
####################
|
||||
# - Static
|
||||
####################
|
||||
@staticproperty
|
||||
def vector_types() -> frozenset[typ.Self]:
|
||||
"""The set of `BLPropType`s that are considered "vectors"."""
|
||||
BPT = BLPropType
|
||||
return frozenset({BPT.BoolVector, BPT.IntVector, BPT.FloatVector})
|
||||
|
||||
@staticproperty
|
||||
def matrix_types() -> frozenset[typ.Self]:
|
||||
"""The set of `BLPropType`s that are considered "matrices"."""
|
||||
BPT = BLPropType
|
||||
return frozenset({BPT.BoolMatrix, BPT.IntMatrix, BPT.FloatMatrix})
|
||||
|
||||
####################
|
||||
# - Computed
|
||||
####################
|
||||
@functools.cached_property
|
||||
def is_vector(self) -> bool:
|
||||
"""Checks whether this `BLPropType` is considered a vector.
|
||||
|
||||
Returns:
|
||||
A boolean indicating "vectorness".
|
||||
"""
|
||||
return self in BLPropType.vector_types
|
||||
|
||||
@functools.cached_property
|
||||
def is_matrix(self) -> bool:
|
||||
"""Checks whether this `BLPropType` is considered a matrix.
|
||||
|
||||
Returns:
|
||||
A boolean indicating "matrixness".
|
||||
"""
|
||||
return self in BLPropType.matrix_types
|
||||
|
||||
@functools.cached_property
|
||||
def bl_prop(self) -> bpy.types.Property:
|
||||
"""Deduce which `bpy.props.*` type should implement this `BLPropType` in practice.
|
||||
|
||||
In practice, `self.parse_kwargs()` collects arguments usable by the type returned by this property.
|
||||
Thus, this property provides the key bridge between `BLPropType` and vanilla Blender properties.
|
||||
|
||||
Returns:
|
||||
A Blender property type, for use as a constructor.
|
||||
"""
|
||||
BPT = BLPropType
|
||||
return {
|
||||
# Scalar
|
||||
BPT.Bool: bpy.props.BoolProperty,
|
||||
BPT.Int: bpy.props.IntProperty,
|
||||
BPT.Float: bpy.props.FloatProperty,
|
||||
# Vector
|
||||
BPT.BoolVector: bpy.props.BoolVectorProperty,
|
||||
BPT.IntVector: bpy.props.IntVectorProperty,
|
||||
BPT.FloatVector: bpy.props.FloatVectorProperty,
|
||||
# Matrix
|
||||
BPT.BoolMatrix: bpy.props.BoolVectorProperty,
|
||||
BPT.IntMatrix: bpy.props.IntVectorProperty,
|
||||
BPT.FloatMatrix: bpy.props.FloatVectorProperty,
|
||||
# String
|
||||
BPT.Str: bpy.props.StringProperty,
|
||||
BPT.Path: bpy.props.StringProperty,
|
||||
# Enum
|
||||
BPT.SingleEnum: bpy.props.EnumProperty,
|
||||
BPT.SetEnum: bpy.props.EnumProperty,
|
||||
BPT.SingleDynEnum: bpy.props.EnumProperty,
|
||||
BPT.SetDynEnum: bpy.props.EnumProperty,
|
||||
# Special
|
||||
BPT.BLPointer: bpy.props.PointerProperty,
|
||||
BPT.Serialized: bpy.props.StringProperty,
|
||||
}[self]
|
||||
|
||||
@functools.cached_property
|
||||
def primitive_type(self) -> type:
|
||||
"""The "primitive" type representable using this property.
|
||||
|
||||
Generally, "primitive" types are Python standard library types.
|
||||
However, exceptions may exist for a ubiquitously used type.
|
||||
|
||||
Returns:
|
||||
A type guaranteed to be representable as a Blender property via. `self.encode()`.
|
||||
|
||||
Note that any relevant constraints on the type are not taken into account in this type.
|
||||
For example, `SingleEnum` has `str`, even though all strings are not valid.
|
||||
Similarly for ex. non-negative integers simply returning `int`.
|
||||
"""
|
||||
BPT = BLPropType
|
||||
return {
|
||||
# Scalar
|
||||
BPT.Bool: bool,
|
||||
BPT.Int: int,
|
||||
BPT.Float: float,
|
||||
# Vector
|
||||
BPT.BoolVector: bool,
|
||||
BPT.IntVector: int,
|
||||
BPT.FloatVector: float,
|
||||
# Matrix
|
||||
BPT.BoolMatrix: bool,
|
||||
BPT.IntMatrix: int,
|
||||
BPT.FloatMatrix: float,
|
||||
# String
|
||||
BPT.Str: str,
|
||||
BPT.Path: Path,
|
||||
# Enum
|
||||
BPT.SingleEnum: str,
|
||||
BPT.SetEnum: set[str],
|
||||
BPT.SingleDynEnum: str,
|
||||
BPT.SetDynEnum: set[str],
|
||||
# Special
|
||||
BPT.BLPointer: None,
|
||||
BPT.Serialized: str,
|
||||
}[self]
|
||||
|
||||
####################
|
||||
# - Parser Methods
|
||||
####################
|
||||
def parse_size(self, obj_type: type) -> Shape:
|
||||
"""Retrieve the shape / shape of data associated with this `BLPropType`.
|
||||
|
||||
Returns:
|
||||
Vectors have `(size,)`.
|
||||
Matrices have `(rows, cols)`.
|
||||
|
||||
Otherwise, `None` indicates a single value/scalar.
|
||||
"""
|
||||
BPT = BLPropType
|
||||
|
||||
match self:
|
||||
case BPT.BoolVector | BPT.IntVector | BPT.FloatVector:
|
||||
return _parse_vector_size(obj_type)
|
||||
case BPT.BoolMatrix | BPT.IntMatrix | BPT.FloatMatrix:
|
||||
return _parse_matrix_size(obj_type)
|
||||
case _:
|
||||
return None
|
||||
|
||||
####################
|
||||
# - KWArg Parsers
|
||||
####################
|
||||
@functools.cached_property
|
||||
def required_info(self) -> list[str]:
|
||||
"""Retrieve a list of required keyword arguments to the constructor returned by `self.bl_prop`.
|
||||
|
||||
Mainly useful via `self.check_info()`.
|
||||
|
||||
Returns:
|
||||
A list of required keys for the Blender property constructor.
|
||||
"""
|
||||
BPT = BLPropType
|
||||
return {
|
||||
# Scalar
|
||||
BPT.Bool: ['default'],
|
||||
BPT.Int: ['default'],
|
||||
BPT.Float: ['default'],
|
||||
# Vector
|
||||
BPT.BoolVector: ['default'],
|
||||
BPT.IntVector: ['default'],
|
||||
BPT.FloatVector: ['default'],
|
||||
# Matrix
|
||||
BPT.BoolMatrix: ['default'],
|
||||
BPT.IntMatrix: ['default'],
|
||||
BPT.FloatMatrix: ['default'],
|
||||
# String
|
||||
BPT.Str: ['default', 'str_search'],
|
||||
BPT.Path: ['default', 'path_type'],
|
||||
# Enum
|
||||
BPT.SingleEnum: ['default'],
|
||||
BPT.SetEnum: ['default'],
|
||||
BPT.SingleDynEnum: ['enum_dynamic'],
|
||||
BPT.SetDynEnum: ['enum_dynamic'],
|
||||
# Special
|
||||
BPT.BLPointer: ['blptr_type'],
|
||||
BPT.Serialized: [],
|
||||
}[self]
|
||||
|
||||
def check_info(self, prop_info: BLPropInfo) -> bool:
|
||||
"""Validate that a dictionary contains all required entries needed when creating a Blender property.
|
||||
|
||||
Returns:
|
||||
True if the provided dictionary is guaranteed to result in a valid Blender property when used as keyword arguments in the `self.bl_prop` constructor.
|
||||
"""
|
||||
return all(
|
||||
required_info_key in prop_info for required_info_key in self.required_info
|
||||
)
|
||||
|
||||
def parse_kwargs( # noqa: PLR0915, PLR0912, C901
|
||||
self,
|
||||
obj_type: type,
|
||||
prop_info: BLPropInfo,
|
||||
) -> BLPropInfo:
|
||||
"""Parse the kwargs dictionary used to construct the Blender property.
|
||||
|
||||
Parameters:
|
||||
obj_type: The exact object type that will be stored in the Blender property.
|
||||
**Generally** should be chosen such that `BLPropType.from_type(obj_type) == self`.
|
||||
prop_info: The property info.
|
||||
**Must** contain keys such that `required_info`
|
||||
|
||||
Returns:
|
||||
Keyword arguments, which can be passed directly as to `self.bl_type` to construct a Blender property according to the `prop_info`.
|
||||
|
||||
In total, creating a Blender property can be done simply using `self.bl_type(**parse_kwargs(...))`.
|
||||
"""
|
||||
BPT = BLPropType
|
||||
|
||||
# Check Availability of Required Information
|
||||
## -> All required fields must be defined.
|
||||
if not self.check_info(prop_info):
|
||||
msg = f'{self} ({obj_type}): Required property attribute is missing from prop_info="{prop_info}"'
|
||||
raise ValueError(msg)
|
||||
|
||||
# Define Information -> KWArg Getter
|
||||
def g_kwarg(name: str, force_key: str | None = None):
|
||||
key = force_key if force_key is not None else name
|
||||
return {key: prop_info[name]} if prop_info.get(name) is not None else {}
|
||||
|
||||
# Encode Default Value
|
||||
if prop_info.get('default', Signal.CacheEmpty) is not Signal.CacheEmpty:
|
||||
encoded_default = {'default': self.encode(prop_info.get('default'))}
|
||||
else:
|
||||
encoded_default = {}
|
||||
|
||||
# Assemble KWArgs
|
||||
kwargs = {}
|
||||
match self:
|
||||
case BPT.Bool if obj_type is bool:
|
||||
kwargs |= encoded_default
|
||||
|
||||
case BPT.Int | BPT.IntVector | BPT.IntMatrix:
|
||||
kwargs |= encoded_default
|
||||
kwargs |= g_kwarg('abs_min')
|
||||
kwargs |= g_kwarg('abs_max')
|
||||
kwargs |= g_kwarg('soft_min')
|
||||
kwargs |= g_kwarg('soft_max')
|
||||
|
||||
case BPT.Float | BPT.FloatVector | BPT.FloatMatrix:
|
||||
kwargs |= encoded_default
|
||||
kwargs |= g_kwarg('abs_min')
|
||||
kwargs |= g_kwarg('abs_max')
|
||||
kwargs |= g_kwarg('soft_min')
|
||||
kwargs |= g_kwarg('soft_max')
|
||||
kwargs |= g_kwarg('step')
|
||||
kwargs |= g_kwarg('precision')
|
||||
|
||||
case BPT.Str if obj_type is str:
|
||||
kwargs |= encoded_default
|
||||
|
||||
# Str: Secret
|
||||
if prop_info.get('str_secret'):
|
||||
kwargs |= {'subtype': 'PASSWORD', 'options': {'SKIP_SAVE'}}
|
||||
|
||||
# Str: Search
|
||||
if prop_info.get('str_search'):
|
||||
kwargs |= g_kwarg('safe_str_cb', force_key='search')
|
||||
|
||||
case BPT.Path if obj_type is Path:
|
||||
kwargs |= encoded_default
|
||||
|
||||
# Path: File/Dir
|
||||
if prop_info.get('path_type'):
|
||||
kwargs |= {
|
||||
'subtype': (
|
||||
'FILE_PATH'
|
||||
if prop_info['path_type'] == 'file'
|
||||
else 'DIR_PATH'
|
||||
)
|
||||
}
|
||||
|
||||
# Explicit Enums
|
||||
case BPT.SingleEnum:
|
||||
SubStrEnum = obj_type
|
||||
|
||||
# Static | Dynamic Enum
|
||||
## -> Dynamic enums are responsible for respecting type.
|
||||
if prop_info.get('enum_dynamic'):
|
||||
kwargs |= g_kwarg('safe_enum_cb', force_key='items')
|
||||
else:
|
||||
kwargs |= encoded_default
|
||||
kwargs |= {
|
||||
'items': [
|
||||
## TODO: Parse __doc__ for item descs
|
||||
(
|
||||
str(value),
|
||||
SubStrEnum.to_name(value),
|
||||
SubStrEnum.to_name(value),
|
||||
SubStrEnum.to_icon(value),
|
||||
i,
|
||||
)
|
||||
for i, value in enumerate(list(obj_type))
|
||||
]
|
||||
}
|
||||
|
||||
case BPT.SetEnum:
|
||||
SubStrEnum = typ.get_args(obj_type)[0]
|
||||
|
||||
# Enum Set: Use ENUM_FLAG option.
|
||||
kwargs |= {'options': {'ENUM_FLAG'}}
|
||||
|
||||
# Static | Dynamic Enum
|
||||
## -> Dynamic enums are responsible for respecting type.
|
||||
if prop_info.get('enum_dynamic'):
|
||||
kwargs |= g_kwarg('safe_enum_cb', force_key='items')
|
||||
else:
|
||||
kwargs |= encoded_default
|
||||
kwargs |= {
|
||||
'items': [
|
||||
## TODO: Parse __doc__ for item descs
|
||||
(
|
||||
str(value),
|
||||
SubStrEnum.to_name(value),
|
||||
SubStrEnum.to_name(value),
|
||||
SubStrEnum.to_icon(value),
|
||||
2**i,
|
||||
)
|
||||
for i, value in enumerate(list(SubStrEnum))
|
||||
]
|
||||
}
|
||||
|
||||
# Anonymous Enums
|
||||
case BPT.SingleDynEnum:
|
||||
kwargs |= g_kwarg('safe_enum_cb', force_key='items')
|
||||
|
||||
case BPT.SetDynEnum:
|
||||
kwargs |= g_kwarg('safe_enum_cb', force_key='items')
|
||||
|
||||
# Enum Set: Use ENUM_FLAG option.
|
||||
kwargs |= {'options': {'ENUM_FLAG'}}
|
||||
|
||||
# BLPointer
|
||||
case BPT.BLPointer:
|
||||
kwargs |= encoded_default
|
||||
|
||||
# BLPointer: ID Type
|
||||
kwargs |= g_kwarg('blptr_type', force_key='type')
|
||||
|
||||
# BLPointer
|
||||
case BPT.Serialized:
|
||||
kwargs |= encoded_default
|
||||
|
||||
# Match Size
|
||||
## -> Matrices have inverted order to mitigate the Matrix Display Bug.
|
||||
size = self.parse_size(obj_type)
|
||||
if size is not None:
|
||||
if self in [BPT.BoolVector, BPT.IntVector, BPT.FloatVector]:
|
||||
kwargs |= {'size': size}
|
||||
if self in [BPT.BoolMatrix, BPT.IntMatrix, BPT.FloatMatrix]:
|
||||
kwargs |= {'size': size[::-1]}
|
||||
|
||||
return kwargs
|
||||
|
||||
####################
|
||||
# - Encode Value
|
||||
####################
|
||||
def encode(self, value: typ.Any) -> typ.Any: # noqa: PLR0911
|
||||
"""Transform a value to a form that can be directly written to a Blender property.
|
||||
|
||||
Parameters:
|
||||
value: A value which should be transformed into a form that can be written to the Blender property returned by `self.bl_type`.
|
||||
|
||||
Returns:
|
||||
A value that can be written directly to the Blender property returned by `self.bl_type`.
|
||||
"""
|
||||
BPT = BLPropType
|
||||
match self:
|
||||
# Scalars: Coerce Losslessly
|
||||
## -> We choose to be very strict, except for float.is_integer() -> int
|
||||
case BPT.Bool if isinstance(value, bool):
|
||||
return value
|
||||
case BPT.Int if isinstance(value, int):
|
||||
return value
|
||||
case BPT.Int if isinstance(value, float) and value.is_integer():
|
||||
return int(value)
|
||||
case BPT.Float if isinstance(value, int | float):
|
||||
return float(value)
|
||||
|
||||
# Vectors | Matrices: list()
|
||||
## -> We could use tuple(), but list() works just as fine when writing.
|
||||
## -> Later, we read back as tuple() to respect the type annotation.
|
||||
## -> Part of the workaround for the Matrix Display Bug happens here.
|
||||
case BPT.BoolVector | BPT.IntVector | BPT.FloatVector:
|
||||
return list(value)
|
||||
case BPT.BoolMatrix | BPT.IntMatrix | BPT.FloatMatrix:
|
||||
rows = len(value)
|
||||
cols = len(value[0])
|
||||
return (
|
||||
np.array(value, dtype=self.primitive_type)
|
||||
.flatten()
|
||||
.reshape([cols, rows])
|
||||
).tolist()
|
||||
|
||||
# String
|
||||
## -> NOTE: This will happily encode StrEnums->str if an enum isn't requested.
|
||||
case BPT.Str if isinstance(value, str):
|
||||
return value
|
||||
|
||||
# Path: Use Absolute-Resolved Stringified Path
|
||||
## -> TODO: Watch out for OS-dependence.
|
||||
case BPT.Path if isinstance(value, Path):
|
||||
return str(value.resolve())
|
||||
|
||||
# Empty Enums
|
||||
## -> Coerce None to 'NONE', since 'NONE' is injected by convention.
|
||||
case (
|
||||
BPT.SingleEnum
|
||||
| BPT.SetEnum
|
||||
| BPT.SingleDynEnum
|
||||
| BPT.SetDynEnum
|
||||
) if value is None:
|
||||
return 'NONE'
|
||||
|
||||
# Single Enum: Coerce to str
|
||||
## -> isinstance(StrEnum.Entry, str) always returns True; thus, a good sanity check.
|
||||
## -> Explicit/Dynamic both encode to str; only decode() coersion differentiates.
|
||||
case BPT.SingleEnum | BPT.SingleDynEnum if isinstance(value, str):
|
||||
return str(value)
|
||||
|
||||
# Single Enum: Coerce to set[str]
|
||||
case BPT.SetEnum | BPT.SetDynEnum if isinstance(value, set):
|
||||
return {str(v) for v in value}
|
||||
|
||||
# BLPointer: Don't Alter
|
||||
case BPT.BLPointer if value in BLIDStructs or value is None:
|
||||
return value
|
||||
|
||||
# Serialized: Serialize To UTF-8
|
||||
## -> TODO: Check serializability
|
||||
case BPT.Serialized:
|
||||
return serialize.encode(value).decode('utf-8')
|
||||
|
||||
msg = f'{self}: No encoder defined for argument {value}'
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
####################
|
||||
# - Decode Value
|
||||
####################
|
||||
def decode(self, raw_value: typ.Any, obj_type: type) -> typ.Any: # noqa: PLR0911
|
||||
"""Transform a raw value from a form read directly from the Blender property returned by `self.bl_type`, to its intended value of approximate type `obj_type`.
|
||||
|
||||
Notes:
|
||||
`obj_type` is only a hint - for example, `obj_type = enum.StrEnum` is an indicator for a dynamic enum.
|
||||
Its purpose is to guide ex. sizing and `StrEnum` coersion, not to guarantee a particular output type.
|
||||
|
||||
Parameters:
|
||||
value: A value which should be transformed into a form that can be written to the Blender property returned by `self.bl_type`.
|
||||
|
||||
Returns:
|
||||
A value that can be written directly to the Blender property returned by `self.bl_type`.
|
||||
"""
|
||||
BPT = BLPropType
|
||||
match self:
|
||||
# Scalars: Inverse Coerce (~Losslessly)
|
||||
## -> We choose to be very strict, except for float.is_integer() -> int
|
||||
case BPT.Bool if isinstance(raw_value, bool):
|
||||
return raw_value
|
||||
case BPT.Int if isinstance(raw_value, int):
|
||||
return raw_value
|
||||
case BPT.Int if isinstance(raw_value, float) and raw_value.is_integer():
|
||||
return int(raw_value)
|
||||
case BPT.Float if isinstance(raw_value, float):
|
||||
return float(raw_value)
|
||||
|
||||
# Vectors | Matrices: tuple() to match declared type annotation.
|
||||
## -> Part of the workaround for the Matrix Display Bug happens here.
|
||||
case BPT.BoolVector | BPT.IntVector | BPT.FloatVector:
|
||||
return tuple(raw_value)
|
||||
case BPT.BoolMatrix | BPT.IntMatrix | BPT.FloatMatrix:
|
||||
rows, cols = self.parse_size(obj_type)
|
||||
return tuple(
|
||||
map(tuple, np.array(raw_value).flatten().reshape([rows, cols]))
|
||||
)
|
||||
|
||||
# String
|
||||
## -> NOTE: This will happily decode StrEnums->str if an enum isn't requested.
|
||||
case BPT.Str if isinstance(raw_value, str):
|
||||
return raw_value
|
||||
|
||||
# Path: Use 'Path(abspath(*))'
|
||||
## -> TODO: Watch out for OS-dependence.
|
||||
case BPT.Path if isinstance(raw_value, str):
|
||||
return Path(bpy.path.abspath(raw_value))
|
||||
|
||||
# Empty Enums
|
||||
## -> Coerce 'NONE' to None, since 'NONE' is injected by convention.
|
||||
## -> Using coerced 'NONE' as guaranteed len=0 element is extremely helpful.
|
||||
case (
|
||||
BPT.SingleEnum
|
||||
| BPT.SetEnum
|
||||
| BPT.SingleDynEnum
|
||||
| BPT.SetDynEnum
|
||||
) if raw_value in ['NONE']:
|
||||
return None
|
||||
|
||||
# Explicit Enum: Coerce to predefined StrEnum
|
||||
## -> This happens independent of whether there's a enum_cb.
|
||||
case BPT.SingleEnum if isinstance(raw_value, str):
|
||||
return obj_type(raw_value)
|
||||
case BPT.SetEnum if isinstance(raw_value, set):
|
||||
return {obj_type(v) for v in raw_value}
|
||||
|
||||
## Dynamic Enums: Nothing to coerce to.
|
||||
## -> The critical distinction is that dynamic enums can't be coerced beyond str.
|
||||
## -> All dynamic enums have an enum_cb, but this is merely a symptom of ^.
|
||||
case BPT.SingleDynEnum if isinstance(raw_value, str):
|
||||
return raw_value
|
||||
case BPT.SetDynEnum if isinstance(raw_value, set):
|
||||
return raw_value
|
||||
|
||||
# BLPointer
|
||||
## -> None is always valid when it comes to BLPointers.
|
||||
case BPT.BLPointer if raw_value in BLIDStructs or raw_value is None:
|
||||
return raw_value
|
||||
|
||||
# Serialized: Deserialize the Argument
|
||||
case BPT.Serialized:
|
||||
return serialize.decode(obj_type, raw_value)
|
||||
|
||||
msg = f'{self}: No decoder defined for argument {raw_value}'
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
####################
|
||||
# - Parse Type
|
||||
####################
|
||||
@staticmethod
|
||||
def from_type(obj_type: type) -> typ.Self: # noqa: PLR0911, PLR0912, C901
|
||||
"""Select an appropriate `BLPropType` to store objects of the given type.
|
||||
|
||||
Use of this method is especially handy when attempting to represent arbitrary, type-annotated objects using Blender properties.
|
||||
For example, the ability of the `BLPropType` to be displayed in a UI is prioritized as much as possible in making this decision.
|
||||
|
||||
Parameters:
|
||||
obj_type: A type like `bool`, `str`, or custom classes.
|
||||
|
||||
Returns:
|
||||
A `BLPropType` capable of storing any object of `obj_type`.
|
||||
"""
|
||||
BPT = BLPropType
|
||||
|
||||
# Match Simple
|
||||
match obj_type:
|
||||
case builtins.bool:
|
||||
return BPT.Bool
|
||||
case builtins.int:
|
||||
return BPT.Int
|
||||
case builtins.float:
|
||||
return BPT.Float
|
||||
case builtins.str:
|
||||
return BPT.Str
|
||||
case pathlib.Path:
|
||||
return BPT.Path
|
||||
case enum.StrEnum:
|
||||
return BPT.SingleDynEnum
|
||||
case _:
|
||||
pass
|
||||
|
||||
# Match Arrays
|
||||
## -> This deconstructs generic statements like ex. tuple[int, int]
|
||||
typ_origin = typ.get_origin(obj_type)
|
||||
typ_args = typ.get_args(obj_type)
|
||||
if typ_origin is tuple and len(typ_args) > 0:
|
||||
# Match Vectors
|
||||
## -> ONLY respect homogeneous types
|
||||
if all(T is bool for T in typ_args):
|
||||
return BPT.BoolVector
|
||||
if all(T is int for T in typ_args):
|
||||
return BPT.IntVector
|
||||
if all(T is float for T in typ_args):
|
||||
return BPT.FloatVector
|
||||
|
||||
# Match Matrices
|
||||
## -> ONLY respect twice-nested homogeneous types
|
||||
## -> TODO: Explicitly require regularized shape, as in _parse_matrix_size
|
||||
typ_args_args = [typ_arg for T0 in typ_args for typ_arg in typ.get_args(T0)]
|
||||
if typ_args_args:
|
||||
if all(T is bool for T in typ_args_args):
|
||||
return BPT.BoolMatrix
|
||||
if all(T is int for T in typ_args_args):
|
||||
return BPT.IntMatrix
|
||||
if all(T is float for T in typ_args_args):
|
||||
return BPT.FloatMatrix
|
||||
|
||||
# Match SetDynEnum
|
||||
## -> We can't do this in the match statement
|
||||
if obj_type == set[enum.StrEnum]:
|
||||
return BPT.SetDynEnum
|
||||
|
||||
# Match Static Enums
|
||||
## -> Match Single w/Helper Function
|
||||
if _is_strenum(obj_type):
|
||||
return BPT.SingleEnum
|
||||
|
||||
## -> Match Set w/Helper Function
|
||||
if typ_origin is set and len(typ_args) == 1 and _is_strenum(typ_args[0]):
|
||||
return BPT.SetEnum
|
||||
|
||||
# Match BLPointers
|
||||
if obj_type in BLIDStructs:
|
||||
return BPT.BLPointer
|
||||
|
||||
# Fallback: Serializable Object
|
||||
## -> TODO: Check serializability.
|
||||
return BPT.Serialized
|
|
@ -0,0 +1,246 @@
|
|||
# blender_maxwell
|
||||
# Copyright (C) 2024 blender_maxwell Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Implements various key caches on instances of Blender objects, especially nodes and sockets."""
|
||||
|
||||
import inspect
|
||||
import typing as typ
|
||||
|
||||
from blender_maxwell.utils import bl_instance, logger, serialize
|
||||
|
||||
from .bl_prop import BLProp
|
||||
from .bl_prop_type import BLPropType
|
||||
from .signal import Signal
|
||||
|
||||
log = logger.get(__name__)
|
||||
|
||||
####################
|
||||
# - Types
|
||||
####################
|
||||
PropGetMethod: typ.TypeAlias = typ.Callable[
|
||||
[bl_instance.BLInstance], serialize.NaivelyEncodableType
|
||||
]
|
||||
PropSetMethod: typ.TypeAlias = typ.Callable[
|
||||
[bl_instance.BLInstance, serialize.NaivelyEncodableType], None
|
||||
]
|
||||
|
||||
|
||||
####################
|
||||
# - CachedBLProperty
|
||||
####################
|
||||
class CachedBLProperty:
|
||||
"""A descriptor that caches a computed attribute of a Blender node/socket/... instance (`bl_instance`).
|
||||
|
||||
Generally used via the associated decorator, `cached_bl_property`.
|
||||
|
||||
Notes:
|
||||
It's like `@cached_property`, but on each Blender Instance ID.
|
||||
|
||||
Attributes:
|
||||
getter_method: Method of `bl_instance` that computes the value.
|
||||
setter_method: Method of `bl_instance` that sets the value.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
getter_method: PropGetMethod,
|
||||
persist: bool = False,
|
||||
depends_on: frozenset[str] = frozenset(),
|
||||
):
|
||||
"""Initialize the getter of the cached property.
|
||||
|
||||
Parameters:
|
||||
getter_method: Method of `bl_instance` that computes the value.
|
||||
"""
|
||||
self.getter_method: PropGetMethod = getter_method
|
||||
self.setter_method: PropSetMethod | None = None
|
||||
|
||||
self.persist: bool = persist
|
||||
self.depends_on: frozenset[str] = depends_on
|
||||
|
||||
self.bl_prop: BLProp | None = None
|
||||
|
||||
self.decode_type: type = inspect.signature(getter_method).return_annotation
|
||||
|
||||
# Check Non-Empty Type Annotation
|
||||
## For now, just presume that all types can be encoded/decoded.
|
||||
if self.decode_type is inspect.Signature.empty:
|
||||
msg = f'A CachedBLProperty was instantiated, but its getter method "{self.getter_method}" has no return type annotation'
|
||||
raise TypeError(msg)
|
||||
|
||||
def __set_name__(self, owner: type[bl_instance.BLInstance], name: str) -> None:
|
||||
"""Generates the property name from the name of the attribute that this descriptor is assigned to.
|
||||
|
||||
Notes:
|
||||
- Run by Python when setting an instance of this class to an attribute.
|
||||
|
||||
Parameters:
|
||||
owner: The class that contains an attribute assigned to an instance of this descriptor.
|
||||
name: The name of the attribute that an instance of descriptor was assigned to.
|
||||
"""
|
||||
self.bl_prop = BLProp(
|
||||
name=name,
|
||||
prop_info={'use_prop_update': True},
|
||||
prop_type=self.decode_type,
|
||||
bl_prop_type=BLPropType.Serialized,
|
||||
)
|
||||
self.bl_prop.init_bl_type(owner, depends_on=self.depends_on)
|
||||
|
||||
def __get__(
|
||||
self,
|
||||
bl_instance: bl_instance.BLInstance | None,
|
||||
owner: type[bl_instance.BLInstance],
|
||||
) -> typ.Any:
|
||||
"""Retrieves the property from a cache, or computes it and fills the cache(s).
|
||||
|
||||
Parameters:
|
||||
bl_instance: The Blender object this prop
|
||||
"""
|
||||
cached_value = self.bl_prop.read_nonpersist(bl_instance)
|
||||
if cached_value is Signal.CacheNotReady or cached_value is Signal.CacheEmpty:
|
||||
if bl_instance is not None:
|
||||
if self.persist:
|
||||
value = self.bl_prop.read(bl_instance)
|
||||
else:
|
||||
value = self.getter_method(bl_instance)
|
||||
|
||||
self.bl_prop.write_nonpersist(bl_instance, value)
|
||||
return value
|
||||
return Signal.CacheNotReady
|
||||
return cached_value
|
||||
|
||||
def __set__(
|
||||
self, bl_instance: bl_instance.BLInstance | None, value: typ.Any
|
||||
) -> None:
|
||||
"""Runs the user-provided setter, after invalidating the caches.
|
||||
|
||||
Notes:
|
||||
- This invalidates all caches without re-filling them.
|
||||
- The caches will be re-filled on the first `__get__` invocation, which may be slow due to having to run the getter method.
|
||||
|
||||
Parameters:
|
||||
bl_instance: The Blender object this prop
|
||||
"""
|
||||
if value is Signal.DoUpdate:
|
||||
bl_instance.on_prop_changed(self.bl_prop.name)
|
||||
|
||||
elif value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate:
|
||||
# Invalidate Partner Non-Persistent Caches
|
||||
## -> Only for the invalidation case do we also invalidate partners.
|
||||
if bl_instance is not None:
|
||||
# Fill Caches
|
||||
## -> persist: Fill Persist and Non-Persist Cache
|
||||
## -> else: Fill Non-Persist Cache
|
||||
if self.persist:
|
||||
self.bl_prop.write(bl_instance, self.getter_method(bl_instance))
|
||||
|
||||
else:
|
||||
self.bl_prop.write_nonpersist(
|
||||
bl_instance, self.getter_method(bl_instance)
|
||||
)
|
||||
|
||||
if value == Signal.InvalidateCache:
|
||||
bl_instance.on_prop_changed(self.bl_prop.name)
|
||||
|
||||
elif self.setter_method is not None:
|
||||
# Run Setter
|
||||
## -> The user-provided setter should do any updating of partners.
|
||||
if self.setter_method is not None:
|
||||
self.setter_method(bl_instance, value)
|
||||
|
||||
# Fill Non-Persistant (and maybe Persistent) Cache
|
||||
if self.persist:
|
||||
self.bl_prop.write(bl_instance, self.getter_method(bl_instance))
|
||||
|
||||
else:
|
||||
self.bl_prop.write_nonpersist(
|
||||
bl_instance, self.getter_method(bl_instance)
|
||||
)
|
||||
bl_instance.on_prop_changed(self.bl_prop.name)
|
||||
|
||||
else:
|
||||
msg = f'Tried to set "{value}" to "{self.prop_name}" on "{bl_instance.bl_label}", but a setter was not defined'
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def setter(self, setter_method: PropSetMethod) -> typ.Self:
|
||||
"""Decorator to add a setter to the cached property.
|
||||
|
||||
Returns:
|
||||
The same descriptor, so that use of the same method name for defining a setter won't change the semantics of the attribute.
|
||||
|
||||
Examples:
|
||||
Without the decorator, it looks like this:
|
||||
```python
|
||||
class Test(bpy.types.Node):
|
||||
bl_label = 'Default'
|
||||
...
|
||||
def method(self) -> str: return self.bl_label
|
||||
attr = CachedBLProperty(getter_method=method)
|
||||
|
||||
@attr.setter
|
||||
def attr(self, value: str) -> None:
|
||||
self.bl_label = 'Altered'
|
||||
```
|
||||
"""
|
||||
# Validate Setter Signature
|
||||
setter_sig = inspect.signature(setter_method)
|
||||
|
||||
## Parameter Length
|
||||
if (sig_len := len(setter_sig.parameters)) != 2: # noqa: PLR2004
|
||||
msg = f'Setter method for "{self.prop_name}" should have 2 parameters, not "{sig_len}"'
|
||||
raise TypeError(msg)
|
||||
|
||||
## Parameter Value Type
|
||||
if (sig_ret_type := setter_sig.return_annotation) is not None:
|
||||
msg = f'Setter method for "{self.prop_name}" return value type "{sig_ret_type}", but it should be "None" (omitting an annotation does not imply "None")'
|
||||
raise TypeError(msg)
|
||||
|
||||
self.setter_method = setter_method
|
||||
return self
|
||||
|
||||
|
||||
####################
|
||||
# - Decorator
|
||||
####################
|
||||
def cached_bl_property(
|
||||
persist: bool = False,
|
||||
depends_on: frozenset[str] = frozenset(),
|
||||
):
|
||||
"""Decorator creating a descriptor that caches a computed attribute of a Blender node/socket.
|
||||
|
||||
Many such `bl_instance`s rely on fast access to computed, cached properties, for example to ensure that `draw()` remains effectively non-blocking.
|
||||
|
||||
Notes:
|
||||
- Unfortunately, `functools.cached_property` doesn't work.
|
||||
- Use `cached_attribute` if not using a node/socket.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
class CustomNode(bpy.types.Node):
|
||||
@bl_cache.cached()
|
||||
def computed_prop(self) -> ...: return ...
|
||||
|
||||
print(bl_instance.prop) ## Computes first time
|
||||
print(bl_instance.prop) ## Cached (after restart, will recompute)
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(getter_method: typ.Callable[[bl_instance.BLInstance], None]) -> type:
|
||||
return CachedBLProperty(
|
||||
getter_method=getter_method, persist=persist, depends_on=depends_on
|
||||
)
|
||||
|
||||
return decorator
|
|
@ -0,0 +1,146 @@
|
|||
# blender_maxwell
|
||||
# Copyright (C) 2024 blender_maxwell Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import typing as typ
|
||||
|
||||
from blender_maxwell.utils import bl_instance, logger, serialize
|
||||
|
||||
log = logger.get(__name__)
|
||||
|
||||
|
||||
class KeyedCache:
|
||||
def __init__(
|
||||
self,
|
||||
func: typ.Callable,
|
||||
exclude: set[str],
|
||||
encode: set[str],
|
||||
):
|
||||
# Function Information
|
||||
self.func: typ.Callable = func
|
||||
self.func_sig: inspect.Signature = inspect.signature(self.func)
|
||||
|
||||
# Arg -> Key Information
|
||||
self.exclude: set[str] = exclude
|
||||
self.include: set[str] = set(self.func_sig.parameters.keys()) - exclude
|
||||
self.encode: set[str] = encode
|
||||
|
||||
# Cache Information
|
||||
self.key_schema: tuple[str, ...] = tuple(
|
||||
[
|
||||
arg_name
|
||||
for arg_name in self.func_sig.parameters
|
||||
if arg_name not in exclude
|
||||
]
|
||||
)
|
||||
self.caches: dict[str | None, dict[tuple[typ.Any, ...], typ.Any]] = {}
|
||||
|
||||
@property
|
||||
def is_method(self):
|
||||
return 'self' in self.exclude
|
||||
|
||||
def cache(self, instance_id: str | None) -> dict[tuple[typ.Any, ...], typ.Any]:
|
||||
if self.caches.get(instance_id) is None:
|
||||
self.caches[instance_id] = {}
|
||||
|
||||
return self.caches[instance_id]
|
||||
|
||||
def _encode_key(self, arguments: dict[str, typ.Any]):
|
||||
## WARNING: Order of arguments matters. Arguments may contain 'exclude'd elements.
|
||||
return tuple(
|
||||
[
|
||||
(
|
||||
arg_value
|
||||
if arg_name not in self.encode
|
||||
else serialize.encode(arg_value)
|
||||
)
|
||||
for arg_name, arg_value in arguments.items()
|
||||
if arg_name in self.include
|
||||
]
|
||||
)
|
||||
|
||||
def __get__(
|
||||
self,
|
||||
bl_instance: bl_instance.BLInstance | None,
|
||||
owner: type[bl_instance.BLInstance],
|
||||
) -> typ.Callable:
|
||||
_func = functools.partial(self, bl_instance)
|
||||
_func.invalidate = functools.partial(
|
||||
self.__class__.invalidate, self, bl_instance
|
||||
)
|
||||
return _func
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
# Test Argument Bindability to Decorated Function
|
||||
try:
|
||||
bound_args = self.func_sig.bind(*args, **kwargs)
|
||||
except TypeError as ex:
|
||||
msg = f'Can\'t bind arguments (args={args}, kwargs={kwargs}) to @keyed_cache-decorated function "{self.func.__name__}" (signature: {self.func_sig})"'
|
||||
raise ValueError(msg) from ex
|
||||
|
||||
# Check that Parameters for Keying the Cache are Available
|
||||
bound_args.apply_defaults()
|
||||
all_arg_keys = set(bound_args.arguments.keys())
|
||||
if not self.include <= (all_arg_keys - self.exclude):
|
||||
msg = f'Arguments spanning the keyed cached ({self.include}) are not available in the non-excluded arguments passed to "{self.func.__name__}": {all_arg_keys - self.exclude}'
|
||||
raise ValueError(msg)
|
||||
|
||||
# Create Keyed Cache Entry
|
||||
key = self._encode_key(bound_args.arguments)
|
||||
cache = self.cache(args[0].instance_id if self.is_method else None)
|
||||
if (value := cache.get(key)) is None:
|
||||
value = self.func(*args, **kwargs)
|
||||
cache[key] = value
|
||||
|
||||
return value
|
||||
|
||||
def invalidate(
|
||||
self,
|
||||
bl_instance: bl_instance.BLInstance | None,
|
||||
**arguments: dict[str, typ.Any],
|
||||
) -> dict[str, typ.Any]:
|
||||
# Determine Wildcard Arguments
|
||||
wildcard_arguments = {
|
||||
arg_name for arg_name, arg_value in arguments.items() if arg_value is ...
|
||||
}
|
||||
|
||||
# Compute Keys to Invalidate
|
||||
arguments_hashable = {
|
||||
arg_name: serialize.encode(arg_value)
|
||||
if arg_name in self.encode and arg_name not in wildcard_arguments
|
||||
else arg_value
|
||||
for arg_name, arg_value in arguments.items()
|
||||
}
|
||||
cache = self.cache(bl_instance.instance_id if self.is_method else None)
|
||||
for key in list(cache.keys()):
|
||||
if all(
|
||||
arguments_hashable.get(arg_name) == arg_value
|
||||
for arg_name, arg_value in zip(self.key_schema, key, strict=True)
|
||||
if arg_name not in wildcard_arguments
|
||||
):
|
||||
cache.pop(key)
|
||||
|
||||
|
||||
def keyed_cache(exclude: set[str], encode: set[str] = frozenset()) -> typ.Callable:
|
||||
def decorator(func: typ.Callable) -> typ.Callable:
|
||||
return KeyedCache(
|
||||
func,
|
||||
exclude=exclude,
|
||||
encode=encode,
|
||||
)
|
||||
|
||||
return decorator
|
|
@ -0,0 +1,171 @@
|
|||
# blender_maxwell
|
||||
# Copyright (C) 2024 blender_maxwell Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Implements various key caches on instances of Blender objects, especially nodes and sockets."""
|
||||
|
||||
## TODO: Note that persist=True on cached_bl_property may cause a draw method to try and write to a Blender property, which Blender disallows.
|
||||
|
||||
import typing as typ
|
||||
|
||||
from blender_maxwell import contracts as ct
|
||||
from blender_maxwell.utils import bl_instance, logger
|
||||
|
||||
from .signal import Signal
|
||||
|
||||
log = logger.get(__name__)
|
||||
|
||||
|
||||
####################
|
||||
# - Global Variables
|
||||
####################
|
||||
_CACHE_NONPERSIST: dict[bl_instance.InstanceID, dict[typ.Hashable, typ.Any]] = {}
|
||||
|
||||
|
||||
####################
|
||||
# - Create/Invalidate
|
||||
####################
|
||||
def bl_instance_nonpersist_cache(
|
||||
bl_instance: bl_instance.BLInstance,
|
||||
) -> dict[typ.Hashable, typ.Any]:
|
||||
"""Retrieve the non-persistent cache of a BLInstance."""
|
||||
# Create Non-Persistent Cache Entry
|
||||
## Prefer explicit cache management to 'defaultdict'
|
||||
if _CACHE_NONPERSIST.get(bl_instance.instance_id) is None:
|
||||
_CACHE_NONPERSIST[bl_instance.instance_id] = {}
|
||||
|
||||
return _CACHE_NONPERSIST[bl_instance.instance_id]
|
||||
|
||||
|
||||
def invalidate_nonpersist_instance_id(instance_id: bl_instance.InstanceID) -> None:
|
||||
"""Invalidate any `instance_id` that might be utilizing cache space in `_CACHE_NONPERSIST`.
|
||||
|
||||
Notes:
|
||||
This should be run by the `instance_id` owner in its `free()` method.
|
||||
|
||||
Parameters:
|
||||
instance_id: The ID of the Blender object instance that's being freed.
|
||||
"""
|
||||
_CACHE_NONPERSIST.pop(instance_id, None)
|
||||
|
||||
|
||||
####################
|
||||
# - Access
|
||||
####################
|
||||
def read(
|
||||
bl_instance: bl_instance.BLInstance | None,
|
||||
key: typ.Hashable,
|
||||
use_nonpersist: bool = True,
|
||||
use_persist: bool = False,
|
||||
) -> typ.Any | typ.Literal[Signal.CacheNotReady, Signal.CacheEmpty]:
|
||||
"""Read the cache associated with a Blender Instance, without writing to it.
|
||||
|
||||
Attributes:
|
||||
key: The name to read from the instance-specific cache.
|
||||
use_nonpersist: If true, will always try the non-persistent cache first.
|
||||
use_persist: If true, will always try accessing the attribute `bl_instance,key`, where `key` is the value of the same-named parameter.
|
||||
Generally, such an attribute should be a `bpy.types.Property`.
|
||||
|
||||
Return:
|
||||
The cache hit, if any; else `Signal.CacheEmpty`.
|
||||
"""
|
||||
# Check BLInstance Readiness
|
||||
if bl_instance is None:
|
||||
return Signal.CacheNotReady
|
||||
|
||||
# Try Hit on Persistent Cache
|
||||
if use_persist:
|
||||
value = getattr(bl_instance, key, Signal.CacheEmpty)
|
||||
if value is not Signal.CacheEmpty:
|
||||
return value
|
||||
|
||||
# Check if Instance ID is Available
|
||||
if not bl_instance.instance_id:
|
||||
log.debug(
|
||||
"Can't Get CachedBLProperty: Instance ID not (yet) defined on bl_instance.BLInstance %s",
|
||||
str(bl_instance),
|
||||
)
|
||||
return Signal.CacheNotReady
|
||||
|
||||
# Try Hit on Non-Persistent Cache
|
||||
if use_nonpersist:
|
||||
cache_nonpersist = bl_instance_nonpersist_cache(bl_instance)
|
||||
value = cache_nonpersist.get(key, Signal.CacheEmpty)
|
||||
if value is not Signal.CacheEmpty:
|
||||
return value
|
||||
|
||||
return Signal.CacheEmpty
|
||||
|
||||
|
||||
def write(
|
||||
bl_instance: bl_instance.BLInstance,
|
||||
key: typ.Hashable,
|
||||
value: typ.Any, ## TODO: "Serializable" type
|
||||
use_nonpersist: bool = True,
|
||||
use_persist: bool = False,
|
||||
) -> None:
|
||||
"""Write to the cache associated with a Blender Instance.
|
||||
|
||||
Attributes:
|
||||
key: The name to write to the instance-specific cache.
|
||||
use_nonpersist: If true, will always write to the non-persistent cache first.
|
||||
use_persist: If true, will always write to attribute `bl_instance.key`, where `key` is the value of the same-named parameter.
|
||||
Generally, such an attribute should be a `bpy.types.Property`.
|
||||
call_on_prop_changed: Whether to trigger `bl_instance.on_prop_changed()` with the
|
||||
"""
|
||||
# Check BLInstance Readiness
|
||||
if bl_instance is None:
|
||||
return
|
||||
|
||||
# Try Write on Persistent Cache
|
||||
if use_persist:
|
||||
# log.critical('%s: Writing %s to %s.', str(bl_instance), str(value), str(key))
|
||||
setattr(bl_instance, key, value)
|
||||
|
||||
if not bl_instance.instance_id:
|
||||
log.debug(
|
||||
"Can't Get CachedBLProperty: Instance ID not (yet) defined on bl_instance.BLInstance %s",
|
||||
str(bl_instance),
|
||||
)
|
||||
return
|
||||
|
||||
# Try Write on Non-Persistent Cache
|
||||
if use_nonpersist:
|
||||
cache_nonpersist = bl_instance_nonpersist_cache(bl_instance)
|
||||
cache_nonpersist[key] = value
|
||||
|
||||
|
||||
def invalidate_nonpersist(
|
||||
bl_instance: bl_instance.BLInstance,
|
||||
key: typ.Hashable,
|
||||
) -> None:
|
||||
"""Invalidate a particular key of the non-persistent cache.
|
||||
|
||||
**Persistent caches can't be invalidated without writing to them**.
|
||||
To get the same effect, consider using `write()` to write its default value (which must be manually tracked).
|
||||
"""
|
||||
# Check BLInstance Readiness
|
||||
if bl_instance is None:
|
||||
return
|
||||
if not bl_instance.instance_id:
|
||||
log.debug(
|
||||
"Can't Get CachedBLProperty: Instance ID not (yet) defined on bl_instance.BLInstance %s",
|
||||
str(bl_instance),
|
||||
)
|
||||
return
|
||||
|
||||
# Retrieve Non-Persistent Cache
|
||||
cache_nonpersist = bl_instance_nonpersist_cache(bl_instance)
|
||||
cache_nonpersist.pop(key, None)
|
|
@ -0,0 +1,61 @@
|
|||
# blender_maxwell
|
||||
# Copyright (C) 2024 blender_maxwell Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
|
||||
class Signal(enum.StrEnum):
|
||||
"""A value used to signal the descriptor via its `__set__`.
|
||||
|
||||
Such a signal **must** be entirely unique: Even a well-thought-out string could conceivably produce a very nasty bug, where instead of setting a descriptor-managed attribute, the user would inadvertently signal the descriptor.
|
||||
|
||||
To make it effectively impossible to confuse any other object whatsoever with a signal, the enum values are set to per-session `uuid.uuid4()`.
|
||||
|
||||
Notes:
|
||||
**Do not** use this enum for anything other than directly signalling a `bl_cache` descriptor via its setter.
|
||||
|
||||
**Do not** store this enum `Signal` in a variable or method binding that survives longer than the session.
|
||||
|
||||
**Do not** persist this enum; the values will change whenever `bl_cache` is (re)loaded.
|
||||
|
||||
Attributes:
|
||||
CacheNotReady: The cache isn't yet ready to be used.
|
||||
Generally, this is because the `BLInstance` isn't made yet.
|
||||
CacheEmpty: The cache has no information to offer.
|
||||
|
||||
InvalidateCache: The cache should be invalidated.
|
||||
InvalidateCacheNoUpdate: The cache should be invalidated, but no update method should be run.
|
||||
DoUpdate: Any update method that the cache triggers on change should be run.
|
||||
An update is **not guaranteeed** to be run, merely requested.
|
||||
|
||||
ResetEnumItems: Cached dynamic enum items should be recomputed on next use.
|
||||
ResetStrSearch: Cached string-search items should be recomputed on next use.
|
||||
"""
|
||||
|
||||
# Cache Management
|
||||
CacheNotReady: str = str(uuid.uuid4())
|
||||
CacheEmpty: str = str(uuid.uuid4())
|
||||
|
||||
# Invalidation
|
||||
InvalidateCache: str = str(uuid.uuid4())
|
||||
InvalidateCacheNoUpdate: str = str(uuid.uuid4())
|
||||
DoUpdate: str = str(uuid.uuid4())
|
||||
|
||||
# Reset Signals
|
||||
## -> Invalidates data adjascent to fields.
|
||||
ResetEnumItems: str = str(uuid.uuid4())
|
||||
ResetStrSearch: str = str(uuid.uuid4())
|
|
@ -0,0 +1,299 @@
|
|||
# blender_maxwell
|
||||
# Copyright (C) 2024 blender_maxwell Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import typing as typ
|
||||
import uuid
|
||||
from types import MappingProxyType
|
||||
|
||||
import bpy
|
||||
|
||||
from blender_maxwell.utils import bl_cache, logger
|
||||
|
||||
InstanceID: typ.TypeAlias = str ## Stringified UUID4
|
||||
|
||||
log = logger.get(__name__)
|
||||
|
||||
|
||||
class BLInstance:
|
||||
"""An instance of a blender object, ex. nodes/sockets.
|
||||
|
||||
Used as a common base of functionality for nodes/sockets, especially when it comes to the magic introduced by `bl_cache`.
|
||||
|
||||
Notes:
|
||||
All the `@classmethod`s are designed to be invoked with `cls` as the subclass of `BLInstance`, not `BLInstance` itself.
|
||||
|
||||
For practical reasons, introducing a metaclass here is not a good idea, and thus `abc.ABC` can't be used.
|
||||
To this end, only `self.on_prop_changed` needs a subclass implementation.
|
||||
It's a little sharp, but managable.
|
||||
|
||||
Inheritance schemes like this are generally not enjoyable.
|
||||
However, the way Blender's node/socket classes are structured makes it the most practical way design for the functionality encapsulated here.
|
||||
|
||||
Attributes:
|
||||
instance_id: Stringified UUID4 that uniquely identifies an instance, among all active instances on all active classes.
|
||||
"""
|
||||
|
||||
####################
|
||||
# - Attributes
|
||||
####################
|
||||
instance_id: bpy.props.StringProperty(default='')
|
||||
|
||||
blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({})
|
||||
blfield_deps: typ.ClassVar[dict[str, list[str]]] = MappingProxyType({})
|
||||
|
||||
blfields_dynamic_enum: typ.ClassVar[set[str]] = frozenset()
|
||||
blfield_dynamic_enum_deps: typ.ClassVar[dict[str, list[str]]] = MappingProxyType({})
|
||||
|
||||
blfields_str_search: typ.ClassVar[set[str]] = frozenset()
|
||||
blfield_str_search_deps: typ.ClassVar[dict[str, list[str]]] = MappingProxyType({})
|
||||
|
||||
####################
|
||||
# - Runtime Instance Management
|
||||
####################
|
||||
def reset_instance_id(self) -> None:
|
||||
"""Reset the Instance ID of a BLInstance.
|
||||
|
||||
The Instance ID is used to index the instance-specific cache, since Blender doesn't always directly support keeping changing data on node/socket instances.
|
||||
|
||||
Notes:
|
||||
Should be run whenever the instance is copied, so that the copy will index its own cache.
|
||||
|
||||
The Instance ID is a `UUID4`, which is globally unique, negating the need for extraneous overlap-checks.
|
||||
"""
|
||||
self.instance_id = str(uuid.uuid4())
|
||||
self.regenerate_dynamic_field_persistance()
|
||||
|
||||
@classmethod
|
||||
def assert_attrs_valid(cls, mandatory_props: set[str]) -> None:
|
||||
"""Asserts that all mandatory attributes are defined on the class.
|
||||
|
||||
The list of mandatory objects is generally sourced from a global variable, `MANDATORY_PROPS`, which should be passed to this function while running `__init_subclass__`.
|
||||
|
||||
Raises:
|
||||
ValueError: If a mandatory attribute defined in `base.MANDATORY_PROPS` is not defined on the class.
|
||||
"""
|
||||
for cls_attr in mandatory_props:
|
||||
if not hasattr(cls, cls_attr):
|
||||
msg = f'Node class {cls} does not define mandatory attribute "{cls_attr}".'
|
||||
raise ValueError(msg)
|
||||
|
||||
####################
|
||||
# - Field Registration
|
||||
####################
|
||||
@classmethod
|
||||
def declare_blfield(
|
||||
cls,
|
||||
attr_name: str,
|
||||
bl_attr_name: str,
|
||||
use_dynamic_enum: bool = False,
|
||||
use_str_search: bool = False,
|
||||
) -> None:
|
||||
"""Declare the existance of a (cached) field and any properties affecting its invalidation.
|
||||
|
||||
Primarily, the `attr_name -> bl_attr_name` map will be available via the `cls.blfields` dictionary.
|
||||
Thus, for use in UIs (where `bl_attr_name` must be used), one can use `cls.blfields[attr_name]`.
|
||||
|
||||
Parameters:
|
||||
attr_name: The name of the attribute accessible via the instance.
|
||||
bl_attr_name: The name of the attribute containing the Blender property.
|
||||
This is used both as a persistant cache for `attr_name`, as well as (possibly) the data altered by the user from the UI.
|
||||
use_dynamic_enum: Will mark `attr_name` as a dynamic enum.
|
||||
Allows `self.regenerate_dynamic_field_persistance` to reset this property, whenever all dynamic `EnumProperty`s are reset at once.
|
||||
use_str_searc: The name of the attribute containing the Blender property.
|
||||
Allows `self.regenerate_dynamic_field_persistance` to reset this property, whenever all searched `StringProperty`s are reset at once.
|
||||
"""
|
||||
cls.blfields = cls.blfields | {attr_name: bl_attr_name}
|
||||
|
||||
if use_dynamic_enum:
|
||||
cls.blfields_dynamic_enum = cls.blfields_dynamic_enum | {attr_name}
|
||||
|
||||
if use_str_search:
|
||||
cls.blfields_str_search = cls.blfields_str_search | {attr_name}
|
||||
|
||||
@classmethod
|
||||
def declare_blfield_dep(
|
||||
cls,
|
||||
src_prop_name: str,
|
||||
dst_prop_name: str,
|
||||
method: typ.Literal[
|
||||
'invalidate', 'reset_enum', 'reset_strsearch'
|
||||
] = 'invalidate',
|
||||
) -> None:
|
||||
"""Declare that `prop_name` relies on another property.
|
||||
|
||||
This is critical for cached, computed properties that must invalidate their cache whenever any of the data they rely on changes.
|
||||
In practice, a chain of invalidation emerges naturally when this is put together, managed internally for performance.
|
||||
|
||||
Notes:
|
||||
If the relevant `*_deps` dictionary is not defined on `cls`, we manually create it.
|
||||
This shadows the relevant `BLInstance` attribute, which is an immutable `MappingProxyType` on purpose, precisely to prevent the situation of altering data that shouldn't be common to all classes inheriting from `BLInstance`.
|
||||
|
||||
Not clean, but it works.
|
||||
|
||||
Parameters:
|
||||
dep_prop_name: The property that should, whenever changed, also invalidate the cache of `prop_name`.
|
||||
prop_name: The property that relies on another property.
|
||||
"""
|
||||
match method:
|
||||
case 'invalidate':
|
||||
if not cls.blfield_deps:
|
||||
cls.blfield_deps = {}
|
||||
deps = cls.blfield_deps
|
||||
case 'reset_enum':
|
||||
if not cls.blfield_dynamic_enum_deps:
|
||||
cls.blfield_dynamic_enum_deps = {}
|
||||
deps = cls.blfield_dynamic_enum_deps
|
||||
case 'reset_strsearch':
|
||||
if not cls.blfield_str_search_deps:
|
||||
cls.blfield_str_search_deps = {}
|
||||
deps = cls.blfield_str_search_deps
|
||||
|
||||
if deps.get(src_prop_name) is None:
|
||||
deps[src_prop_name] = []
|
||||
|
||||
deps[src_prop_name].append(dst_prop_name)
|
||||
|
||||
@classmethod
|
||||
def set_prop(
|
||||
cls,
|
||||
bl_prop_name: str,
|
||||
prop: bpy.types.Property,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Adds a Blender property via `__annotations__`, so that it will be initialized on all subclasses.
|
||||
|
||||
**All Blender properties trigger an update method** when updated from the UI, in order to invalidate the non-persistent cache of the associated `BLField`.
|
||||
Specifically, this behavior happens in `on_bl_prop_changed()`.
|
||||
|
||||
However, whether anything else happens after that invalidation is entirely up to the particular `BLField`.
|
||||
Thus, `BLField` is put in charge of how/when updates occur.
|
||||
|
||||
Notes:
|
||||
In general, Blender properties can't be set on classes directly.
|
||||
They must be added as type annotations, which Blender will read and understand.
|
||||
|
||||
This is essentially a convenience method to encapsulate this unexpected behavior, as well as constrain the behavior of the `update` method somewhat.
|
||||
|
||||
Parameters:
|
||||
bl_prop_name: The name of the property to set, as accessible from Blender.
|
||||
Generally, from code, the user would access the wrapping `BLField` instead of directly accessing the `bl_prop_name` attribute.
|
||||
prop: The `bpy.types.Property` to instantiate and attach..
|
||||
kwargs: Constructor arguments to pass to the Blender property.
|
||||
There are many mostly-documented nuances with these.
|
||||
The methods of `bl_cache.BLPropType` are designed to provide more strict, helpful abstractions for practical use.
|
||||
"""
|
||||
cls.__annotations__[bl_prop_name] = prop(
|
||||
update=lambda self, context: self.on_bl_prop_changed(bl_prop_name, context),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
####################
|
||||
# - Runtime Field Management
|
||||
####################
|
||||
def regenerate_dynamic_field_persistance(self):
|
||||
"""Regenerate the persisted data of all dynamic enums and str search BLFields.
|
||||
|
||||
In practice, this sets special "signal" values:
|
||||
- **Dynamic Enums**: The signal value `bl_cache.Signal.ResetEnumItems` will be set, causing `BLField.__set__` to regenerate the enum items using the user-provided callback.
|
||||
- **Searched Strings**: The signal value `bl_cache.Signal.ResetStrSearch` will be set, causing `BLField.__set__` to regenerate the available search strings using the user-provided callback.
|
||||
"""
|
||||
# Generate Enum Items
|
||||
## -> This guarantees that the items are persisted from the start.
|
||||
for dyn_enum_prop_name in self.blfields_dynamic_enum:
|
||||
setattr(self, dyn_enum_prop_name, bl_cache.Signal.ResetEnumItems)
|
||||
|
||||
# Generate Str Search Items
|
||||
## -> Match dynamic enum semantics
|
||||
for str_search_prop_name in self.blfields_str_search:
|
||||
setattr(self, str_search_prop_name, bl_cache.Signal.ResetStrSearch)
|
||||
|
||||
def on_bl_prop_changed(self, bl_prop_name: str, _: bpy.types.Context) -> None:
|
||||
"""Called when a property has been updated via the Blender UI.
|
||||
|
||||
The only effect is to invalidate the non-persistent cache of the associated BLField.
|
||||
The BLField then decides whether to take any other action, ex. calling `self.on_prop_changed()`.
|
||||
"""
|
||||
## TODO: What about non-Blender set properties?
|
||||
|
||||
# Strip the Internal Prefix
|
||||
## -> TODO: This is a bit of a hack. Use a contracts constant.
|
||||
prop_name = bl_prop_name.removeprefix('blfield__')
|
||||
# log.debug(
|
||||
# 'Callback on Property %s (stripped: %s)',
|
||||
# bl_prop_name,
|
||||
# prop_name,
|
||||
# )
|
||||
# log.debug(
|
||||
# 'Dependencies (PROP: %s) (ENUM: %s) (SEAR: %s)',
|
||||
# self.blfield_deps,
|
||||
# self.blfield_dynamic_enum_deps,
|
||||
# self.blfield_str_search_deps,
|
||||
# )
|
||||
|
||||
# Invalidate Property Cache
|
||||
## -> Only the non-persistent cache is regenerated.
|
||||
## -> The BLField decides whether to trigger `on_prop_changed`.
|
||||
if prop_name in self.blfields:
|
||||
# RULE: =1 DataChanged per Dependency Chain
|
||||
## -> We MUST invalidate the cache, but might not want to update.
|
||||
## -> Update should only be triggered when ==0 dependents.
|
||||
setattr(self, prop_name, bl_cache.Signal.InvalidateCacheNoUpdate)
|
||||
|
||||
# Invalidate Dependent Properties (incl. DynEnums and StrSearch)
|
||||
## -> NOTE: Dependent props may also trigger `on_prop_changed`.
|
||||
## -> Meaning, don't use extraneous dependencies (as usual).
|
||||
for deps, invalidate_signal in zip(
|
||||
[
|
||||
self.blfield_deps,
|
||||
self.blfield_dynamic_enum_deps,
|
||||
self.blfield_str_search_deps,
|
||||
],
|
||||
[
|
||||
bl_cache.Signal.InvalidateCache,
|
||||
bl_cache.Signal.ResetEnumItems,
|
||||
bl_cache.Signal.ResetStrSearch,
|
||||
],
|
||||
strict=True,
|
||||
):
|
||||
if prop_name in deps:
|
||||
for dst_prop_name in deps[prop_name]:
|
||||
# log.debug(
|
||||
# 'Property %s is invalidating %s',
|
||||
# prop_name,
|
||||
# dst_prop_name,
|
||||
# )
|
||||
setattr(
|
||||
self,
|
||||
dst_prop_name,
|
||||
invalidate_signal,
|
||||
)
|
||||
|
||||
# Do Update AFTER Dependencies
|
||||
## -> Yes, update will run once per dependency.
|
||||
## -> Don't abuse dependencies :)
|
||||
## -> If no-update is important, use_prop_update is still respected.
|
||||
setattr(self, prop_name, bl_cache.Signal.DoUpdate)
|
||||
|
||||
def on_prop_changed(self, prop_name: str) -> None:
|
||||
"""Triggers changes/an event chain based on a changed property.
|
||||
|
||||
In general, the `BLField` descriptor associated with `prop_name` decides whether this method should be called whenever `__set__` is used.
|
||||
An indirect consequence of this is that `self.on_bl_prop_changed`, which is _always_ triggered, may only _sometimes_ result in `on_prop_changed` being called, at the discretion of the relevant `BLField`.
|
||||
|
||||
Notes:
|
||||
**Must** be overridden on all `BLInstance` subclasses.
|
||||
"""
|
||||
raise NotImplementedError
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue