From c9936b89423a68222ec44488be350f741b977440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sofus=20Albert=20H=C3=B8gsbro=20Rose?= Date: Wed, 15 May 2024 12:37:38 +0200 Subject: [PATCH] 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. --- src/blender_maxwell/contracts/__init__.py | 4 + src/blender_maxwell/contracts/bl_types.py | 28 + .../maxwell_sim_nodes/bl_socket_map.py | 2 +- .../maxwell_sim_nodes/contracts/__init__.py | 2 + .../contracts/flow_kinds/__init__.py | 3 +- .../contracts/flow_kinds/array.py | 9 +- .../contracts/flow_kinds/flow_kinds.py | 43 + .../contracts/flow_kinds/lazy_array_range.py | 28 +- .../node_trees/maxwell_sim_nodes/node_tree.py | 25 +- .../nodes/analysis/extract_data.py | 3 +- .../nodes/analysis/math/filter_math.py | 16 +- .../nodes/analysis/math/map_math.py | 4 +- .../nodes/analysis/math/operate_math.py | 14 +- .../nodes/analysis/math/transform_math.py | 4 +- .../maxwell_sim_nodes/nodes/analysis/viz.py | 98 +- .../maxwell_sim_nodes/nodes/base.py | 323 +++--- .../bound_cond_nodes/absorbing_bound_cond.py | 7 +- .../bounds/bound_cond_nodes/pml_bound_cond.py | 14 +- .../inputs/constants/physical_constant.py | 3 +- .../maxwell_sim_nodes/nodes/inputs/scene.py | 84 +- .../nodes/inputs/wave_constant.py | 16 +- .../nodes/mediums/library_medium.py | 46 +- .../nodes/monitors/eh_field_monitor.py | 6 +- .../monitors/field_power_flux_monitor.py | 6 +- .../maxwell_sim_nodes/nodes/outputs/viewer.py | 32 +- .../web_exporters/tidy3d_web_exporter.py | 11 +- .../nodes/simulations/sim_domain.py | 4 +- .../nodes/sources/gaussian_beam_source.py | 6 +- .../nodes/sources/plane_wave_source.py | 4 +- .../nodes/sources/point_dipole_source.py | 2 +- .../nodes/structures/geonodes_structure.py | 2 +- .../structures/primitives/box_structure.py | 4 +- .../structures/primitives/sphere_structure.py | 5 +- .../maxwell_sim_nodes/sockets/base.py | 206 ++-- .../sockets/basic/file_path.py | 21 +- .../maxwell_sim_nodes/sockets/expr.py | 974 ++++++++++------ .../sockets/tidy3d/cloud_task.py | 7 +- src/blender_maxwell/utils/bl_cache.py | 1016 ----------------- .../utils/bl_cache/__init__.py | 36 + .../utils/bl_cache/bl_field.py | 424 +++++++ src/blender_maxwell/utils/bl_cache/bl_prop.py | 235 ++++ .../utils/bl_cache/bl_prop_type.py | 755 ++++++++++++ .../utils/bl_cache/cached_bl_property.py | 246 ++++ .../utils/bl_cache/keyed_cache.py | 146 +++ .../utils/bl_cache/managed_cache.py | 171 +++ src/blender_maxwell/utils/bl_cache/signal.py | 61 + src/blender_maxwell/utils/bl_instance.py | 299 +++++ .../utils/extra_sympy_units.py | 87 +- src/blender_maxwell/utils/staticproperty.py | 2 +- 49 files changed, 3591 insertions(+), 1953 deletions(-) create mode 100644 src/blender_maxwell/contracts/bl_types.py delete mode 100644 src/blender_maxwell/utils/bl_cache.py create mode 100644 src/blender_maxwell/utils/bl_cache/__init__.py create mode 100644 src/blender_maxwell/utils/bl_cache/bl_field.py create mode 100644 src/blender_maxwell/utils/bl_cache/bl_prop.py create mode 100644 src/blender_maxwell/utils/bl_cache/bl_prop_type.py create mode 100644 src/blender_maxwell/utils/bl_cache/cached_bl_property.py create mode 100644 src/blender_maxwell/utils/bl_cache/keyed_cache.py create mode 100644 src/blender_maxwell/utils/bl_cache/managed_cache.py create mode 100644 src/blender_maxwell/utils/bl_cache/signal.py create mode 100644 src/blender_maxwell/utils/bl_instance.py diff --git a/src/blender_maxwell/contracts/__init__.py b/src/blender_maxwell/contracts/__init__.py index 164ec1e..8953491 100644 --- a/src/blender_maxwell/contracts/__init__.py +++ b/src/blender_maxwell/contracts/__init__.py @@ -36,6 +36,7 @@ from .bl import ( PresetName, SocketName, ) +from .bl_types import BLEnumStrEnum from .operator_types import ( OperatorType, ) @@ -64,6 +65,9 @@ __all__ = [ 'ManagedObjName', 'PresetName', 'SocketName', + 'BLEnumStrEnum', + 'BLInstance', + 'InstanceID', 'OperatorType', 'PanelType', ] diff --git a/src/blender_maxwell/contracts/bl_types.py b/src/blender_maxwell/contracts/bl_types.py new file mode 100644 index 0000000..466276a --- /dev/null +++ b/src/blender_maxwell/contracts/bl_types.py @@ -0,0 +1,28 @@ +# blender_maxwell +# Copyright (C) 2024 blender_maxwell Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import typing as typ + + +#################### +# - Blender Enum (w/EnumProperty support) +#################### +class BLEnumStrEnum(typ.Protocol): + @staticmethod + def to_name(value: typ.Self) -> str: ... + + @staticmethod + def to_icon(value: typ.Self) -> str: ... diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/bl_socket_map.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/bl_socket_map.py index a975c74..ee62c9e 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/bl_socket_map.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/bl_socket_map.py @@ -44,7 +44,7 @@ def socket_def_from_bl_isocket( ## -> Accounts for any combo of shape/MathType/PhysicalType. if blsck_info.socket_type == ct.SocketType.Expr: return sockets.ExprSocketDef( - shape=blsck_info.size.shape, + size=blsck_info.size, mathtype=blsck_info.mathtype, physical_type=blsck_info.physical_type, default_unit=ct.UNITS_BLENDER[blsck_info.physical_type], diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py index fbc53a9..b672d2f 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py @@ -50,6 +50,7 @@ from .flow_kinds import ( LazyArrayRangeFlow, LazyValueFuncFlow, ParamsFlow, + ScalingMode, ValueFlow, ) from .flow_signals import FlowSignal @@ -116,6 +117,7 @@ __all__ = [ 'LazyArrayRangeFlow', 'LazyValueFuncFlow', 'ParamsFlow', + 'ScalingMode', 'ValueFlow', 'FlowSignal', ] diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/__init__.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/__init__.py index 6fa20a0..bac21fb 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/__init__.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/__init__.py @@ -18,7 +18,7 @@ from .array import ArrayFlow from .capabilities import CapabilitiesFlow from .flow_kinds import FlowKind from .info import InfoFlow -from .lazy_array_range import LazyArrayRangeFlow +from .lazy_array_range import LazyArrayRangeFlow, ScalingMode from .lazy_value_func import LazyValueFuncFlow from .params import ParamsFlow from .value import ValueFlow @@ -29,6 +29,7 @@ __all__ = [ 'FlowKind', 'InfoFlow', 'LazyArrayRangeFlow', + 'ScalingMode', 'LazyValueFuncFlow', 'ParamsFlow', 'ValueFlow', diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/array.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/array.py index c9428ab..bc49b12 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/array.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/array.py @@ -96,7 +96,8 @@ class ArrayFlow: msg = f'Tried to correct unit of unitless LazyDataValueRange "{corrected_unit}"' raise ValueError(msg) - def rescale_to_unit(self, unit: spu.Quantity) -> typ.Self: + def rescale_to_unit(self, unit: spu.Quantity | None) -> typ.Self: + ## TODO: Cache by unit would be a very nice speedup for Viz node. if self.unit is not None: return ArrayFlow( values=float(spux.scaling_factor(self.unit, unit)) * self.values, @@ -104,8 +105,8 @@ class ArrayFlow: is_sorted=self.is_sorted, ) + if unit is None: + return self + msg = f'Tried to rescale unitless LazyDataValueRange to unit {unit}' raise ValueError(msg) - - def rescale_to_unit_system(self, unit: spu.Quantity) -> typ.Self: - raise NotImplementedError diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/flow_kinds.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/flow_kinds.py index e713bd7..9baff92 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/flow_kinds.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/flow_kinds.py @@ -62,6 +62,9 @@ class FlowKind(enum.StrEnum): Params = enum.auto() Info = enum.auto() + #################### + # - Class Methods + #################### @classmethod def scale_to_unit_system( cls, @@ -85,3 +88,43 @@ class FlowKind(enum.StrEnum): msg = 'Tried to scale unknown kind' raise ValueError(msg) + + #################### + # - Computed + #################### + @property + def flow_kind(self) -> str: + return { + FlowKind.Value: FlowKind.Value, + FlowKind.Array: FlowKind.Array, + FlowKind.LazyValueFunc: FlowKind.LazyValueFunc, + FlowKind.LazyArrayRange: FlowKind.LazyArrayRange, + }[self] + + @property + def socket_shape(self) -> str: + return { + FlowKind.Value: 'CIRCLE', + FlowKind.Array: 'SQUARE', + FlowKind.LazyArrayRange: 'SQUARE', + FlowKind.LazyValueFunc: 'DIAMOND', + }[self] + + #################### + # - Blender Enum + #################### + @staticmethod + def to_name(v: typ.Self) -> str: + return { + FlowKind.Capabilities: 'Capabilities', + FlowKind.Value: 'Value', + FlowKind.Array: 'Array', + FlowKind.LazyArrayRange: 'Range', + FlowKind.LazyValueFunc: 'Lazy Value', + FlowKind.Params: 'Parameters', + FlowKind.Info: 'Information', + }[v] + + @staticmethod + def to_icon(_: typ.Self) -> str: + return '' diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_array_range.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_array_range.py index a98900e..867fb1d 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_array_range.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_array_range.py @@ -15,6 +15,7 @@ # along with this program. If not, see . import dataclasses +import enum import functools import typing as typ from types import MappingProxyType @@ -33,6 +34,25 @@ from .lazy_value_func import LazyValueFuncFlow log = logger.get(__name__) +class ScalingMode(enum.StrEnum): + Lin = enum.auto() + Geom = enum.auto() + Log = enum.auto() + + @staticmethod + def to_name(v: typ.Self) -> str: + SM = ScalingMode + return { + SM.Lin: 'Linear', + SM.Geom: 'Geometric', + SM.Log: 'Logarithmic', + }[v] + + @staticmethod + def to_icon(_: typ.Self) -> str: + return '' + + @dataclasses.dataclass(frozen=True, kw_only=True) class LazyArrayRangeFlow: r"""Represents a linearly/logarithmically spaced array using symbolic boundary expressions, with support for units and lazy evaluation. @@ -84,7 +104,7 @@ class LazyArrayRangeFlow: start: spux.ScalarUnitlessComplexExpr stop: spux.ScalarUnitlessComplexExpr steps: int - scaling: typ.Literal['lin', 'geom', 'log'] = 'lin' + scaling: ScalingMode = ScalingMode.Lin unit: spux.Unit | None = None @@ -295,9 +315,9 @@ class LazyArrayRangeFlow: A `jax` function that takes a valid `start`, `stop`, and `steps`, and returns a 1D `jax` array. """ jnp_nspace = { - 'lin': jnp.linspace, - 'geom': jnp.geomspace, - 'log': jnp.logspace, + ScalingMode.Lin: jnp.linspace, + ScalingMode.Geom: jnp.geomspace, + ScalingMode.Log: jnp.logspace, }.get(self.scaling) if jnp_nspace is None: msg = f'ArrayFlow scaling method {self.scaling} is unsupported' diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py index 79d74a2..797915c 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py @@ -430,17 +430,40 @@ class MaxwellSimTree(bpy.types.NodeTree): #################### # - Post-Load Handler #################### -def initialize_sim_tree_node_link_cache(_: bpy.types.Scene): +@bpy.app.handlers.persistent +def initialize_sim_tree_node_link_cache(_): """Whenever a file is loaded, create/regenerate the NodeLinkCache in all trees.""" for node_tree in bpy.data.node_groups: if node_tree.bl_idname == 'MaxwellSimTree': node_tree.on_load() +@bpy.app.handlers.persistent +def populate_missing_persistence(_) -> None: + """For all nodes and sockets with elements that don't have persistent elements computed, compute them. + + This is used when new dynamic enum properties are added to nodes and sockets, which need to first be computed and persisted in a context where setting properties is allowed. + """ + # Iterate over MaxwellSim Trees + for node_tree in [ + _node_tree + for _node_tree in bpy.data.node_groups + if _node_tree.bl_idname == ct.TreeType.MaxwellSim.value and _node_tree.is_active + ]: + # Iterate over MaxwellSim Nodes + # -> Excludes ex. frame and reroute nodes. + for node in [_node for _node in node_tree.nodes if hasattr(_node, 'node_type')]: + node.regenerate_dynamic_field_persistance() + for bl_sockets in [node.inputs, node.outputs]: + for bl_socket in bl_sockets: + bl_socket.regenerate_dynamic_field_persistance() + + #################### # - Blender Registration #################### bpy.app.handlers.load_post.append(initialize_sim_tree_node_link_cache) +bpy.app.handlers.load_post.append(populate_missing_persistence) ## TODO: Move to top-level registration. BL_REGISTER = [ diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py index 1c7d914..196e3b4 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py @@ -67,8 +67,7 @@ class ExtractDataNode(base.MaxwellSimNode): #################### # - Properties #################### - extract_filter: enum.Enum = bl_cache.BLField( - prop_ui=True, + extract_filter: enum.StrEnum = bl_cache.BLField( enum_cb=lambda self, _: self.search_extract_filters(), ) diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py index 281ed9d..d7d1bf3 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py @@ -126,10 +126,10 @@ class FilterMathNode(base.MaxwellSimNode): bl_label = 'Filter Math' input_sockets: typ.ClassVar = { - 'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array), + 'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc), } output_sockets: typ.ClassVar = { - 'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array), + 'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc), } #################### @@ -141,12 +141,8 @@ class FilterMathNode(base.MaxwellSimNode): ) # Dimension Selection - dim_0: enum.Enum = bl_cache.BLField( - None, prop_ui=True, enum_cb=lambda self, _: self.search_dims() - ) - dim_1: enum.Enum = bl_cache.BLField( - None, prop_ui=True, enum_cb=lambda self, _: self.search_dims() - ) + dim_0: enum.StrEnum = bl_cache.BLField(enum_cb=lambda self, _: self.search_dims()) + dim_1: enum.StrEnum = bl_cache.BLField(enum_cb=lambda self, _: self.search_dims()) #################### # - Computed @@ -259,14 +255,14 @@ class FilterMathNode(base.MaxwellSimNode): # Determine Whether to Declare New Loose Input SOcket if ( current_bl_socket is None - or current_bl_socket.shape is not None + or current_bl_socket.size is not spux.NumberSize1D.Scalar or current_bl_socket.physical_type != pinned_physical_type or current_bl_socket.mathtype != wanted_mathtype ): self.loose_input_sockets = { 'Value': sockets.ExprSocketDef( active_kind=ct.FlowKind.Value, - shape=None, + size=spux.NumberSize1D.Scalar, physical_type=pinned_physical_type, mathtype=wanted_mathtype, default_unit=pinned_unit, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py index 2866702..9f52c9e 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py @@ -364,10 +364,10 @@ class MapMathNode(base.MaxwellSimNode): bl_label = 'Map Math' input_sockets: typ.ClassVar = { - 'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array), + 'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc), } output_sockets: typ.ClassVar = { - 'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array), + 'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc), } #################### diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/operate_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/operate_math.py index 717d7c8..7a52579 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/operate_math.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/operate_math.py @@ -71,22 +71,22 @@ class OperateMathNode(base.MaxwellSimNode): bl_label = 'Operate Math' input_sockets: typ.ClassVar = { - 'Expr L': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array), - 'Expr R': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array), + 'Expr L': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc), + 'Expr R': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc), } output_sockets: typ.ClassVar = { - 'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.Array), + 'Expr': sockets.ExprSocketDef(active_kind=ct.FlowKind.LazyValueFunc), } #################### # - Properties #################### - category: enum.Enum = bl_cache.BLField( - prop_ui=True, enum_cb=lambda self, _: self.search_categories() + category: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.search_categories() ) - operation: enum.Enum = bl_cache.BLField( - prop_ui=True, enum_cb=lambda self, _: self.search_operations() + operation: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.search_operations() ) def search_categories(self) -> list[ct.BLEnumElement]: diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/transform_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/transform_math.py index fdb5106..05a8027 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/transform_math.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/transform_math.py @@ -62,8 +62,8 @@ class TransformMathNode(base.MaxwellSimNode): #################### # - Properties #################### - operation: enum.Enum = bl_cache.BLField( - prop_ui=True, enum_cb=lambda self, _: self.search_operations() + operation: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.search_operations() ) def search_operations(self) -> list[ct.BLEnumElement]: diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py index c932da1..050bd80 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py @@ -209,7 +209,7 @@ class VizNode(base.MaxwellSimNode): #################### input_sockets: typ.ClassVar = { 'Expr': sockets.ExprSocketDef( - active_kind=ct.FlowKind.Array, + active_kind=ct.FlowKind.LazyValueFunc, symbols={_x := sp.Symbol('x', real=True)}, default_value=2 * _x, ), @@ -225,31 +225,33 @@ class VizNode(base.MaxwellSimNode): ##################### ## - Properties ##################### - viz_mode: enum.Enum = bl_cache.BLField( - prop_ui=True, enum_cb=lambda self, _: self.search_viz_modes() - ) - viz_target: enum.Enum = bl_cache.BLField( - prop_ui=True, enum_cb=lambda self, _: self.search_targets() - ) - - # Mode-Dependent Properties - colormap: image_ops.Colormap = bl_cache.BLField( - image_ops.Colormap.Viridis, prop_ui=True - ) - - ##################### - ## - Mode Searcher - ##################### - @property - def data_info(self) -> ct.InfoFlow | None: + @bl_cache.cached_bl_property() + def input_info(self) -> ct.InfoFlow | None: info = self._compute_input('Expr', kind=ct.FlowKind.Info) if not ct.FlowSignal.check(info): return info return None + viz_mode: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.search_viz_modes(), + cb_depends_on={'input_info'}, + ) + viz_target: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.search_targets(), + cb_depends_on={'viz_mode'}, + ) + + # Mode-Dependent Properties + colormap: image_ops.Colormap = bl_cache.BLField( + image_ops.Colormap.Viridis, + ) + + ##################### + ## - Searchers + ##################### def search_viz_modes(self) -> list[ct.BLEnumElement]: - if self.data_info is not None: + if self.input_info is not None: return [ ( viz_mode, @@ -258,14 +260,11 @@ class VizNode(base.MaxwellSimNode): VizMode.to_icon(viz_mode), i, ) - for i, viz_mode in enumerate(VizMode.valid_modes_for(self.data_info)) + for i, viz_mode in enumerate(VizMode.valid_modes_for(self.input_info)) ] return [] - ##################### - ## - Target Searcher - ##################### def search_targets(self) -> list[ct.BLEnumElement]: if self.viz_mode is not None: return [ @@ -302,20 +301,14 @@ class VizNode(base.MaxwellSimNode): input_sockets_optional={'Expr': True}, ) def on_any_changed(self, input_sockets: dict): + self.input_info = bl_cache.Signal.InvalidateCache + info = input_sockets['Expr'][ct.FlowKind.Info] params = input_sockets['Expr'][ct.FlowKind.Params] has_info = not ct.FlowSignal.check(info) has_params = not ct.FlowSignal.check(params) - # Reset Viz Mode/Target - has_nonpending_info = not ct.FlowSignal.check_single( - info, ct.FlowSignal.FlowPending - ) - if has_nonpending_info: - self.viz_mode = bl_cache.Signal.ResetEnumItems - self.viz_target = bl_cache.Signal.ResetEnumItems - # Provide Sockets for Symbol Realization ## -> This happens if Params contains not-yet-realized symbols. if has_info and has_params and params.symbols: @@ -325,7 +318,7 @@ class VizNode(base.MaxwellSimNode): self.loose_input_sockets = { sym.name: sockets.ExprSocketDef( active_kind=ct.FlowKind.LazyArrayRange, - shape=None, + size=spux.NumberSize1D.Scalar, mathtype=info.dim_mathtypes[sym.name], physical_type=info.dim_physical_types[sym.name], default_min=( @@ -340,22 +333,13 @@ class VizNode(base.MaxwellSimNode): ), default_steps=50, ) - for sym in sorted( - params.symbols, key=lambda el: info.dim_names.index(el.name) - ) + for sym in params.sorted_symbols if sym.name in info.dim_names } elif self.loose_input_sockets: self.loose_input_sockets = {} - @events.on_value_changed( - prop_name='viz_mode', - run_on_init=True, - ) - def on_viz_mode_changed(self): - self.viz_target = bl_cache.Signal.ResetEnumItems - ##################### ## - Plotting ##################### @@ -374,12 +358,15 @@ class VizNode(base.MaxwellSimNode): self, managed_objs, props, input_sockets, loose_input_sockets, unit_systems ): # Retrieve Inputs + lazy_value_func = input_sockets['Expr'][ct.FlowKind.LazyValueFunc] info = input_sockets['Expr'][ct.FlowKind.Info] params = input_sockets['Expr'][ct.FlowKind.Params] has_info = not ct.FlowSignal.check(info) has_params = not ct.FlowSignal.check(params) + # Invalid Mode | Target + ## -> To limit branching, return now if things aren't right. if ( not has_info or not has_params @@ -388,18 +375,21 @@ class VizNode(base.MaxwellSimNode): ): return - # Compute Data - lazy_value_func = input_sockets['Expr'][ct.FlowKind.LazyValueFunc] - symbol_values = ( - loose_input_sockets - if not params.symbols - else { - sym: loose_input_sockets[sym.name] + # Compute LazyArrayRanges for Symbols from Loose Sockets + ## -> These are the concrete values of the symbol for plotting. + ## -> In a quite nice turn of events, all this is cached lookups. + ## -> ...Unless something changed, in which case, well. It changed. + symbol_values = { + sym: ( + loose_input_sockets[sym.name] .realize_array.rescale_to_unit(info.dim_units[sym.name]) .values - for sym in params.sorted_symbols - } - ) + ) + for sym in params.sorted_symbols + } + + # Realize LazyValueFunc w/Symbolic Values, Unit System + ## -> This gives us the actual plot data! data = lazy_value_func.func_jax( *params.scaled_func_args( unit_systems['BlenderUnits'], symbol_values=symbol_values @@ -408,6 +398,9 @@ class VizNode(base.MaxwellSimNode): unit_systems['BlenderUnits'], symbol_values=symbol_values ), ) + + # Replace InfoFlow Indices w/Realized Symbolic Ranges + ## -> This ensures correct axis scaling. if params.symbols: info = info.rescale_dim_idxs(loose_input_sockets) @@ -417,6 +410,7 @@ class VizNode(base.MaxwellSimNode): lambda ax: VizMode.to_plotter(props['viz_mode'])(data, info, ax), bl_select=True, ) + if props['viz_target'] == VizTarget.Pixels: managed_objs['plot'].map_2d_to_image( data, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py index 9f51403..2cc41a6 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py @@ -20,17 +20,15 @@ Attributes: MANDATORY_PROPS: Properties that must be defined on the `MaxwellSimNode`. """ -## TODO: Check whether input_socket_sets and output_socket_sets have the right shape? Or just use a type checker... - +import enum import typing as typ -import uuid from collections import defaultdict from types import MappingProxyType import bpy import sympy as sp -from blender_maxwell.utils import bl_cache, logger +from blender_maxwell.utils import bl_cache, bl_instance, logger from .. import contracts as ct from .. import managed_objs as _managed_objs @@ -40,10 +38,20 @@ from . import presets as _presets log = logger.get(__name__) +#################### +# - Types +#################### +Sockets: typ.TypeAlias = dict[str, sockets.base.SocketDef] +Preset: typ.TypeAlias = dict[str, _presets.PresetDef] +ManagedObjs: typ.TypeAlias = dict[ct.ManagedObjName, type[_managed_objs.ManagedObj]] + MANDATORY_PROPS: set[str] = {'node_type', 'bl_label'} -class MaxwellSimNode(bpy.types.Node): +#################### +# - Node +#################### +class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): """A specialized Blender node for Maxwell simulations. Attributes: @@ -58,100 +66,111 @@ class MaxwellSimNode(bpy.types.Node): locked: Whether the node is currently 'locked' aka. non-editable. """ + #################### + # - Properties + #################### + node_type: ct.NodeType + bl_label: str + + # Features use_sim_node_name: bool = False - ## TODO: bl_description from first line of __doc__? - # Sockets - input_sockets: typ.ClassVar[dict[str, sockets.base.SocketDef]] = MappingProxyType( - {} - ) - output_sockets: typ.ClassVar[dict[str, sockets.base.SocketDef]] = MappingProxyType( - {} - ) - input_socket_sets: typ.ClassVar[dict[str, dict[str, sockets.base.SocketDef]]] = ( - MappingProxyType({}) - ) - output_socket_sets: typ.ClassVar[dict[str, dict[str, sockets.base.SocketDef]]] = ( - MappingProxyType({}) + # Declarations + input_sockets: typ.ClassVar[Sockets] = MappingProxyType({}) + output_sockets: typ.ClassVar[Sockets] = MappingProxyType({}) + + input_socket_sets: typ.ClassVar[dict[str, Sockets]] = MappingProxyType({}) + output_socket_sets: typ.ClassVar[dict[str, Sockets]] = MappingProxyType({}) + + managed_obj_types: typ.ClassVar[ManagedObjs] = MappingProxyType({}) + presets: typ.ClassVar[dict[str, Preset]] = MappingProxyType({}) + + ## __init_subclass__ Computed + bl_idname: str + + #################### + # - Fields + #################### + sim_node_name: str = bl_cache.BLField('') + + # Loose Sockets + loose_input_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({}) + loose_output_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({}) + + # UI Options + preview_active: bool = bl_cache.BLField(False) + locked: bool = bl_cache.BLField(False, use_prop_update=False) + + # Active Socket Set + active_socket_set: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.socket_sets_bl_enum() ) - # Presets - presets: typ.ClassVar[dict[str, dict[str, _presets.PresetDef]]] = MappingProxyType( - {} + @classmethod + def socket_sets_bl_enum(cls) -> list[ct.BLEnumElement]: + return [ + (socket_set_name, socket_set_name, socket_set_name, '', i) + for i, socket_set_name in enumerate(cls.socket_set_names()) + ] + + # Active Preset + active_preset: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.presets_bl_enum() ) - # Managed Objects - managed_obj_types: typ.ClassVar[ - dict[ct.ManagedObjName, type[_managed_objs.ManagedObj]] - ] = MappingProxyType({}) + @classmethod + def presets_bl_enum(cls) -> list[ct.BLEnumElement]: + return [ + ( + preset_name, + preset_def.label, + preset_def.description, + '', + i, + ) + for i, (preset_name, preset_def) in enumerate(cls.presets.items()) + ] - def reset_instance_id(self) -> None: - self.instance_id = str(uuid.uuid4()) + #################### + # - Managed Objects + #################### + @bl_cache.cached_bl_property(depends_on={'sim_node_name'}) + def managed_objs(self) -> dict[str, _managed_objs.ManagedObj]: + """Access the constructed managed objects defined in `self.managed_obj_types`. - # BLFields - blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({}) - ui_blfields: typ.ClassVar[set[str]] = frozenset() + Managed objects are special in that they **don't keep any non-reproducible state**. + In fact, all managed object state can generally be derived entirely from the managed object's `name` attribute. + As a result, **consistency in namespacing is of the utmost importance**, if reproducibility of managed objects is to be guaranteed. + + This name must be in sync with the name of the managed "thing", which is where this computed property comes in. + The node's half of the responsibility is to push a new name whenever `self.sim_node_name` changes. + """ + if self.managed_obj_types: + return { + mobj_name: mobj_type(self.sim_node_name) + for mobj_name, mobj_type in self.managed_obj_types.items() + } + + return {} #################### # - Class Methods #################### @classmethod - def _assert_attrs_valid(cls) -> None: - """Asserts that all mandatory attributes are defined on the class. - - The list of mandatory objects is sourced from `base.MANDATORY_PROPS`. - - Raises: - ValueError: If a mandatory attribute defined in `base.MANDATORY_PROPS` is not defined on the class. - """ - for cls_attr in MANDATORY_PROPS: - if not hasattr(cls, cls_attr): - msg = f'Node class {cls} does not define mandatory attribute "{cls_attr}".' - raise ValueError(msg) - - @classmethod - def declare_blfield( - cls, attr_name: str, bl_attr_name: str, prop_ui: bool = False - ) -> None: - cls.blfields = cls.blfields | {attr_name: bl_attr_name} - - if prop_ui: - cls.ui_blfields = cls.ui_blfields | {attr_name} - - @classmethod - def set_prop( - cls, - prop_name: str, - prop: bpy.types.Property, - no_update: bool = False, - update_with_name: str | None = None, - **kwargs, - ) -> None: - """Adds a Blender property to a class via `__annotations__`, so it initializes with any subclass. + def socket_set_names(cls) -> list[str]: + """Retrieve the names of socket sets, in an order-preserving way. Notes: - - Blender properties can't be set within `__init_subclass__` simply by adding attributes to the class; they must be added as type annotations. - - Must be called **within** `__init_subclass__`. + Semantically similar to `list(set(...) | set(...))`. - Parameters: - name: The name of the property to set. - prop: The `bpy.types.Property` to instantiate and attach.. - no_update: Don't attach a `self.on_prop_changed()` callback to the property's `update`. + Returns: + List of socket set names, without duplicates, in definition order. """ - _update_with_name = prop_name if update_with_name is None else update_with_name - extra_kwargs = ( - { - 'update': lambda self, context: self.on_prop_changed( - _update_with_name, context - ), - } - if not no_update - else {} - ) - cls.__annotations__[prop_name] = prop( - **kwargs, - **extra_kwargs, - ) + return (_input_socket_set_names := list(cls.input_socket_sets.keys())) + [ + output_socket_set_name + for output_socket_set_name in cls.output_socket_sets + if output_socket_set_name not in _input_socket_set_names + ] @classmethod def _gather_event_methods(cls) -> dict[str, typ.Callable[[], None]]: @@ -169,6 +188,7 @@ class MaxwellSimNode(bpy.types.Node): for attr_name in dir(cls) if hasattr(method := getattr(cls, attr_name), 'event') and method.event in set(ct.FlowEvent) + ## Forbidding blfields prevents triggering __get__ on bl_property ] event_methods_by_event = {event: [] for event in set(ct.FlowEvent)} for method in event_methods: @@ -176,22 +196,6 @@ class MaxwellSimNode(bpy.types.Node): return event_methods_by_event - @classmethod - def socket_set_names(cls) -> list[str]: - """Retrieve the names of socket sets, in an order-preserving way. - - Notes: - Semantically similar to `list(set(...) | set(...))`. - - Returns: - List of socket set names, without duplicates, in definition order. - """ - return (_input_socket_set_names := list(cls.input_socket_sets.keys())) + [ - output_socket_set_name - for output_socket_set_name in cls.output_socket_sets - if output_socket_set_name not in _input_socket_set_names - ] - #################### # - Subclass Initialization #################### @@ -204,64 +208,20 @@ class MaxwellSimNode(bpy.types.Node): """ log.debug('Initializing Node: %s', cls.node_type) super().__init_subclass__(**kwargs) - cls._assert_attrs_valid() + + # Check Attribute Validity + cls.assert_attrs_valid(MANDATORY_PROPS) # Node Properties - ## Identifiers cls.bl_idname: str = str(cls.node_type.value) - cls.set_prop('instance_id', bpy.props.StringProperty, no_update=True) - cls.set_prop('sim_node_name', bpy.props.StringProperty, default='') - - ## Special States - cls.set_prop('preview_active', bpy.props.BoolProperty, default=False) - cls.set_prop('locked', bpy.props.BoolProperty, no_update=True, default=False) - - ## Event Method Callbacks cls.event_methods_by_event = cls._gather_event_methods() - ## Active Socket Set - if len(cls.input_socket_sets) + len(cls.output_socket_sets) > 0: - socket_set_names = cls.socket_set_names() - cls.set_prop( - 'active_socket_set', - bpy.props.EnumProperty, - name='Active Socket Set', - items=[ - (socket_set_name, socket_set_name, socket_set_name) - for socket_set_name in socket_set_names - ], - default=socket_set_names[0], - ) - else: - cls.active_socket_set = None - - ## Active Preset - ## TODO: Validate Presets - if cls.presets: - cls.set_prop( - 'active_preset', - bpy.props.EnumProperty, - name='Active Preset', - description='The currently active preset', - items=[ - ( - preset_name, - preset_def.label, - preset_def.description, - ) - for preset_name, preset_def in cls.presets.items() - ], - default=next(cls.presets.keys()), - ) - else: - cls.active_preset = None - #################### - # - Events: Default + # - Events: Sim Node Name | Active Socket Set | Active Preset #################### @events.on_value_changed( prop_name='sim_node_name', - props={'sim_node_name'}, + props={'sim_node_name', 'managed_objs'}, stop_propagation=True, ) def _on_sim_node_name_changed(self, props): @@ -273,7 +233,7 @@ class MaxwellSimNode(bpy.types.Node): ) # Set Name of Managed Objects - for mobj in self.managed_objs.values(): + for mobj in props['managed_objs'].values(): mobj.name = props['sim_node_name'] ## Invalidate Cache @@ -290,7 +250,10 @@ class MaxwellSimNode(bpy.types.Node): self._sync_sockets() @events.on_value_changed( - prop_name='active_preset', props=['presets', 'active_preset'] + prop_name='active_preset', + run_on_init=True, + props={'presets', 'active_preset'}, + stop_propagation=True, ) def _on_active_preset_changed(self, props: dict): if props['active_preset'] is not None: @@ -313,6 +276,9 @@ class MaxwellSimNode(bpy.types.Node): ## TODO: Account for FlowKind bl_socket.value = socket_value + #################### + # - Events: Preview | Plot + #################### @events.on_show_plot(stop_propagation=False) def _on_show_plot(self): node_tree = self.id_data @@ -339,6 +305,9 @@ class MaxwellSimNode(bpy.types.Node): for mobj in self.managed_objs.values(): mobj.hide_preview() + #################### + # - Events: Lock + #################### @events.on_enable_lock() def _on_enabled_lock(self): # Set Locked to Active @@ -354,11 +323,8 @@ class MaxwellSimNode(bpy.types.Node): self.locked = False #################### - # - Loose Sockets w/Events + # - Events: Loose Sockets #################### - loose_input_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({}) - loose_output_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({}) - @events.on_value_changed(prop_name={'loose_input_sockets', 'loose_output_sockets'}) def _on_loose_sockets_changed(self): self._sync_sockets() @@ -516,23 +482,6 @@ class MaxwellSimNode(bpy.types.Node): self._prune_inactive_sockets() self._add_new_active_sockets() - #################### - # - Managed Objects - #################### - @bl_cache.cached_bl_property(persist=True) - def managed_objs(self) -> dict[str, _managed_objs.ManagedObj]: - """Access the managed objects defined on this node. - - Persistent cache ensures that the managed objects are only created on first access, even across file reloads. - """ - if self.managed_obj_types: - return { - mobj_name: mobj_type(self.sim_node_name) - for mobj_name, mobj_type in self.managed_obj_types.items() - } - - return {} - #################### # - Event Methods #################### @@ -733,7 +682,7 @@ class MaxwellSimNode(bpy.types.Node): ) ) - @bl_cache.cached_bl_property(persist=False) + @bl_cache.cached_bl_property() def _dependent_outputs( self, ) -> dict[ @@ -904,7 +853,7 @@ class MaxwellSimNode(bpy.types.Node): #################### # - Property Event: On Update #################### - def on_prop_changed(self, prop_name: str, _: bpy.types.Context) -> None: + def on_prop_changed(self, prop_name: str) -> None: """Report that a particular property has changed, which may cause certain caches to regenerate. Notes: @@ -916,10 +865,6 @@ class MaxwellSimNode(bpy.types.Node): prop_name: The name of the property that changed. """ if hasattr(self, prop_name): - # Invalidate UI BLField Caches - if prop_name in self.ui_blfields: - setattr(self, prop_name, bl_cache.Signal.InvalidateCache) - # Trigger Event self.trigger_event(ct.FlowEvent.DataChanged, prop_name=prop_name) else: @@ -952,16 +897,16 @@ class MaxwellSimNode(bpy.types.Node): layout.enabled = False if self.active_socket_set: - layout.prop(self, 'active_socket_set', text='') + layout.prop(self, self.blfields['active_socket_set'], text='') if self.active_preset is not None: - layout.prop(self, 'active_preset', text='') + layout.prop(self, self.blfields['active_preset'], text='') # Draw Name if self.use_sim_node_name: row = layout.row(align=True) row.label(text='', icon='FILE_TEXT') - row.prop(self, 'sim_node_name', text='') + row.prop(self, self.blfields['sim_node_name'], text='') # Draw Name self.draw_props(context, layout) @@ -1029,24 +974,20 @@ class MaxwellSimNode(bpy.types.Node): Notes: Run by Blender when a new instance of a node is added to a tree. """ + # Initialize Sockets + ## -> Ensures the availability of static sockets before items/methods. + ## -> Ensures the availability of static sockets before items/methods. + self._sync_sockets() + # Initialize Instance ID - ## This is used by various caches from 'bl_cache'. + ## -> This is used by various caches from 'bl_cache'. + ## -> Also generates (first-time) the various enums. self.reset_instance_id() # Initialize Name - ## This is used whenever a unique name pointing to this node is needed. - ## Contrary to self.name, it can be altered by the user as a property. + ## -> Ensures the availability of sim_node_name immediately. self.sim_node_name = self.name - # Initialize Sockets - ## This initializes any nodes that need initializing - self._sync_sockets() - - # Apply Preset - ## This applies the default preset, if any. - if self.active_preset: - self._on_active_preset_changed() - # Event Methods ## Run any 'DataChanged' methods with 'run_on_init' set. ## Semantically: Creating data _arguably_ changes it. diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/absorbing_bound_cond.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/absorbing_bound_cond.py index bb3b6ff..2bf8b6b 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/absorbing_bound_cond.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/absorbing_bound_cond.py @@ -61,7 +61,7 @@ class AdiabAbsorbBoundCondNode(base.MaxwellSimNode): #################### input_sockets: typ.ClassVar = { 'Layers': sockets.ExprSocketDef( - shape=None, + size=spux.NumberSize1D.Scalar, mathtype=spux.MathType.Integer, abs_min=1, default_value=40, @@ -71,14 +71,13 @@ class AdiabAbsorbBoundCondNode(base.MaxwellSimNode): 'Simple': {}, 'Full': { 'σ Order': sockets.ExprSocketDef( - shape=None, + size=spux.NumberSize1D.Scalar, mathtype=spux.MathType.Integer, abs_min=1, default_value=3, ), 'σ Range': sockets.ExprSocketDef( - shape=(2,), - mathtype=spux.MathType.Real, + size=spux.NumberSize1D.Vec2, default_value=sp.Matrix([0, 1.5]), abs_min=0, ), diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/pml_bound_cond.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/pml_bound_cond.py index d973316..9609a7e 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/pml_bound_cond.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/bounds/bound_cond_nodes/pml_bound_cond.py @@ -64,7 +64,7 @@ class PMLBoundCondNode(base.MaxwellSimNode): #################### input_sockets: typ.ClassVar = { 'Layers': sockets.ExprSocketDef( - shape=None, + size=spux.NumberSize1D.Scalar, mathtype=spux.MathType.Integer, abs_min=1, default_value=12, @@ -74,37 +74,37 @@ class PMLBoundCondNode(base.MaxwellSimNode): 'Simple': {}, 'Full': { 'σ Order': sockets.ExprSocketDef( - shape=None, + size=spux.NumberSize1D.Scalar, mathtype=spux.MathType.Integer, abs_min=1, default_value=3, ), 'σ Range': sockets.ExprSocketDef( - shape=(2,), + size=spux.NumberSize1D.Vec2, mathtype=spux.MathType.Real, default_value=sp.Matrix([0, 1.5]), abs_min=0, ), 'κ Order': sockets.ExprSocketDef( - shape=None, + size=spux.NumberSize1D.Scalar, mathtype=spux.MathType.Integer, abs_min=1, default_value=3, ), 'κ Range': sockets.ExprSocketDef( - shape=(2,), + size=spux.NumberSize1D.Vec2, mathtype=spux.MathType.Real, default_value=sp.Matrix([0, 1.5]), abs_min=0, ), 'α Order': sockets.ExprSocketDef( - shape=None, + size=spux.NumberSize1D.Scalar, mathtype=spux.MathType.Integer, abs_min=1, default_value=3, ), 'α Range': sockets.ExprSocketDef( - shape=(2,), + size=spux.NumberSize1D.Vec2, mathtype=spux.MathType.Real, default_value=sp.Matrix([0, 1.5]), abs_min=0, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/physical_constant.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/physical_constant.py index 6da242b..8897416 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/physical_constant.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/physical_constant.py @@ -59,7 +59,6 @@ class PhysicalConstantNode(base.MaxwellSimNode): size: spux.NumberSize1D = bl_cache.BLField( enum_cb=lambda self, _: self.search_sizes(), - prop_ui=True, ) #################### @@ -75,7 +74,7 @@ class PhysicalConstantNode(base.MaxwellSimNode): return [ spux.NumberSize1D.from_shape(shape).bl_enum_element(i) for i, shape in enumerate(self.physical_type.valid_shapes) - if spux.NumberSize1D.supports_shape(shape) + if spux.NumberSize1D.has_shape(shape) ] #################### diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/scene.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/scene.py index 2978a2b..6239cb7 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/scene.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/scene.py @@ -16,11 +16,11 @@ """Implements `SceneNode`.""" -import enum import typing as typ import bpy import sympy as sp +import sympy.physics.units as spu from blender_maxwell.utils import bl_cache, logger from blender_maxwell.utils import extra_sympy_units as spux @@ -45,7 +45,11 @@ class SceneNode(base.MaxwellSimNode): input_sockets: typ.ClassVar = { 'Frames / Unit': sockets.ExprSocketDef( mathtype=spux.MathType.Integer, - default_value=24, + default_value=48, + ), + 'Unit': sockets.ExprSocketDef( + default_unit=spu.ps, + default_value=1, ), } output_sockets: typ.ClassVar = { @@ -60,7 +64,7 @@ class SceneNode(base.MaxwellSimNode): #################### # - Properties: Frame #################### - @property + @bl_cache.cached_bl_property() def scene_frame(self) -> int: """Retrieve the current frame of the scene. @@ -71,6 +75,7 @@ class SceneNode(base.MaxwellSimNode): @property def scene_frame_range(self) -> ct.LazyArrayRangeFlow: + """Retrieve the current start/end frame of the scene, with `steps` corresponding to single-frame steps.""" frame_start = bpy.context.scene.frame_start frame_stop = bpy.context.scene.frame_end return ct.LazyArrayRangeFlow( @@ -79,69 +84,20 @@ class SceneNode(base.MaxwellSimNode): steps=frame_stop - frame_start + 1, ) - #################### - # - Property: Time Unit - #################### - active_time_unit: enum.Enum = bl_cache.BLField( - enum_cb=lambda self, _: self.search_units(), prop_ui=True - ) - - def search_units(self) -> list[ct.BLEnumElement]: - return [ - (sp.sstr(unit), spux.sp_to_str(unit), sp.sstr(unit), '', i) - for i, unit in enumerate(spux.PhysicalType.Time.valid_units) - ] - - @property - def time_unit(self) -> spux.Unit | None: - """Gets the current active unit. - - Returns: - The current active `sympy` unit. - - If the socket expression is unitless, this returns `None`. - """ - if self.active_time_unit is not None: - return spux.unit_str_to_unit(self.active_time_unit) - - return None - - @time_unit.setter - def time_unit(self, time_unit: spux.Unit | None) -> None: - """Set the unit, without touching the `raw_*` UI properties. - - Notes: - To set a new unit, **and** convert the `raw_*` UI properties to the new unit, use `self.convert_unit()` instead. - """ - if time_unit in spux.PhysicalType.Time.valid_units: - self.active_time_unit = sp.sstr(time_unit) - else: - msg = f'Tried to set invalid time unit {time_unit}' - raise ValueError(msg) - - #################### - # - UI - #################### - def draw_props(self, _: bpy.types.Context, col: bpy.types.UILayout) -> None: - """Draws the button that allows toggling between single and range output. - - Parameters: - col: Target for defining UI elements. - """ - col.prop(self, self.blfields['active_time_unit'], toggle=True, text='Unit') - #################### # - FlowKinds #################### @events.computes_output_socket( 'Time', kind=ct.FlowKind.Value, - input_sockets={'Frames / Unit'}, - props={'scene_frame', 'active_time_unit', 'time_unit'}, + input_sockets={'Frames / Unit', 'Unit'}, + props={'scene_frame'}, ) def compute_time(self, props, input_sockets) -> sp.Expr: return ( - props['scene_frame'] / input_sockets['Frames / Unit'] * props['time_unit'] + props['scene_frame'] + / input_sockets['Frames / Unit'] + * input_sockets['Unit'] ) @events.computes_output_socket( @@ -159,10 +115,18 @@ class SceneNode(base.MaxwellSimNode): BL_REGISTER = [ SceneNode, ] +BL_NODES = {ct.NodeType.Scene: (ct.NodeCategory.MAXWELLSIM_INPUTS)} +#################### +# - Blender Handlers +#################### @bpy.app.handlers.persistent -def update_scene_node_after_frame_changed(scene, depsgraph) -> None: +def update_scene_node_after_frame_changed( + scene: bpy.types.Scene, # noqa: ARG001 + depsgraph: bpy.types.Depsgraph, # noqa: ARG001 +) -> None: + """Invalidate the cached scene frame on all `SceneNode`s in all active simulation node trees, whenever the frame changes.""" for node_tree in [ _node_tree for _node_tree in bpy.data.node_groups @@ -173,9 +137,7 @@ def update_scene_node_after_frame_changed(scene, depsgraph) -> None: for _node in node_tree.nodes if hasattr(_node, 'node_type') and _node.node_type == ct.NodeType.Scene ]: - node.trigger_event(ct.FlowEvent.DataChanged, prop_name='scene_frame') + node.scene_frame = bl_cache.Signal.InvalidateCache bpy.app.handlers.frame_change_post.append(update_scene_node_after_frame_changed) - -BL_NODES = {ct.NodeType.Scene: (ct.NodeCategory.MAXWELLSIM_INPUTS)} diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/wave_constant.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/wave_constant.py index bd38d9b..11ca6c9 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/wave_constant.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/wave_constant.py @@ -49,36 +49,28 @@ class WaveConstantNode(base.MaxwellSimNode): input_socket_sets: typ.ClassVar = { 'Wavelength': { 'WL': sockets.ExprSocketDef( - active_kind=ct.FlowKind.Value, - physical_type=spux.PhysicalType.Length, - # Defaults default_unit=spu.nm, default_value=500, default_min=200, default_max=700, - default_steps=2, + default_steps=50, ) }, 'Frequency': { 'Freq': sockets.ExprSocketDef( - active_kind=ct.FlowKind.Value, - physical_type=spux.PhysicalType.Freq, - # Defaults default_unit=spux.THz, default_value=1, default_min=0.3, default_max=3, - default_steps=2, + default_steps=50, ), }, } output_sockets: typ.ClassVar = { 'WL': sockets.ExprSocketDef( - active_kind=ct.FlowKind.Value, physical_type=spux.PhysicalType.Length, ), 'Freq': sockets.ExprSocketDef( - active_kind=ct.FlowKind.Value, physical_type=spux.PhysicalType.Freq, ), } @@ -86,7 +78,7 @@ class WaveConstantNode(base.MaxwellSimNode): #################### # - Properties #################### - use_range: bool = bl_cache.BLField(False, prop_ui=True) + use_range: bool = bl_cache.BLField(False) #################### # - UI @@ -192,6 +184,7 @@ class WaveConstantNode(base.MaxwellSimNode): sci_constants.vac_speed_of_light / (freq.start * freq.unit), spu.um ), steps=freq.steps, + scaling=freq.scaling, unit=spu.um, ) @@ -220,6 +213,7 @@ class WaveConstantNode(base.MaxwellSimNode): sci_constants.vac_speed_of_light / (wl.start * wl.unit), spux.THz ), steps=wl.steps, + scaling=wl.scaling, unit=spux.THz, ) diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/mediums/library_medium.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/mediums/library_medium.py index bced2a4..121b053 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/mediums/library_medium.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/mediums/library_medium.py @@ -109,11 +109,7 @@ class LibraryMediumNode(base.MaxwellSimNode): #################### # - Sockets #################### - input_sockets: typ.ClassVar = { - 'Generated Steps': sockets.ExprSocketDef( - mathtype=spux.MathType.Integer, default_value=2, abs_min=2 - ) - } + input_sockets: typ.ClassVar = {} output_sockets: typ.ClassVar = { 'Medium': sockets.MaxwellMediumSocketDef(), 'Valid Freqs': sockets.ExprSocketDef( @@ -133,9 +129,9 @@ class LibraryMediumNode(base.MaxwellSimNode): #################### # - Properties #################### - vendored_medium: VendoredMedium = bl_cache.BLField(VendoredMedium.Au, prop_ui=True) - variant_name: enum.Enum = bl_cache.BLField( - prop_ui=True, enum_cb=lambda self, _: self.search_variants() + vendored_medium: VendoredMedium = bl_cache.BLField(VendoredMedium.Au) + variant_name: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.search_variants() ) def search_variants(self) -> list[ct.BLEnumElement]: @@ -145,28 +141,28 @@ class LibraryMediumNode(base.MaxwellSimNode): #################### # - Computed #################### - @property + @bl_cache.cached_bl_property(depends_on={'vendored_medium', 'variant_name'}) def variant(self) -> Tidy3DMediumVariant: """Deduce the actual medium variant from `self.vendored_medium` and `self.variant_name`.""" return self.vendored_medium.medium_variants[self.variant_name] - @property + @bl_cache.cached_bl_property(depends_on={'variant'}) def medium(self) -> td.PoleResidue: """Deduce the actual currently selected `PoleResidue` medium from `self.variant`.""" return self.variant.medium - @property + @bl_cache.cached_bl_property(depends_on={'variant'}) def data_url(self) -> str | None: """Deduce the URL associated with the currently selected medium from `self.variant`.""" return self.variant.data_url - @property + @bl_cache.cached_bl_property(depends_on={'variant'}) def references(self) -> td.PoleResidue: """Deduce the references associated with the currently selected `PoleResidue` medium from `self.variant`.""" return self.variant.reference - @property - def freq_range(self) -> spux.SympyExpr: + @bl_cache.cached_bl_property(depends_on={'medium'}) + def freq_range(self) -> sp.Expr: """Deduce the frequency range as a unit-aware (THz, for convenience) column vector. A rational approximation to each frequency bound is computed with `sp.nsimplify`, in order to **guarantee** lack of precision-loss as computations are performed on the frequency. @@ -178,8 +174,8 @@ class LibraryMediumNode(base.MaxwellSimNode): spux.terahertz, ) - @property - def wl_range(self) -> spux.SympyExpr: + @bl_cache.cached_bl_property(depends_on={'freq_range'}) + def wl_range(self) -> sp.Expr: """Deduce the vacuum wavelength range as a unit-aware (nanometer, for convenience) column vector.""" return sp.Matrix( self.freq_range.applyfunc( @@ -203,12 +199,12 @@ class LibraryMediumNode(base.MaxwellSimNode): formatted_str = f'{number:.2e}' return formatted_str - @bl_cache.cached_bl_property() + @bl_cache.cached_bl_property(depends_on={'freq_range'}) def ui_freq_range(self) -> tuple[str, str]: """Cached mirror of `self.wl_range` which contains UI-ready strings.""" return tuple([self._ui_range_format(el) for el in self.freq_range]) - @bl_cache.cached_bl_property() + @bl_cache.cached_bl_property(depends_on={'wl_range'}) def ui_wl_range(self) -> tuple[str, str]: """Cached mirror of `self.wl_range` which contains UI-ready strings.""" return tuple([self._ui_range_format(el) for el in self.wl_range]) @@ -279,14 +275,13 @@ class LibraryMediumNode(base.MaxwellSimNode): 'Valid Freqs', kind=ct.FlowKind.LazyArrayRange, props={'freq_range'}, - input_sockets={'Generated Steps'}, ) - def compute_valid_freqs_lazy(self, props, input_sockets) -> sp.Expr: + def compute_valid_freqs_lazy(self, props) -> sp.Expr: return ct.LazyArrayRangeFlow( start=props['freq_range'][0] / spux.THz, stop=props['freq_range'][1] / spux.THz, - steps=input_sockets['Generated Steps'], - scaling='lin', + steps=0, + scaling=ct.ScalingMode.Lin, unit=spux.THz, ) @@ -301,14 +296,13 @@ class LibraryMediumNode(base.MaxwellSimNode): 'Valid WLs', kind=ct.FlowKind.LazyArrayRange, props={'wl_range'}, - input_sockets={'Generated Steps'}, ) - def compute_valid_wls_lazy(self, props, input_sockets) -> sp.Expr: + def compute_valid_wls_lazy(self, props) -> sp.Expr: return ct.LazyArrayRangeFlow( start=props['wl_range'][0] / spu.nm, stop=props['wl_range'][0] / spu.nm, - steps=input_sockets['Generated Steps'], - scaling='lin', + steps=0, + scaling=ct.ScalingMode.Lin, unit=spu.nm, ) diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/eh_field_monitor.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/eh_field_monitor.py index 83c904c..af9469d 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/eh_field_monitor.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/eh_field_monitor.py @@ -43,16 +43,16 @@ class EHFieldMonitorNode(base.MaxwellSimNode): #################### input_sockets: typ.ClassVar = { 'Center': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, physical_type=spux.PhysicalType.Length, ), 'Size': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, physical_type=spux.PhysicalType.Length, default_value=sp.Matrix([1, 1, 1]), ), 'Spatial Subdivs': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, mathtype=spux.MathType.Integer, default_value=sp.Matrix([10, 10, 10]), ), diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/field_power_flux_monitor.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/field_power_flux_monitor.py index 3672309..d0519ed 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/field_power_flux_monitor.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/field_power_flux_monitor.py @@ -41,16 +41,16 @@ class PowerFluxMonitorNode(base.MaxwellSimNode): #################### input_sockets: typ.ClassVar = { 'Center': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, physical_type=spux.PhysicalType.Length, ), 'Size': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, physical_type=spux.PhysicalType.Length, default_value=sp.Matrix([1, 1, 1]), ), 'Samples/Space': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, mathtype=spux.MathType.Integer, default_value=sp.Matrix([10, 10, 10]), ), diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/viewer.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/viewer.py index 85d892e..00e19a4 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/viewer.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/viewer.py @@ -19,8 +19,8 @@ import typing as typ import bpy import sympy as sp +from blender_maxwell.utils import bl_cache, logger from blender_maxwell.utils import extra_sympy_units as spux -from blender_maxwell.utils import logger from ... import contracts as ct from ... import sockets @@ -73,33 +73,15 @@ class ViewerNode(base.MaxwellSimNode): #################### # - Properties #################### - print_kind: bpy.props.EnumProperty( - name='Print Kind', - description='FlowKind of the input socket to print', - items=[(kind, kind.name, kind.name) for kind in list(ct.FlowKind)], - default=ct.FlowKind.Value, - update=lambda self, context: self.on_prop_changed('print_kind', context), - ) - - auto_plot: bpy.props.BoolProperty( - name='Auto-Plot', - description='Whether to auto-plot anything plugged into the viewer node', - default=False, - update=lambda self, context: self.on_prop_changed('auto_plot', context), - ) - - auto_3d_preview: bpy.props.BoolProperty( - name='Auto 3D Preview', - description="Whether to auto-preview anything 3D, that's plugged into the viewer node", - default=True, - update=lambda self, context: self.on_prop_changed('auto_3d_preview', context), - ) + print_kind: ct.FlowKind = bl_cache.BLField(ct.FlowKind.Value) + auto_plot: bool = bl_cache.BLField(False) + auto_3d_preview: bool = bl_cache.BLField(True) #################### # - UI #################### def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout): - layout.prop(self, 'print_kind', text='') + layout.prop(self, self.blfields['print_kind'], text='') def draw_operators(self, _: bpy.types.Context, layout: bpy.types.UILayout): split = layout.split(factor=0.4) @@ -118,7 +100,7 @@ class ViewerNode(base.MaxwellSimNode): ## Plot Options row = col.row(align=True) - row.prop(self, 'auto_plot', text='Plot', toggle=True) + row.prop(self, self.blfields['auto_plot'], text='Plot', toggle=True) row.operator( RefreshPlotViewOperator.bl_idname, text='', @@ -127,7 +109,7 @@ class ViewerNode(base.MaxwellSimNode): ## 3D Preview Options row = col.row(align=True) - row.prop(self, 'auto_3d_preview', text='3D Preview', toggle=True) + row.prop(self, self.blfields['auto_3d_preview'], text='3D Preview', toggle=True) #################### # - Methods diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py index 45f5782..3371d6a 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py @@ -144,7 +144,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): #################### # - Computed - Sim #################### - @bl_cache.cached_bl_property(persist=False) + @bl_cache.cached_bl_property() def sim(self) -> td.Simulation | None: sim = self._compute_input('Sim') has_sim = not ct.FlowSignal.check(sim) @@ -153,7 +153,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): return sim return None - @bl_cache.cached_bl_property(persist=False) + @bl_cache.cached_bl_property() def total_monitor_data(self) -> float | None: if self.sim is not None: return sum(self.sim.monitors_data_size.values()) @@ -188,8 +188,9 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): If one can't be loaded, return None. """ has_uploaded_task = self.uploaded_task_id != '' + has_new_cloud_task = self.new_cloud_task is not None - if has_uploaded_task: + if has_uploaded_task and has_new_cloud_task: return tdcloud.TidyCloudTasks.tasks(self.new_cloud_task.cloud_folder).get( self.uploaded_task_id ) @@ -206,7 +207,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): return tdcloud.TidyCloudTasks.task_info(self.uploaded_task_id) return None - @bl_cache.cached_bl_property(persist=False) + @bl_cache.cached_bl_property() def uploaded_est_cost(self) -> float | None: task_info = self.uploaded_task_info if task_info is not None: @@ -219,7 +220,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): #################### # - Computed - Combined #################### - @bl_cache.cached_bl_property(persist=False) + @bl_cache.cached_bl_property() def is_sim_uploadable(self) -> bool: if ( self.sim is not None diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/sim_domain.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/sim_domain.py index e283b8e..1c060c6 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/sim_domain.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/sim_domain.py @@ -43,14 +43,14 @@ class SimDomainNode(base.MaxwellSimNode): abs_min=0, ), 'Center': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Length, default_unit=spu.micrometer, default_value=sp.Matrix([0, 0, 0]), ), 'Size': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Length, default_unit=spu.micrometer, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/gaussian_beam_source.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/gaussian_beam_source.py index fc72c76..cc584f1 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/gaussian_beam_source.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/gaussian_beam_source.py @@ -54,13 +54,13 @@ class GaussianBeamSourceNode(base.MaxwellSimNode): input_sockets: typ.ClassVar = { 'Temporal Shape': sockets.MaxwellTemporalShapeSocketDef(), 'Center': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Length, default_value=sp.Matrix([0, 0, 0]), ), 'Size': sockets.ExprSocketDef( - shape=(2,), + size=spux.NumberSize1D.Vec2, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Length, default_value=sp.Matrix([1, 1]), @@ -77,7 +77,7 @@ class GaussianBeamSourceNode(base.MaxwellSimNode): abs_min=0.01, ), 'Spherical': sockets.ExprSocketDef( - shape=(2,), + size=spux.NumberSize1D.Vec2, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Angle, default_value=sp.Matrix([0, 0]), diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/plane_wave_source.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/plane_wave_source.py index 7927836..994aca3 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/plane_wave_source.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/plane_wave_source.py @@ -52,13 +52,13 @@ class PlaneWaveSourceNode(base.MaxwellSimNode): input_sockets: typ.ClassVar = { 'Temporal Shape': sockets.MaxwellTemporalShapeSocketDef(), 'Center': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Length, default_value=sp.Matrix([0, 0, 0]), ), 'Spherical': sockets.ExprSocketDef( - shape=(2,), + size=spux.NumberSize1D.Vec2, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Angle, default_value=sp.Matrix([0, 0]), diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/point_dipole_source.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/point_dipole_source.py index 664f576..6ef0582 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/point_dipole_source.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/sources/point_dipole_source.py @@ -42,7 +42,7 @@ class PointDipoleSourceNode(base.MaxwellSimNode): input_sockets: typ.ClassVar = { 'Temporal Shape': sockets.MaxwellTemporalShapeSocketDef(), 'Center': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Length, default_value=sp.Matrix([0, 0, 0]), diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/geonodes_structure.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/geonodes_structure.py index 3a91fc5..e3a0ad2 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/geonodes_structure.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/geonodes_structure.py @@ -42,7 +42,7 @@ class GeoNodesStructureNode(base.MaxwellSimNode): 'GeoNodes': sockets.BlenderGeoNodesSocketDef(), 'Medium': sockets.MaxwellMediumSocketDef(), 'Center': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Length, default_unit=spu.micrometer, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/box_structure.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/box_structure.py index e713b20..8ae14f8 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/box_structure.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/box_structure.py @@ -42,14 +42,14 @@ class BoxStructureNode(base.MaxwellSimNode): input_sockets: typ.ClassVar = { 'Medium': sockets.MaxwellMediumSocketDef(), 'Center': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Length, default_unit=spu.micrometer, default_value=sp.Matrix([0, 0, 0]), ), 'Size': sockets.ExprSocketDef( - shape=(3,), + size=spux.NumberSize1D.Vec3, mathtype=spux.MathType.Real, physical_type=spux.PhysicalType.Length, default_unit=spu.nanometer, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/sphere_structure.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/sphere_structure.py index ea7da53..2b18598 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/sphere_structure.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/sphere_structure.py @@ -42,14 +42,11 @@ class SphereStructureNode(base.MaxwellSimNode): input_sockets: typ.ClassVar = { 'Medium': sockets.MaxwellMediumSocketDef(), 'Center': sockets.ExprSocketDef( - shape=(3,), - mathtype=spux.MathType.Real, - physical_type=spux.PhysicalType.Length, + size=spux.NumberSize1D.Vec3, default_unit=spu.micrometer, default_value=sp.Matrix([0, 0, 0]), ), 'Radius': sockets.ExprSocketDef( - physical_type=spux.PhysicalType.Length, default_unit=spu.nanometer, default_value=150, ), diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py index 6d05d33..a657c34 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py @@ -16,13 +16,11 @@ import abc import typing as typ -import uuid -from types import MappingProxyType import bpy import pydantic as pyd -from blender_maxwell.utils import bl_cache, logger, serialize +from blender_maxwell.utils import bl_cache, bl_instance, logger, serialize from .. import contracts as ct @@ -60,7 +58,7 @@ class SocketDef(pyd.BaseModel, abc.ABC): Parameters: bl_socket: The Blender node socket to alter using data from this SocketDef. """ - bl_socket.initializing = False + bl_socket.is_initializing = False bl_socket.on_active_kind_changed() @abc.abstractmethod @@ -116,9 +114,12 @@ class SocketDef(pyd.BaseModel, abc.ABC): #################### -# - SocketDef +# - Socket #################### -class MaxwellSimSocket(bpy.types.NodeSocket): +MANDATORY_PROPS: set[str] = {'socket_type', 'bl_label'} + + +class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): """A specialized Blender socket for nodes in a Maxwell simulation. Attributes: @@ -128,112 +129,45 @@ class MaxwellSimSocket(bpy.types.NodeSocket): locked: The lock-state of a particular socket, which determines the socket's user editability """ - # Fundamentals + # Properties + ## Class socket_type: ct.SocketType bl_label: str - # Style - display_shape: typ.Literal[ - 'CIRCLE', - 'SQUARE', - 'DIAMOND', - 'CIRCLE_DOT', - 'SQUARE_DOT', - 'DIAMOND_DOT', - ] - ## We use the following conventions for shapes: - ## - CIRCLE: Single Value. - ## - SQUARE: Container of Value. - ## - DIAMOND: Pointer Value. - ## - +DOT: Uses Units - socket_color: tuple - - # Options - use_prelock: bool = False - use_info_draw: bool = False - - # Computed + ## Computed by Subclass bl_idname: str # BLFields - blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({}) - ui_blfields: typ.ClassVar[set[str]] = frozenset() + ## Identifying + is_initializing: bool = bl_cache.BLField(True, use_prop_update=False) + + active_kind: ct.FlowKind = bl_cache.BLField(ct.FlowKind.Value) + + ## UI + use_info_draw: bool = bl_cache.BLField(False, use_prop_update=False) + use_prelock: bool = bl_cache.BLField(False, use_prop_update=False) + + locked: bool = bl_cache.BLField(False, use_prop_update=False) + + use_socket_color: bool = bl_cache.BLField(False, use_prop_update=False) + socket_color: tuple[float, float, float, float] = bl_cache.BLField( + (0, 0, 0, 0), use_prop_update=False + ) #################### # - Initialization #################### - ## TODO: Common implementation of this for both sockets and nodes - perhaps a BLInstance base class? - def reset_instance_id(self) -> None: - self.instance_id = str(uuid.uuid4()) - - @classmethod - def declare_blfield( - cls, attr_name: str, bl_attr_name: str, prop_ui: bool = False - ) -> None: - cls.blfields = cls.blfields | {attr_name: bl_attr_name} - - if prop_ui: - cls.ui_blfields = cls.ui_blfields | {attr_name} - - @classmethod - def set_prop( - cls, - prop_name: str, - prop: bpy.types.Property, - no_update: bool = False, - update_with_name: str | None = None, - **kwargs, - ) -> None: - """Adds a Blender property to a class via `__annotations__`, so it initializes with any subclass. + def __init_subclass__(cls, **kwargs: typ.Any): + """Initializes socket properties and attributes for use. Notes: - - Blender properties can't be set within `__init_subclass__` simply by adding attributes to the class; they must be added as type annotations. - - Must be called **within** `__init_subclass__`. - - Parameters: - name: The name of the property to set. - prop: The `bpy.types.Property` to instantiate and attach.. - no_update: Don't attach a `self.on_prop_changed()` callback to the property's `update`. + Run when initializing any subclass of MaxwellSimSocket. """ - _update_with_name = prop_name if update_with_name is None else update_with_name - extra_kwargs = ( - { - 'update': lambda self, context: self.on_prop_changed( - _update_with_name, context - ), - } - if not no_update - else {} - ) - cls.__annotations__[prop_name] = prop( - **kwargs, - **extra_kwargs, - ) - - def __init_subclass__(cls, **kwargs: typ.Any): log.debug('Initializing Socket: %s', cls.socket_type) super().__init_subclass__(**kwargs) - # cls._assert_attrs_valid() - ## TODO: Implement this :) + cls.assert_attrs_valid(MANDATORY_PROPS) - # Socket Properties - ## Identifiers cls.bl_idname: str = str(cls.socket_type.value) - cls.set_prop('instance_id', bpy.props.StringProperty, no_update=True) - cls.set_prop( - 'initializing', bpy.props.BoolProperty, default=True, no_update=True - ) - - ## Special States - cls.set_prop('locked', bpy.props.BoolProperty, no_update=True, default=False) - - # Setup Style - cls.socket_color = ct.SOCKET_COLORS[cls.socket_type] - - # Setup List - cls.set_prop( - 'active_kind', bpy.props.StringProperty, default=str(ct.FlowKind.Value) - ) #################### # - Property Event: On Update @@ -244,12 +178,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket): Notes: Called by `self.on_prop_changed()` when `self.active_kind` was changed. """ - self.display_shape = { - ct.FlowKind.Value: 'CIRCLE', - ct.FlowKind.Array: 'SQUARE', - ct.FlowKind.LazyArrayRange: 'SQUARE', - ct.FlowKind.LazyValueFunc: 'DIAMOND', - }[self.active_kind] + self.display_shape = self.active_kind.socket_shape def on_socket_prop_changed(self, prop_name: str) -> None: """Called when a property has been updated. @@ -264,7 +193,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket): prop_name: The name of the property that was changed. """ - def on_prop_changed(self, prop_name: str, _: bpy.types.Context) -> None: + def on_prop_changed(self, prop_name: str) -> None: """Called when a property has been updated. Contrary to `node.on_prop_changed()`, socket-specific callbacks are baked into this function: @@ -275,17 +204,13 @@ class MaxwellSimSocket(bpy.types.NodeSocket): prop_name: The name of the property that was changed. """ ## TODO: Evaluate this properly - if self.initializing: + if self.is_initializing: log.debug( '%s: Rejected on_prop_changed("%s") while initializing', self.bl_label, prop_name, ) elif hasattr(self, prop_name): - # Invalidate UI BLField Caches - if prop_name in self.ui_blfields: - setattr(self, prop_name, bl_cache.Signal.InvalidateCache) - # Property Callbacks: Active Kind if prop_name == 'active_kind': self.on_active_kind_changed() @@ -469,34 +394,32 @@ class MaxwellSimSocket(bpy.types.NodeSocket): if event in [ct.FlowEvent.EnableLock, ct.FlowEvent.DisableLock]: self.locked = event == ct.FlowEvent.EnableLock - # Input Socket | Input Flow - if not self.is_output and flow_direction == 'input': - for link in self.links: - link.from_socket.trigger_event(event, socket_kinds=socket_kinds) + # Event by Socket Orientation | Flow Direction + match (self.is_output, flow_direction): + case (False, 'input'): + for link in self.links: + link.from_socket.trigger_event(event, socket_kinds=socket_kinds) - # Input Socket | Output Flow - if not self.is_output and flow_direction == 'output': - if event == ct.FlowEvent.LinkChanged: - self.node.trigger_event( - ct.FlowEvent.DataChanged, - socket_name=self.name, - socket_kinds=socket_kinds, - ) - else: + case (False, 'output'): + if event == ct.FlowEvent.LinkChanged: + self.node.trigger_event( + ct.FlowEvent.DataChanged, + socket_name=self.name, + socket_kinds=socket_kinds, + ) + else: + self.node.trigger_event( + event, socket_name=self.name, socket_kinds=socket_kinds + ) + + case (True, 'input'): self.node.trigger_event( event, socket_name=self.name, socket_kinds=socket_kinds ) - # Output Socket | Input Flow - if self.is_output and flow_direction == 'input': - self.node.trigger_event( - event, socket_name=self.name, socket_kinds=socket_kinds - ) - - # Output Socket | Output Flow - if self.is_output and flow_direction == 'output': - for link in self.links: - link.to_socket.trigger_event(event, socket_kinds=socket_kinds) + case (True, 'output'): + for link in self.links: + link.to_socket.trigger_event(event, socket_kinds=socket_kinds) #################### # - FlowKind: Auxiliary @@ -729,19 +652,24 @@ class MaxwellSimSocket(bpy.types.NodeSocket): raise NotImplementedError(msg) #################### - # - Theme + # - UI - Color #################### - @classmethod - def draw_color_simple(cls) -> ct.BLColorRGBA: - """Sets the socket's color to `cls.socket_color`. + def draw_color( + self, + _: bpy.types.Context, + node: bpy.types.Node, # noqa: ARG002 + ) -> tuple[float, float, float, float]: + """Draw the socket color depending on context. + + When `self.use_socket_color` is set, the property `socket_color` can be used to control the socket color directly. + Otherwise, a default based on `self.socket_type` will be used. Notes: - Blender calls this method to determine the socket color. - - Returns: - A Blender-compatible RGBA value, with no explicit color space. + Called by Blender to call the socket color. """ - return cls.socket_color + if self.use_socket_color: + return self.socket_color + return ct.SOCKET_COLORS[self.socket_type] #################### # - UI diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/file_path.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/file_path.py index 66a1355..a9738b0 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/file_path.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/file_path.py @@ -18,9 +18,13 @@ from pathlib import Path import bpy +from blender_maxwell.utils import bl_cache, logger + from ... import contracts as ct from .. import base +log = logger.get(__name__) + #################### # - Blender Socket @@ -32,30 +36,25 @@ class FilePathBLSocket(base.MaxwellSimSocket): #################### # - Properties #################### - raw_value: bpy.props.StringProperty( - name='File Path', - description='Represents the path to a file', - subtype='FILE_PATH', - update=(lambda self, context: self.on_prop_changed('raw_value', context)), - ) + raw_value: Path = bl_cache.BLField(Path(), path_type='file') #################### # - Socket UI #################### def draw_value(self, col: bpy.types.UILayout) -> None: - col_row = col.row(align=True) - col_row.prop(self, 'raw_value', text='') + # col_row = col.row(align=True) + col.prop(self, self.blfields['raw_value'], text='') #################### - # - Computation of Default Value + # - FlowKind: Value #################### @property def value(self) -> Path: - return Path(bpy.path.abspath(self.raw_value)) + return self.raw_value @value.setter def value(self, value: Path) -> None: - self.raw_value = bpy.path.relpath(str(value)) + self.raw_value = value #################### diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/expr.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/expr.py index 54d945a..0b3644c 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/expr.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/expr.py @@ -14,12 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +"""Implements the `ExprSocket` node socket.""" + import enum import typing as typ import bpy import pydantic as pyd import sympy as sp +import sympy.physics.units as spu from blender_maxwell.utils import bl_cache, logger from blender_maxwell.utils import extra_sympy_units as spux @@ -41,10 +44,18 @@ Float32: typ.TypeAlias = tuple[ ] -def unicode_superscript(n): +#################### +# - Utilitives +#################### +def unicode_superscript(n: int) -> str: + """Transform an integer into its unicode-based superscript character.""" return ''.join(['⁰¹²³⁴⁵⁶⁷⁸⁹'[ord(c) - ord('0')] for c in str(n)]) +def _check_sym_oo(sym): + return sym.is_real or sym.is_rational or sym.is_integer + + class InfoDisplayCol(enum.StrEnum): """Valid columns for specifying displayed information from an `ct.InfoFlow`.""" @@ -71,114 +82,125 @@ class InfoDisplayCol(enum.StrEnum): }[value] +#################### +# - Socket +#################### class ExprBLSocket(base.MaxwellSimSocket): """The `Expr` ("Expression") socket is an accessible approach to specifying any expression. - - **Shape**: There is an intuitive UI for scalar, 2D, and 3D, but the `Expr` socket also supports parsing mathematical expressions of any shape (including matrices). - - **Math Type**: Support integer, rational, real, and complex mathematical types, for which there is an intuitive UI for scalar, 2D, and 3D cases. - - **Physical Type**: Supports the declaration of a physical unit dimension, for which a UI exists for the user to switch between long lists of valid units for that dimension, with automatic conversion of the value. This causes the expression to become unit-aware, which will be respected when using it for math. - - **Symbols**: Supports the use of variables (each w/predefined `MathType`) to define arbitrary mathematical expressions, which can be used as part of a function composition chain and/or as a parameter realized at `Viz` / when generating batched simulations / when performing gradient-based optimization. - - **Information UX**: All information encoded by the expression is presented using an intuitive UI, including filterable access to the shape of any data passing through a linked socket. + Attributes: + size: The dimensionality of the expression. + The socket can exposes a UI for scalar, 2D, and 3D. + Otherwise, a string-based `sympy` expression is the fallback. + mathtype: The mathematical identity of the expression. + Encompasses the usual suspects ex. integer, rational, real, complex, etc. . + Generally, there is a UI available for all of these. + The enum itself can be dynamically altered, ex. via its UI dropdown support. + physical_type: The physical identity of the expression. + The default indicator of a unitless (aka. non-physical) expression is `spux.PhysicalType.NonPhysical`. + When active, `self.active_unit` can be used via the UI to select valid unit of the given `self.physical_type`, and `self.unit` works. + The enum itself can be dynamically altered, ex. via its UI dropdown support. + symbols: The symbolic variables valid in the context of the expression. + Various features, including `LazyValueFunc` support, become available when symbols are in use. + The presence of symbols forces fallback to a string-based `sympy` expression UI. + + active_unit: The currently active unit, as a dropdown. + Its values are always the valid units of the currently active `physical_type`. """ socket_type = ct.SocketType.Expr bl_label = 'Expr' - use_info_draw = True #################### # - Properties #################### - shape: tuple[int, ...] | None = bl_cache.BLField(None) - mathtype: spux.MathType = bl_cache.BLField(spux.MathType.Real, prop_ui=True) - physical_type: spux.PhysicalType | None = bl_cache.BLField(None) + size: spux.NumberSize1D = bl_cache.BLField(spux.NumberSize1D.Scalar) + mathtype: spux.MathType = bl_cache.BLField(spux.MathType.Real) + physical_type: spux.PhysicalType = bl_cache.BLField(spux.PhysicalType.NonPhysical) symbols: frozenset[sp.Symbol] = bl_cache.BLField(frozenset()) - active_unit: enum.Enum = bl_cache.BLField( - None, enum_cb=lambda self, _: self.search_units(), prop_ui=True - ) - - # UI: Value - ## Expression - raw_value_spstr: str = bl_cache.BLField('', prop_ui=True) - ## 1D - raw_value_int: int = bl_cache.BLField(0, prop_ui=True) - raw_value_rat: Int2 = bl_cache.BLField((0, 1), prop_ui=True) - raw_value_float: float = bl_cache.BLField(0.0, float_prec=4, prop_ui=True) - raw_value_complex: Float2 = bl_cache.BLField((0.0, 0.0), float_prec=4, prop_ui=True) - ## 2D - raw_value_int2: Int2 = bl_cache.BLField((0, 0), prop_ui=True) - raw_value_rat2: Int22 = bl_cache.BLField(((0, 1), (0, 1)), prop_ui=True) - raw_value_float2: Float2 = bl_cache.BLField((0.0, 0.0), float_prec=4, prop_ui=True) - raw_value_complex2: Float22 = bl_cache.BLField( - ((0.0, 0.0), (0.0, 0.0)), float_prec=4, prop_ui=True - ) - ## 3D - raw_value_int3: Int3 = bl_cache.BLField((0, 0, 0), prop_ui=True) - raw_value_rat3: Int32 = bl_cache.BLField(((0, 1), (0, 1), (0, 1)), prop_ui=True) - raw_value_float3: Float3 = bl_cache.BLField( - (0.0, 0.0, 0.0), float_prec=4, prop_ui=True - ) - raw_value_complex3: Float32 = bl_cache.BLField( - ((0.0, 0.0), (0.0, 0.0), (0.0, 0.0)), float_prec=4, prop_ui=True - ) - - # UI: LazyArrayRange - steps: int = bl_cache.BLField(2, abs_min=2, prop_ui=True) - ## Expression - raw_min_spstr: str = bl_cache.BLField('', prop_ui=True) - raw_max_spstr: str = bl_cache.BLField('', prop_ui=True) - ## By MathType - raw_range_int: Int2 = bl_cache.BLField((0, 1), prop_ui=True) - raw_range_rat: Int22 = bl_cache.BLField(((0, 1), (1, 1)), prop_ui=True) - raw_range_float: Float2 = bl_cache.BLField((0.0, 1.0), prop_ui=True) - raw_range_complex: Float22 = bl_cache.BLField( - ((0.0, 0.0), (1.0, 1.0)), float_prec=4, prop_ui=True - ) - - # UI: Info - show_info_columns: bool = bl_cache.BLField(False, prop_ui=True) - info_columns: InfoDisplayCol = bl_cache.BLField( - {InfoDisplayCol.MathType, InfoDisplayCol.Unit}, - prop_ui=True, - enum_many=True, - ) - - #################### - # - Computed: Raw Expressions - #################### - @property + @bl_cache.cached_bl_property(depends_on={'symbols'}) def sorted_symbols(self) -> list[sp.Symbol]: - """Retrieves all symbols and sorts them by name. - - Returns: - Repeateably ordered list of symbols. - """ + """Name-sorted symbols.""" return sorted(self.symbols, key=lambda sym: sym.name) - @property - def raw_value_sp(self) -> spux.SympyExpr: - return self._parse_expr_str(self.raw_value_spstr) + active_unit: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.search_valid_units(), + use_prop_update=False, + cb_depends_on={'physical_type'}, + ) - @property - def raw_min_sp(self) -> spux.SympyExpr: - return self._parse_expr_str(self.raw_min_spstr) - - @property - def raw_max_sp(self) -> spux.SympyExpr: - return self._parse_expr_str(self.raw_max_spstr) - - #################### - # - Computed: Units - #################### - def search_units(self) -> list[ct.BLEnumElement]: - if self.physical_type is not None: + def search_valid_units(self) -> list[ct.BLEnumElement]: + """Compute Blender enum elements of valid units for the current `physical_type`.""" + if self.physical_type is not spux.PhysicalType.NonPhysical: return [ (sp.sstr(unit), spux.sp_to_str(unit), sp.sstr(unit), '', i) for i, unit in enumerate(self.physical_type.valid_units) ] return [] - @bl_cache.cached_bl_property() + # UI: Value + ## Expression + raw_value_spstr: str = bl_cache.BLField('0.0') + ## 1D + raw_value_int: int = bl_cache.BLField(0) + raw_value_rat: Int2 = bl_cache.BLField((0, 1)) + raw_value_float: float = bl_cache.BLField(0.0, float_prec=4) + raw_value_complex: Float2 = bl_cache.BLField((0.0, 0.0)) + ## 2D + raw_value_int2: Int2 = bl_cache.BLField((0, 0)) + raw_value_rat2: Int22 = bl_cache.BLField(((0, 1), (0, 1))) + raw_value_float2: Float2 = bl_cache.BLField((0.0, 0.0), float_prec=4) + raw_value_complex2: Float22 = bl_cache.BLField( + ((0.0, 0.0), (0.0, 0.0)), float_prec=4 + ) + ## 3D + raw_value_int3: Int3 = bl_cache.BLField((0, 0, 0)) + raw_value_rat3: Int32 = bl_cache.BLField(((0, 1), (0, 1), (0, 1))) + raw_value_float3: Float3 = bl_cache.BLField((0.0, 0.0, 0.0), float_prec=4) + raw_value_complex3: Float32 = bl_cache.BLField( + ((0.0, 0.0), (0.0, 0.0), (0.0, 0.0)), float_prec=4 + ) + + # UI: LazyArrayRange + steps: int = bl_cache.BLField(2, soft_min=2, abs_min=0) + scaling: ct.ScalingMode = bl_cache.BLField(ct.ScalingMode.Lin) + ## Expression + raw_min_spstr: str = bl_cache.BLField('0.0') + raw_max_spstr: str = bl_cache.BLField('1.0') + ## By MathType + raw_range_int: Int2 = bl_cache.BLField((0, 1)) + raw_range_rat: Int22 = bl_cache.BLField(((0, 1), (1, 1))) + raw_range_float: Float2 = bl_cache.BLField((0.0, 1.0)) + raw_range_complex: Float22 = bl_cache.BLField( + ((0.0, 0.0), (1.0, 1.0)), float_prec=4 + ) + + # UI: Info + show_info_columns: bool = bl_cache.BLField(False) + info_columns: set[InfoDisplayCol] = bl_cache.BLField( + {InfoDisplayCol.MathType, InfoDisplayCol.Unit} + ) + + #################### + # - Computed String Expressions + #################### + @bl_cache.cached_bl_property(depends_on={'raw_value_spstr'}) + def raw_value_sp(self) -> spux.SympyExpr: + return self._parse_expr_str(self.raw_value_spstr) + + @bl_cache.cached_bl_property(depends_on={'raw_min_spstr'}) + def raw_min_sp(self) -> spux.SympyExpr: + return self._parse_expr_str(self.raw_min_spstr) + + @bl_cache.cached_bl_property(depends_on={'raw_max_spstr'}) + def raw_max_sp(self) -> spux.SympyExpr: + return self._parse_expr_str(self.raw_max_spstr) + + #################### + # - Computed Unit + #################### + @bl_cache.cached_bl_property(depends_on={'active_unit'}) def unit(self) -> spux.Unit | None: """Gets the current active unit. @@ -192,56 +214,47 @@ class ExprBLSocket(base.MaxwellSimSocket): return None - @unit.setter - def unit(self, unit: spux.Unit | None) -> None: - """Set the unit, without touching the `raw_*` UI properties. - - Notes: - To set a new unit, **and** convert the `raw_*` UI properties to the new unit, use `self.convert_unit()` instead. - """ - if self.physical_type is not None: - if unit in self.physical_type.valid_units: - self.active_unit = sp.sstr(unit) - else: - msg = f'Tried to set invalid unit {unit} (physical type "{self.physical_type}" only supports "{self.physical_type.valid_units}")' - raise ValueError(msg) - elif unit is not None: - msg = f'Tried to set invalid unit {unit} (physical type is {self.physical_type}, and has no unit support!)")' - raise ValueError(msg) - - def convert_unit(self, unit_to: spux.Unit) -> None: - current_value = self.value - current_lazy_array_range = self.lazy_array_range - - # Old Unit Not in Physical Type - ## -> This happens when dynamically altering self.physical_type - if self.unit in self.physical_type.valid_units: - self.unit = bl_cache.Signal.InvalidateCache - - self.value = current_value - self.lazy_array_range = current_lazy_array_range - else: - self.unit = bl_cache.Signal.InvalidateCache - - # Workaround: Manually Jiggle FlowKind Invalidation - self.value = self.value - self.lazy_array_range = self.lazy_array_range + @bl_cache.cached_bl_property() + def prev_unit(self) -> spux.Unit | None: + return self.unit #################### - # - Property Callback + # - Prop-Change Callback #################### def on_socket_prop_changed(self, prop_name: str) -> None: - if prop_name == 'physical_type': - self.active_unit = bl_cache.Signal.ResetEnumItems - if prop_name == 'active_unit' and self.active_unit is not None: - self.convert_unit(spux.unit_str_to_unit(self.active_unit)) + # Conditional Unit-Conversion + ## -> This is niche functionality, but the only way to convert units. + ## -> We can only catch 'unit' since it's at the end of a depschain. + if prop_name == 'unit': + # Check Unit Change + ## -> self.prev_unit only updates here; "lags" behind self.unit. + ## -> 1. "Laggy" unit must be different than new unit. + ## -> 2. Unit-conversion of value only within same physical_type + ## -> 3. Never unit-convert expressions w/symbolic variables + ## No matter what, prev_unit is always re-armed. + if ( + self.prev_unit != self.unit + and self.prev_unit in self.physical_type.valid_units + and not self.symbols + ): + log.critical(self.value, self.prev_unit, self.unit) + self.value = spu.convert_to(self.value, self.prev_unit) + log.critical(self.value, self.prev_unit, self.unit) + self.lazy_array_range = self.lazy_array_range.rescale_to_unit( + self.prev_unit + ) + self.prev_unit = bl_cache.Signal.InvalidateCache #################### - # - Methods + # - Value Utilities #################### def _parse_expr_info( self, expr: spux.SympyExpr ) -> tuple[spux.MathType, tuple[int, ...] | None, spux.UnitDimension]: + """Parse a given expression for mathtype and size information. + + Various compatibility checks are also performed, allowing this method to serve as a generic runtime validator/parser for any expressions that need to enter the socket. + """ # Parse MathType mathtype = spux.MathType.from_expr(expr) if not self.mathtype.is_compatible(mathtype): @@ -255,18 +268,19 @@ class ExprBLSocket(base.MaxwellSimSocket): # Parse Dimensions shape = spux.parse_shape(expr) - if shape != self.shape and not ( - shape is not None - and self.shape is not None - and len(self.shape) == 1 - and 1 in shape - ): - msg = f'Expr {expr} has shape {shape}, which is incompatible with the expr socket (shape {self.shape})' + if not self.size.supports_shape(shape): + msg = f'Expr {expr} has non-1D shape {shape}, which is incompatible with the expr socket (shape {self.shape})' raise ValueError(msg) - return mathtype, shape + size = spux.NumberSize1D.from_shape(shape) + if self.size != size: + msg = f'Expr {expr} has 1D size {size}, which is incompatible with the expr socket (size {self.size})' + raise ValueError(msg) + + return mathtype, size def _to_raw_value(self, expr: spux.SympyExpr, force_complex: bool = False): + """Cast the given expression to the appropriate raw value, with scaling guided by `self.unit`.""" if self.unit is not None: pyvalue = spux.sympy_to_python(spux.scale_to_unit(expr, self.unit)) else: @@ -286,13 +300,20 @@ class ExprBLSocket(base.MaxwellSimSocket): return pyvalue - def _parse_expr_str(self, expr_spstr: str) -> None: + def _parse_expr_str(self, expr_spstr: str) -> spux.SympyExpr | None: + """Parse an expression string by choosing opinionated options for `sp.sympify`. + + Uses `self._parse_expr_info()` to validate the parsed result. + + Returns: + The parsed expression, if it manages to validate; else None. + """ expr = sp.sympify( expr_spstr, locals={sym.name: sym for sym in self.symbols}, strict=False, convert_xor=True, - ).subs(spux.UNIT_BY_SYMBOL) * (self.unit if self.unit is not None else 1) + ).subs(spux.UNIT_BY_SYMBOL) # Try Parsing and Returning the Expression try: @@ -312,7 +333,7 @@ class ExprBLSocket(base.MaxwellSimSocket): #################### @property def value(self) -> spux.SympyExpr: - """Return the expression defined by the socket. + """Return the expression defined by the socket as `FlowKind.Value`. - **Num Dims**: Determine which property dimensionality to pull data from. - **MathType**: Determine which property type to pull data from. @@ -324,12 +345,21 @@ class ExprBLSocket(base.MaxwellSimSocket): Return: The expression defined by the socket, in the socket's unit. + + When the string expression `self.raw_value_spstr` fails to parse,the property returns `FlowPending`. """ - if self.symbols or self.shape not in [None, (2,), (3,)]: + if self.symbols: expr = self.raw_value_sp if expr is None: return ct.FlowSignal.FlowPending - return expr + return expr * (self.unit if self.unit is not None else 1) + + # Vec4 -> FlowPending + ## -> ExprSocket doesn't support Vec4 (yet?). + ## -> I mean, have you _seen_ that mess of attributes up top? + NS = spux.NumberSize1D + if self.size == NS.Vec4: + return ct.Flow MT_Z = spux.MathType.Integer MT_Q = spux.MathType.Rational @@ -339,7 +369,7 @@ class ExprBLSocket(base.MaxwellSimSocket): Q = sp.Rational R = sp.RealNumber return { - None: { + NS.Scalar: { MT_Z: lambda: Z(self.raw_value_int), MT_Q: lambda: Q(self.raw_value_rat[0], self.raw_value_rat[1]), MT_R: lambda: R(self.raw_value_float), @@ -347,7 +377,7 @@ class ExprBLSocket(base.MaxwellSimSocket): self.raw_value_complex[0] + sp.I * self.raw_value_complex[1] ), }, - (2,): { + NS.Vec2: { MT_Z: lambda: sp.Matrix([Z(i) for i in self.raw_value_int2]), MT_Q: lambda: sp.Matrix([Q(q[0], q[1]) for q in self.raw_value_rat2]), MT_R: lambda: sp.Matrix([R(r) for r in self.raw_value_float2]), @@ -355,7 +385,7 @@ class ExprBLSocket(base.MaxwellSimSocket): [c[0] + sp.I * c[1] for c in self.raw_value_complex2] ), }, - (3,): { + NS.Vec3: { MT_Z: lambda: sp.Matrix([Z(i) for i in self.raw_value_int3]), MT_Q: lambda: sp.Matrix([Q(q[0], q[1]) for q in self.raw_value_rat3]), MT_R: lambda: sp.Matrix([R(r) for r in self.raw_value_float3]), @@ -363,54 +393,51 @@ class ExprBLSocket(base.MaxwellSimSocket): [c[0] + sp.I * c[1] for c in self.raw_value_complex3] ), }, - }[self.shape][self.mathtype]() * (self.unit if self.unit is not None else 1) + }[self.size][self.mathtype]() * (self.unit if self.unit is not None else 1) @value.setter def value(self, expr: spux.SympyExpr) -> None: - """Set the expression defined by the socket. + """Set the expression defined by the socket to a compatible `expr`. Notes: Called to set the internal `FlowKind.Value` of this socket. """ - _mathtype, _shape = self._parse_expr_info(expr) - if self.symbols or self.shape not in [None, (2,), (3,)]: + _mathtype, _size = self._parse_expr_info(expr) + if self.symbols: self.raw_value_spstr = sp.sstr(expr) - else: - MT_Z = spux.MathType.Integer - MT_Q = spux.MathType.Rational - MT_R = spux.MathType.Real - MT_C = spux.MathType.Complex - if self.shape is None: - if self.mathtype == MT_Z: + NS = spux.NumberSize1D + MT = spux.MathType + match (self.size, self.mathtype): + case (NS.Scalar, MT.Integer): self.raw_value_int = self._to_raw_value(expr) - elif self.mathtype == MT_Q: + case (NS.Scalar, MT.Rational): self.raw_value_rat = self._to_raw_value(expr) - elif self.mathtype == MT_R: + case (NS.Scalar, MT.Real): self.raw_value_float = self._to_raw_value(expr) - elif self.mathtype == MT_C: + case (NS.Scalar, MT.Complex): self.raw_value_complex = self._to_raw_value( expr, force_complex=True ) - elif self.shape == (2,): - if self.mathtype == MT_Z: + + case (NS.Vec2, MT.Integer): self.raw_value_int2 = self._to_raw_value(expr) - elif self.mathtype == MT_Q: + case (NS.Vec2, MT.Rational): self.raw_value_rat2 = self._to_raw_value(expr) - elif self.mathtype == MT_R: + case (NS.Vec2, MT.Real): self.raw_value_float2 = self._to_raw_value(expr) - elif self.mathtype == MT_C: + case (NS.Vec2, MT.Complex): self.raw_value_complex2 = self._to_raw_value( expr, force_complex=True ) - elif self.shape == (3,): - if self.mathtype == MT_Z: + + case (NS.Vec3, MT.Integer): self.raw_value_int3 = self._to_raw_value(expr) - elif self.mathtype == MT_Q: + case (NS.Vec3, MT.Rational): self.raw_value_rat3 = self._to_raw_value(expr) - elif self.mathtype == MT_R: + case (NS.Vec3, MT.Real): self.raw_value_float3 = self._to_raw_value(expr) - elif self.mathtype == MT_C: + case (NS.Vec3, MT.Complex): self.raw_value_complex3 = self._to_raw_value( expr, force_complex=True ) @@ -433,7 +460,7 @@ class ExprBLSocket(base.MaxwellSimSocket): start=self.raw_min_sp, stop=self.raw_max_sp, steps=self.steps, - scaling='lin', + scaling=self.scaling, unit=self.unit, symbols=self.symbols, ) @@ -445,6 +472,7 @@ class ExprBLSocket(base.MaxwellSimSocket): Z = sp.Integer Q = sp.Rational R = sp.RealNumber + min_bound, max_bound = { MT_Z: lambda: [Z(bound) for bound in self.raw_range_int], MT_Q: lambda: [Q(bound[0], bound[1]) for bound in self.raw_range_rat], @@ -458,7 +486,7 @@ class ExprBLSocket(base.MaxwellSimSocket): start=min_bound, stop=max_bound, steps=self.steps, - scaling='lin', + scaling=self.scaling, unit=self.unit, ) @@ -470,6 +498,7 @@ class ExprBLSocket(base.MaxwellSimSocket): Called to compute the internal `FlowKind.LazyArrayRange` of this socket. """ self.steps = value.steps + self.scaling = value.scaling if self.symbols: self.raw_min_spstr = sp.sstr(value.start) @@ -482,22 +511,22 @@ class ExprBLSocket(base.MaxwellSimSocket): MT_C = spux.MathType.Complex unit = value.unit if value.unit is not None else 1 - if value.mathtype == MT_Z: + if self.mathtype == MT_Z: self.raw_range_int = [ self._to_raw_value(bound * unit) for bound in [value.start, value.stop] ] - elif value.mathtype == MT_Q: + elif self.mathtype == MT_Q: self.raw_range_rat = [ self._to_raw_value(bound * unit) for bound in [value.start, value.stop] ] - elif value.mathtype == MT_R: + elif self.mathtype == MT_R: self.raw_range_float = [ self._to_raw_value(bound * unit) for bound in [value.start, value.stop] ] - elif value.mathtype == MT_C: + elif self.mathtype == MT_C: self.raw_range_complex = [ self._to_raw_value(bound * unit, force_complex=True) for bound in [value.start, value.stop] @@ -508,18 +537,30 @@ class ExprBLSocket(base.MaxwellSimSocket): #################### @property def lazy_value_func(self) -> ct.LazyValueFuncFlow: - # Lazy Value: Arbitrary Expression - if self.symbols or self.shape not in [None, (2,), (3,)]: + """Returns a lazy value that computes the expression returned by `self.value`. + + If `self.value` has unknown symbols (as indicated by `self.symbols`), then these will be the arguments of the `LazyValueFuncFlow`. + Otherwise, the returned lazy value function will be a simple excuse for `self.params` to pass the verbatim `self.value`. + """ + # Symbolic + ## -> `self.value` is guaranteed to be an expression with unknowns. + ## -> The function computes `self.value` with unknowns as arguments. + if self.symbols: return ct.LazyValueFuncFlow( - func=sp.lambdify(self.sorted_symbols, self.value, 'jax'), + func=sp.lambdify( + self.sorted_symbols, + spux.scale_to_unit(self.value, self.unit), + 'jax', + ), func_args=[spux.MathType.from_expr(sym) for sym in self.sorted_symbols], supports_jax=True, ) - # Lazy Value: Constant - ## -> A very simple function, which takes a single argument. - ## -> What will be passed is a unit-scaled/stripped, pytype-converted Expr:Value. - ## -> Until then, the user can utilize this LVF in a function composition chain. + # Constant + ## -> When a `self.value` has no unknowns, use a dummy function. + ## -> ("Dummy" as in returns the same argument that it takes). + ## -> This is an excuse to let `ParamsFlow` pass `self.value` verbatim. + ## -> Generally only useful for operations with other expressions. return ct.LazyValueFuncFlow( func=lambda v: v, func_args=[ @@ -530,38 +571,65 @@ class ExprBLSocket(base.MaxwellSimSocket): @property def params(self) -> ct.ParamsFlow: - # Params Value: Symbolic + """Returns parameter symbols/values to accompany `self.lazy_value_func`. + + If `self.value` has unknown symbols (as indicated by `self.symbols`), then these will be passed into `ParamsFlow`, which will thus be parameterized (and require realization before use). + Otherwise, `self.value` is passed verbatim as the only `ParamsFlow.func_arg`. + """ + # Symbolic ## -> The Expr socket does not declare actual values for the symbols. - ## -> Those values must come from elsewhere. - ## -> If someone tries to load them anyway, tell them 'NoFlow'. - if self.symbols or self.shape not in [None, (2,), (3,)]: - return ct.FlowSignal.NoFlow - - # Params Value: Constant - ## -> Simply pass the Expr:Value as parameter. - return ct.ParamsFlow(func_args=[self.value]) - - #################### - # - FlowKind: Array - #################### - @property - def array(self) -> ct.ArrayFlow: - if not self.symbols: - return ct.ArrayFlow( - values=self.lazy_value_func.func_jax(), - unit=self.unit, + ## -> They should be realized later, ex. in a Viz node. + ## -> Therefore, we just dump the symbols. Easy! + ## -> NOTE: func_args must have the same symbol order as was lambdified. + if self.symbols: + return ct.ParamsFlow( + func_args=self.sorted_symbols, + symbols=self.symbols, ) - return ct.FlowSignal.NoFlow + # Constant + ## -> Simply pass self.value verbatim as a function argument. + ## -> Easy dice, easy life! + return ct.ParamsFlow(func_args=[self.value]) - #################### - # - FlowKind: Info - #################### @property def info(self) -> ct.ArrayFlow: + r"""Returns parameter symbols/values to accompany `self.lazy_value_func`. + + The output name/size/mathtype/unit corresponds directly the `ExprSocket`. + + If `self.symbols` has entries, then these will propagate as dimensions with unresolvable `LazyArrayRangeFlow` index descriptions. + The index range will be $(-\infty,\infty)$, with $0$ steps and no unit. + The order/naming matches `self.params` and `self.lazy_value_func`. + + Otherwise, only the output name/size/mathtype/unit corresponding to the socket is passed along. + """ + if self.symbols: + return ct.InfoFlow( + dim_names=[sym.name for sym in self.sorted_symbols], + dim_idx={ + sym.name: ct.LazyArrayRangeFlow( + start=-sp.oo if _check_sym_oo(sym) else -sp.zoo, + stop=sp.oo if _check_sym_oo(sym) else sp.zoo, + steps=0, + unit=None, ## Symbols alone are unitless. + ) + ## TODO: PhysicalTypes for symbols? Or nah? + ## TODO: Can we parse some sp.Interval for explicit domains? + ## -> We investigated sp.Symbol(..., domain=...). + ## -> It's no good. We can't re-extract the interval given to domain. + for sym in self.sorted_symbols + }, + output_name='_', ## Use node:socket name? Or something? Ahh + output_shape=self.size.shape, + output_mathtype=self.mathtype, + output_unit=self.unit, + ) + + # Constant return ct.InfoFlow( - output_name='_', - output_shape=self.shape, + output_name='_', ## Use node:socket name? Or something? Ahh + output_shape=self.size.shape, output_mathtype=self.mathtype, output_unit=self.unit, ) @@ -577,9 +645,11 @@ class ExprBLSocket(base.MaxwellSimSocket): ) #################### - # - UI + # - UI: Label Row #################### def draw_label_row(self, row: bpy.types.UILayout, text) -> None: + """Draw the unlinked input label row, with a unit dropdown (if `self.active_unit`).""" + # Has Unit: Draw Label and Unit Dropdown if self.active_unit is not None: split = row.split(factor=0.6, align=True) @@ -588,83 +658,17 @@ class ExprBLSocket(base.MaxwellSimSocket): _col = split.column(align=True) _col.prop(self, self.blfields['active_unit'], text='') + + # No Unit: Draw Label else: row.label(text=text) - def draw_value(self, col: bpy.types.UILayout) -> None: - if self.symbols: - col.prop(self, self.blfields['raw_value_spstr'], text='') - - else: - MT_Z = spux.MathType.Integer - MT_Q = spux.MathType.Rational - MT_R = spux.MathType.Real - MT_C = spux.MathType.Complex - if self.shape is None: - if self.mathtype == MT_Z: - col.prop(self, self.blfields['raw_value_int'], text='') - elif self.mathtype == MT_Q: - col.prop(self, self.blfields['raw_value_rat'], text='') - elif self.mathtype == MT_R: - col.prop(self, self.blfields['raw_value_float'], text='') - elif self.mathtype == MT_C: - col.prop(self, self.blfields['raw_value_complex'], text='') - elif self.shape == (2,): - if self.mathtype == MT_Z: - col.prop(self, self.blfields['raw_value_int2'], text='') - elif self.mathtype == MT_Q: - col.prop(self, self.blfields['raw_value_rat2'], text='') - elif self.mathtype == MT_R: - col.prop(self, self.blfields['raw_value_float2'], text='') - elif self.mathtype == MT_C: - col.prop(self, self.blfields['raw_value_complex2'], text='') - elif self.shape == (3,): - if self.mathtype == MT_Z: - col.prop(self, self.blfields['raw_value_int3'], text='') - elif self.mathtype == MT_Q: - col.prop(self, self.blfields['raw_value_rat3'], text='') - elif self.mathtype == MT_R: - col.prop(self, self.blfields['raw_value_float3'], text='') - elif self.mathtype == MT_C: - col.prop(self, self.blfields['raw_value_complex3'], text='') - - # Symbol Information - if self.symbols: - box = col.box() - split = box.split(factor=0.3) - - # Left Col - col = split.column() - col.label(text='Let:') - - # Right Col - col = split.column() - col.alignment = 'RIGHT' - for sym in self.symbols: - col.label(text=spux.pretty_symbol(sym)) - - def draw_lazy_array_range(self, col: bpy.types.UILayout) -> None: - if self.symbols: - col.prop(self, self.blfields['raw_min_spstr'], text='') - col.prop(self, self.blfields['raw_max_spstr'], text='') - - else: - MT_Z = spux.MathType.Integer - MT_Q = spux.MathType.Rational - MT_R = spux.MathType.Real - MT_C = spux.MathType.Complex - if self.mathtype == MT_Z: - col.prop(self, self.blfields['raw_range_int'], text='') - elif self.mathtype == MT_Q: - col.prop(self, self.blfields['raw_range_rat'], text='') - elif self.mathtype == MT_R: - col.prop(self, self.blfields['raw_range_float'], text='') - elif self.mathtype == MT_C: - col.prop(self, self.blfields['raw_range_complex'], text='') - - col.prop(self, self.blfields['steps'], text='') - def draw_input_label_row(self, row: bpy.types.UILayout, text) -> None: + """Provide a dropdown for enabling the `InfoFlow` UI in the linked input label row. + + Notes: + Whether information about the expression passing through a linked socket is shown is governed by `self.show_info_columns`. + """ info = self.compute_data(kind=ct.FlowKind.Info) has_dims = not ct.FlowSignal.check(info) and info.dim_names @@ -690,6 +694,13 @@ class ExprBLSocket(base.MaxwellSimSocket): ) def draw_output_label_row(self, row: bpy.types.UILayout, text) -> None: + """Provide a dropdown for enabling the `InfoFlow` UI in the linked output label row. + + Extremely similar to `draw_input_label_row`, except for some tricky right-alignment. + + Notes: + Whether information about the expression passing through a linked socket is shown is governed by `self.show_info_columns`. + """ info = self.compute_data(kind=ct.FlowKind.Info) has_info = not ct.FlowSignal.check(info) @@ -718,6 +729,109 @@ class ExprBLSocket(base.MaxwellSimSocket): _row.label(text=text) + #################### + # - UI: Active FlowKind + #################### + def draw_value(self, col: bpy.types.UILayout) -> None: + """Draw the socket body for a single values/expression. + + Drawn when `self.active_kind == FlowKind.Value`. + """ + if self.symbols: + col.prop(self, self.blfields['raw_value_spstr'], text='') + + else: + NS = spux.NumberSize1D + MT = spux.MathType + match (self.size, self.mathtype): + case (NS.Scalar, MT.Integer): + col.prop(self, self.blfields['raw_value_int'], text='') + case (NS.Scalar, MT.Rational): + col.prop(self, self.blfields['raw_value_rat'], text='') + case (NS.Scalar, MT.Real): + col.prop(self, self.blfields['raw_value_float'], text='') + case (NS.Scalar, MT.Complex): + col.prop(self, self.blfields['raw_value_complex'], text='') + + case (NS.Vec2, MT.Integer): + col.prop(self, self.blfields['raw_value_int2'], text='') + case (NS.Vec2, MT.Rational): + col.prop(self, self.blfields['raw_value_rat2'], text='') + case (NS.Vec2, MT.Real): + col.prop(self, self.blfields['raw_value_float2'], text='') + case (NS.Vec2, MT.Complex): + col.prop(self, self.blfields['raw_value_complex2'], text='') + + case (NS.Vec3, MT.Integer): + col.prop(self, self.blfields['raw_value_int3'], text='') + case (NS.Vec3, MT.Rational): + col.prop(self, self.blfields['raw_value_rat3'], text='') + case (NS.Vec3, MT.Real): + col.prop(self, self.blfields['raw_value_float3'], text='') + case (NS.Vec3, MT.Complex): + col.prop(self, self.blfields['raw_value_complex3'], text='') + + # Symbol Information + if self.symbols: + box = col.box() + split = box.split(factor=0.3) + + # Left Col + col = split.column() + col.label(text='Let:') + + # Right Col + col = split.column() + col.alignment = 'RIGHT' + for sym in self.symbols: + col.label(text=spux.pretty_symbol(sym)) + + def draw_lazy_array_range(self, col: bpy.types.UILayout) -> None: + """Draw the socket body for a simple, uniform range of values between two values/expressions. + + Drawn when `self.active_kind == FlowKind.LazyArrayRange`. + + Notes: + If `self.steps == 0`, then the `LazyArrayRange` is considered to have a to-be-determined number of steps. + As such, `self.steps` won't be exposed in the UI. + """ + if self.symbols: + col.prop(self, self.blfields['raw_min_spstr'], text='') + col.prop(self, self.blfields['raw_max_spstr'], text='') + + else: + MT_Z = spux.MathType.Integer + MT_Q = spux.MathType.Rational + MT_R = spux.MathType.Real + MT_C = spux.MathType.Complex + if self.mathtype == MT_Z: + col.prop(self, self.blfields['raw_range_int'], text='') + elif self.mathtype == MT_Q: + col.prop(self, self.blfields['raw_range_rat'], text='') + elif self.mathtype == MT_R: + col.prop(self, self.blfields['raw_range_float'], text='') + elif self.mathtype == MT_C: + col.prop(self, self.blfields['raw_range_complex'], text='') + + if self.steps != 0: + col.prop(self, self.blfields['steps'], text='') + + def draw_lazy_value_func(self, col: bpy.types.UILayout) -> None: + """Draw the socket body for a value/expression meant for use in a lazy function composition chain. + + Drawn when `self.active_kind == FlowKind.LazyValueFunc`. + """ + col.prop(self, self.blfields['physical_type'], text='') + if not self.symbols: + row = col.row(align=True) + row.prop(self, self.blfields['size'], text='') + row.prop(self, self.blfields['mathtype'], text='') + + self.draw_value(col) + + #################### + # - UI: InfoFlow + #################### def draw_info(self, info: ct.InfoFlow, col: bpy.types.UILayout) -> None: if self.active_kind == ct.FlowKind.Array and self.show_info_columns: row = col.row() @@ -771,50 +885,259 @@ class ExprBLSocket(base.MaxwellSimSocket): class ExprSocketDef(base.SocketDef): socket_type: ct.SocketType = ct.SocketType.Expr active_kind: typ.Literal[ - ct.FlowKind.Value, ct.FlowKind.LazyArrayRange, ct.FlowKind.Array + ct.FlowKind.Value, + ct.FlowKind.LazyArrayRange, + ct.FlowKind.Array, + ct.FlowKind.LazyValueFunc, ] = ct.FlowKind.Value # Socket Interface - shape: tuple[int, ...] | None = None + size: spux.NumberSize1D = spux.NumberSize1D.Scalar mathtype: spux.MathType = spux.MathType.Real - physical_type: spux.PhysicalType | None = None + physical_type: spux.PhysicalType = spux.PhysicalType.NonPhysical + + default_unit: spux.Unit | None = None symbols: frozenset[spux.Symbol] = frozenset() - # Socket Units - default_unit: spux.Unit | None = None - # FlowKind: Value - default_value: spux.SympyExpr = sp.S(0) + default_value: spux.SympyExpr = 0 abs_min: spux.SympyExpr | None = None abs_max: spux.SympyExpr | None = None # FlowKind: LazyArrayRange - default_min: spux.SympyExpr = sp.S(0) - default_max: spux.SympyExpr = sp.S(1) + default_min: spux.SympyExpr = 0 + default_max: spux.SympyExpr = 1 default_steps: int = 2 + default_scaling: ct.ScalingMode = ct.ScalingMode.Lin # UI show_info_columns: bool = False #################### - # - Validators - Coersion + # - Parse Unit and/or Physical Type #################### @pyd.model_validator(mode='after') - def shape_value_coersion(self) -> str: - if self.shape is not None and not isinstance(self.default_value, sp.MatrixBase): - if len(self.shape) == 1: - self.default_value = self.default_value * sp.Matrix.ones( - self.shape[0], 1 - ) - if len(self.shape) == 2: - self.default_value = self.default_value * sp.Matrix.ones(*self.shape) + def parse_default_unit(self) -> typ.Self: + """Guarantees that a valid default unit is defined, with respect to a given `self.physical_type`. + + If no `self.default_unit` is given, then the physical type's builtin default unit is inserted. + """ + if ( + self.physical_type is not spux.PhysicalType.NonPhysical + and self.default_unit is None + ): + self.default_unit = self.physical_type.default_unit return self @pyd.model_validator(mode='after') - def unit_coersion(self) -> str: - if self.physical_type is not None and self.default_unit is None: - self.default_unit = self.physical_type.default_unit + def parse_physical_type_from_unit(self) -> typ.Self: + """Guarantees that a valid physical type is defined based on the unit. + + If no `self.physical_type` is given, but a unit is defined, then `spux.PhysicalType.from_unit()` is used to find an appropriate PhysicalType. + + Raises: + ValueError: If `self.default_unit` has no obvious physical type. + This might happen if `self.default_unit` isn't a unit at all! + """ + if ( + self.physical_type is spux.PhysicalType.NonPhysical + and self.default_unit is not None + ): + physical_type = spux.PhysicalType.from_unit(self.default_unit) + if physical_type is spux.PhysicalType.NonPhysical: + msg = f'ExprSocket: Defined unit {self.default_unit} has no obvious physical type defined for it.' + raise ValueError(msg) + + self.physical_type = physical_type + return self + + @pyd.model_validator(mode='after') + def assert_physical_type_mathtype_compatibility(self) -> typ.Self: + """Guarantees that the physical type is compatible with `self.mathtype`. + + The `self.physical_type.valid_mathtypes` method is used to perform this check. + + Raises: + ValueError: If `self.default_unit` has no obvious physical type. + This might happen if `self.default_unit` isn't a unit at all! + """ + # Check MathType-PhysicalType Compatibility + ## -> NOTE: NonPhysical has a valid_mathtypes list. + if self.mathtype not in self.physical_type.valid_mathtypes: + msg = f'ExprSocket: Defined unit {self.default_unit} has no obvious physical type defined for it.' + raise ValueError(msg) + + return self + + @pyd.model_validator(mode='after') + def assert_unit_is_valid_in_physical_type(self) -> str: + """Guarantees that the given unit is a valid unit within the given `spux.PhysicalType`. + + This is implemented by checking `self.physical_type.valid_units`. + + Raises: + ValueError: If `self.default_unit` has no obvious physical type. + This might happen if `self.default_unit` isn't a unit at all! + """ + if ( + self.default_unit is not None + and self.default_unit not in self.physical_type.valid_units + ): + msg = f'ExprSocket: Defined unit {self.default_unit} is not a valid unit of {self.physical_type} (valid units = {self.physical_type.valid_units})' + raise ValueError(msg) + + return self + + #################### + # - Parse FlowKind.Value + #################### + @pyd.model_validator(mode='after') + def parse_default_value_size(self) -> typ.Self: + """Guarantees that the default value is correctly shaped. + + If a single number for `self.default_value` is given, then it will be broadcast into the given `self.size.shape`. + + Raises: + ValueError: If `self.default_value` is shaped, but with a shape not identical to `self.size`. + """ + # Default Value is sp.Matrix + ## -> Let the user take responsibility for shape + if isinstance(self.default_value, sp.MatrixBase): + if self.size.supports_shape(self.default_value.shape): + return self + + msg = f"ExprSocket: Default value {self.default_value} is shaped, but its shape {self.default_value.shape} doesn't match the shape of the ExprSocket {self.size.shape}" + raise ValueError(msg) + + if self.size.shape is not None: + # Coerce Number -> Column 0-Vector + ## -> TODO: We don't strictly know if default_value is a number. + if len(self.size.shape) == 1: + self.default_value = self.default_value * sp.Matrix.ones( + self.size.shape[0], 1 + ) + + # Coerce Number -> 0-Matrix + ## -> TODO: We don't strictly know if default_value is a number. + if len(self.size.shape) > 1: + self.default_value = self.default_value * sp.Matrix.ones( + *self.size.shape + ) + + return self + + @pyd.model_validator(mode='after') + def parse_default_value_number(self) -> typ.Self: + """Guarantees that the default value is a sympy expression w/valid (possibly pre-coerced) MathType. + + If `self.default_value` is a scalar Python type, it will be coerced into the corresponding Sympy type using `sp.S`, after coersion to the correct Python type using `self.mathtype.coerce_compatible_pyobj()`. + + Raises: + ValueError: If `self.default_value` has no obvious, coerceable `spux.MathType` compatible with `self.mathtype`, as determined by `spux.MathType.has_mathtype`. + """ + mathtype_guide = spux.MathType.has_mathtype(self.default_value) + + # None: No Obvious Mathtype + if mathtype_guide is None: + msg = f'ExprSocket: Type of default value {self.default_value} (type {type(self.default_value)})' + raise ValueError(msg) + + # PyType: Coerce from PyType + if mathtype_guide == 'pytype': + dv_mathtype = spux.MathType.from_pytype(type(self.default_value)) + if self.mathtype.is_compatible(dv_mathtype): + self.default_value = sp.S( + self.mathtype.coerce_compatible_pyobj(self.default_value) + ) + else: + msg = f'ExprSocket: Mathtype {dv_mathtype} of default value {self.default_value} (type {type(self.default_value)}) is incompatible with socket MathType {self.mathtype}' + raise ValueError(msg) + + # Expr: Merely Check MathType Compatibility + if mathtype_guide == 'expr': + dv_mathtype = spux.MathType.from_expr(self.default_value) + if not self.mathtype.is_compatible(dv_mathtype): + msg = f'ExprSocket: Mathtype {dv_mathtype} of default value expression {self.default_value} (type {type(self.default_value)}) is incompatible with socket MathType {self.mathtype}' + raise ValueError(msg) + + return self + + #################### + # - Parse FlowKind.LazyArrayRange + #################### + @pyd.field_validator('default_steps') + @classmethod + def steps_must_be_0_or_gte_2(cls, v: int) -> int: + r"""Checks that steps is either 0 (not currently set), or $\ge 2$.""" + if not (v >= 2 or v == 0): # noqa: PLR2004 + msg = f'Default steps {v} must either be greater than or equal to 2, or 0 (denoting that no steps are currently given)' + raise ValueError(msg) + + return v + + @pyd.model_validator(mode='after') + def parse_default_lazy_array_range_numbers(self) -> typ.Self: + """Guarantees that the default `ct.LazyArrayRange` bounds are sympy expressions. + + If `self.default_value` is a scalar Python type, it will be coerced into the corresponding Sympy type using `sp.S`. + + Raises: + ValueError: If `self.default_value` has no obvious `spux.MathType`, as determined by `spux.MathType.has_mathtype`. + """ + new_bounds = [None, None] + for i, bound in enumerate([self.default_min, self.default_max]): + mathtype_guide = spux.MathType.has_mathtype(bound) + + # None: No Obvious Mathtype + if mathtype_guide is None: + msg = f'ExprSocket: A default bound {bound} (type {type(bound)}) has no MathType.' + raise ValueError(msg) + + # PyType: Coerce from PyType + if mathtype_guide == 'pytype': + dv_mathtype = spux.MathType.from_pytype(type(bound)) + if self.mathtype.is_compatible(dv_mathtype): + new_bounds[i] = sp.S(self.mathtype.coerce_compatible_pyobj(bound)) + else: + msg = f'ExprSocket: Mathtype {dv_mathtype} of a bound {bound} (type {type(bound)}) is incompatible with socket MathType {self.mathtype}' + raise ValueError(msg) + + # Expr: Merely Check MathType Compatibility + if mathtype_guide == 'expr': + dv_mathtype = spux.MathType.from_expr(bound) + if not self.mathtype.is_compatible(dv_mathtype): + msg = f'ExprSocket: Mathtype {dv_mathtype} of a default LazyArrayRange min or max expression {bound} (type {type(self.default_value)}) is incompatible with socket MathType {self.mathtype}' + raise ValueError(msg) + + if new_bounds[0] is not None: + self.default_min = new_bounds[0] + if new_bounds[1] is not None: + self.default_max = new_bounds[1] + + return self + + @pyd.model_validator(mode='after') + def parse_default_lazy_array_range_size(self) -> typ.Self: + """Guarantees that the default `ct.LazyArrayRange` bounds are unshaped. + + Raises: + ValueError: If `self.default_min` or `self.default_max` are shaped. + """ + # Check ActiveKind and Size + ## -> NOTE: This doesn't protect against dynamic changes to either. + if ( + self.active_kind == ct.FlowKind.LazyArrayRange + and self.size is not spux.NumberSize1D.Scalar + ): + msg = "Can't have a non-Scalar size when LazyArrayRange is set as the active kind." + raise ValueError(msg) + + # Check that Bounds are Shapeless + for bound in [self.default_min, self.default_max]: + if hasattr(bound, 'shape'): + msg = f'ExprSocket: A default bound {bound} (type {type(bound)}) has a shape, but LazyArrayRange supports no shape in ExprSockets.' + raise ValueError(msg) return self @@ -822,24 +1145,7 @@ class ExprSocketDef(base.SocketDef): # - Validators - Assertion #################### @pyd.model_validator(mode='after') - def valid_shapes(self) -> str: - if self.active_kind == ct.FlowKind.LazyArrayRange and self.shape is not None: - msg = "Can't have a non-None shape when LazyArrayRange is set as the active kind." - raise ValueError(msg) - - return self - - @pyd.model_validator(mode='after') - def mathtype_value(self) -> str: - default_value_mathtype = spux.MathType.from_expr(self.default_value) - if not self.mathtype.is_compatible(default_value_mathtype): - msg = f'MathType is {self.mathtype}, but tried to set default value {self.default_value} with mathtype {default_value_mathtype}' - raise ValueError(msg) - - return self - - @pyd.model_validator(mode='after') - def symbols_value(self) -> str: + def symbols_value(self) -> typ.Self: if ( self.default_value.free_symbols and not self.default_value.free_symbols.issubset(self.symbols) @@ -850,15 +1156,15 @@ class ExprSocketDef(base.SocketDef): return self @pyd.model_validator(mode='after') - def shape_value(self) -> str: + def shape_value(self) -> typ.Self: shape = spux.parse_shape(self.default_value) - if shape != self.shape and not ( - shape is not None - and self.shape is not None - and len(self.shape) == 1 - and 1 in shape - ): - msg = f'Default value {self.default_value} has shape {shape}, which is incompatible with the expr socket (shape {self.shape})' + if not self.size.supports_shape(shape): + msg = f'Default expr {self.default_value} has non-1D shape {shape}, which is incompatible with the expr socket def (size {self.size})' + raise ValueError(msg) + + size = spux.NumberSize1D.from_shape(shape) + if self.size != size: + msg = f'Default expr size {size} is incompatible with the expr socket (size {self.size})' raise ValueError(msg) return self @@ -870,30 +1176,36 @@ class ExprSocketDef(base.SocketDef): bl_socket.active_kind = self.active_kind # Socket Interface - bl_socket.shape = self.shape + ## -> Recall that auto-updates are turned off during init() + bl_socket.size = self.size bl_socket.mathtype = self.mathtype bl_socket.physical_type = self.physical_type bl_socket.symbols = self.symbols - # Socket Units & FlowKind.Value - if self.physical_type is not None: - bl_socket.unit = self.default_unit + # FlowKind.Value + ## -> We must take units into account when setting bl_socket.value + if self.physical_type is not spux.PhysicalType.NonPhysical: + self.active_unit = sp.sstr(self.default_unit) bl_socket.value = self.default_value * self.default_unit else: bl_socket.value = self.default_value - # FlowKind: LazyArrayRange + # FlowKind.LazyArrayRange + ## -> We can directly pass None to unit. bl_socket.lazy_array_range = ct.LazyArrayRangeFlow( start=self.default_min, stop=self.default_max, steps=self.default_steps, - scaling='lin', + scaling=self.default_scaling, unit=self.default_unit, ) # UI bl_socket.show_info_columns = self.show_info_columns + # Info Draw + bl_socket.use_info_draw = True + #################### # - Blender Registration diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py index 04160ee..ad6d08b 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py @@ -99,18 +99,16 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket): socket_type = ct.SocketType.Tidy3DCloudTask bl_label = 'Tidy3D Cloud Task' - use_prelock = True - #################### # - Properties #################### api_key: str = bl_cache.BLField('', prop_ui=True, str_secret=True) should_exist: bool = bl_cache.BLField(False) - existing_folder_id: enum.Enum = bl_cache.BLField( + existing_folder_id: enum.StrEnum = bl_cache.BLField( prop_ui=True, enum_cb=lambda self, _: self.search_cloud_folders() ) - existing_task_id: enum.Enum = bl_cache.BLField( + existing_task_id: enum.StrEnum = bl_cache.BLField( prop_ui=True, enum_cb=lambda self, _: self.search_cloud_tasks() ) @@ -299,6 +297,7 @@ class Tidy3DCloudTaskSocketDef(base.SocketDef): def init(self, bl_socket: Tidy3DCloudTaskBLSocket) -> None: bl_socket.should_exist = self.should_exist + bl_socket.use_prelock = True #################### diff --git a/src/blender_maxwell/utils/bl_cache.py b/src/blender_maxwell/utils/bl_cache.py deleted file mode 100644 index 7b97ae1..0000000 --- a/src/blender_maxwell/utils/bl_cache.py +++ /dev/null @@ -1,1016 +0,0 @@ -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Implements various key caches on instances of Blender objects, especially nodes and sockets.""" - -## TODO: Note that persist=True on cached_bl_property may cause a draw method to try and write to a Blender property, which Blender disallows. - -import enum -import functools -import inspect -import typing as typ -import uuid -from pathlib import Path - -import bpy -import numpy as np - -from blender_maxwell import contracts as ct -from blender_maxwell.utils import logger, serialize - -log = logger.get(__name__) - -InstanceID: typ.TypeAlias = str ## Stringified UUID4 - - -class Signal(enum.StrEnum): - """A value used to signal the descriptor via its `__set__`. - - Such a signal **must** be entirely unique: Even a well-thought-out string could conceivably produce a very nasty bug, where instead of setting a descriptor-managed attribute, the user would inadvertently signal the descriptor. - - To make it effectively impossible to confuse any other object whatsoever with a signal, the enum values are set to per-session `uuid.uuid4()`. - - Notes: - **Do not** use this enum for anything other than directly signalling a `bl_cache` descriptor via its setter. - - **Do not** store this enum `Signal` in a variable or method binding that survives longer than the session. - - **Do not** persist this enum; the values will change whenever `bl_cache` is (re)loaded. - """ - - InvalidateCache: str = str(uuid.uuid4()) - ResetEnumItems: str = str(uuid.uuid4()) - ResetStrSearch: str = str(uuid.uuid4()) - - -class BLInstance(typ.Protocol): - """An instance of a blender object, ex. nodes/sockets. - - Attributes: - instance_id: Stringified UUID4 that uniquely identifies an instance, among all active instances on all active classes. - """ - - instance_id: InstanceID - - def reset_instance_id(self) -> None: ... - - @classmethod - def declare_blfield( - cls, attr_name: str, bl_attr_name: str, prop_ui: bool = False - ) -> None: ... - - @classmethod - def set_prop( - cls, - prop_name: str, - prop: bpy.types.Property, - no_update: bool = False, - update_with_name: str | None = None, - **kwargs, - ) -> None: ... - - -class BLEnumStrEnum(typ.Protocol): - @staticmethod - def to_name(value: typ.Self) -> str: ... - - @staticmethod - def to_icon(value: typ.Self) -> ct.BLIcon: ... - - -StringPropSubType: typ.TypeAlias = typ.Literal[ - 'FILE_PATH', 'DIR_PATH', 'FILE_NAME', 'BYTE_STRING', 'PASSWORD', 'NONE' -] - -StrMethod: typ.TypeAlias = typ.Callable[ - [BLInstance, bpy.types.Context, str], list[tuple[str, str]] -] -EnumMethod: typ.TypeAlias = typ.Callable[ - [BLInstance, bpy.types.Context], list[ct.BLEnumElement] -] - -PropGetMethod: typ.TypeAlias = typ.Callable[ - [BLInstance], serialize.NaivelyEncodableType -] -PropSetMethod: typ.TypeAlias = typ.Callable[ - [BLInstance, serialize.NaivelyEncodableType], None -] - - -#################### -# - Cache: Non-Persistent -#################### -CACHE_NOPERSIST: dict[InstanceID, dict[typ.Any, typ.Any]] = {} - - -def invalidate_nonpersist_instance_id(instance_id: InstanceID) -> None: - """Invalidate any `instance_id` that might be utilizing cache space in `CACHE_NOPERSIST`. - - Notes: - This should be run by the `instance_id` owner in its `free()` method. - - Parameters: - instance_id: The ID of the Blender object instance that's being freed. - """ - CACHE_NOPERSIST.pop(instance_id, None) - - -#################### -# - Property Descriptor -#################### -class KeyedCache: - def __init__( - self, - func: typ.Callable, - exclude: set[str], - encode: set[str], - ): - # Function Information - self.func: typ.Callable = func - self.func_sig: inspect.Signature = inspect.signature(self.func) - - # Arg -> Key Information - self.exclude: set[str] = exclude - self.include: set[str] = set(self.func_sig.parameters.keys()) - exclude - self.encode: set[str] = encode - - # Cache Information - self.key_schema: tuple[str, ...] = tuple( - [ - arg_name - for arg_name in self.func_sig.parameters - if arg_name not in exclude - ] - ) - self.caches: dict[str | None, dict[tuple[typ.Any, ...], typ.Any]] = {} - - @property - def is_method(self): - return 'self' in self.exclude - - def cache(self, instance_id: str | None) -> dict[tuple[typ.Any, ...], typ.Any]: - if self.caches.get(instance_id) is None: - self.caches[instance_id] = {} - - return self.caches[instance_id] - - def _encode_key(self, arguments: dict[str, typ.Any]): - ## WARNING: Order of arguments matters. Arguments may contain 'exclude'd elements. - return tuple( - [ - ( - arg_value - if arg_name not in self.encode - else serialize.encode(arg_value) - ) - for arg_name, arg_value in arguments.items() - if arg_name in self.include - ] - ) - - def __get__( - self, bl_instance: BLInstance | None, owner: type[BLInstance] - ) -> typ.Callable: - _func = functools.partial(self, bl_instance) - _func.invalidate = functools.partial( - self.__class__.invalidate, self, bl_instance - ) - return _func - - def __call__(self, *args, **kwargs): - # Test Argument Bindability to Decorated Function - try: - bound_args = self.func_sig.bind(*args, **kwargs) - except TypeError as ex: - msg = f'Can\'t bind arguments (args={args}, kwargs={kwargs}) to @keyed_cache-decorated function "{self.func.__name__}" (signature: {self.func_sig})"' - raise ValueError(msg) from ex - - # Check that Parameters for Keying the Cache are Available - bound_args.apply_defaults() - all_arg_keys = set(bound_args.arguments.keys()) - if not self.include <= (all_arg_keys - self.exclude): - msg = f'Arguments spanning the keyed cached ({self.include}) are not available in the non-excluded arguments passed to "{self.func.__name__}": {all_arg_keys - self.exclude}' - raise ValueError(msg) - - # Create Keyed Cache Entry - key = self._encode_key(bound_args.arguments) - cache = self.cache(args[0].instance_id if self.is_method else None) - if (value := cache.get(key)) is None: - value = self.func(*args, **kwargs) - cache[key] = value - - return value - - def invalidate( - self, bl_instance: BLInstance | None, **arguments: dict[str, typ.Any] - ) -> dict[str, typ.Any]: - # Determine Wildcard Arguments - wildcard_arguments = { - arg_name for arg_name, arg_value in arguments.items() if arg_value is ... - } - - # Compute Keys to Invalidate - arguments_hashable = { - arg_name: serialize.encode(arg_value) - if arg_name in self.encode and arg_name not in wildcard_arguments - else arg_value - for arg_name, arg_value in arguments.items() - } - cache = self.cache(bl_instance.instance_id if self.is_method else None) - for key in list(cache.keys()): - if all( - arguments_hashable.get(arg_name) == arg_value - for arg_name, arg_value in zip(self.key_schema, key, strict=True) - if arg_name not in wildcard_arguments - ): - cache.pop(key) - - -def keyed_cache(exclude: set[str], encode: set[str] = frozenset()) -> typ.Callable: - def decorator(func: typ.Callable) -> typ.Callable: - return KeyedCache( - func, - exclude=exclude, - encode=encode, - ) - - return decorator - - -#################### -# - Property Descriptor -#################### -class CachedBLProperty: - """A descriptor that caches a computed attribute of a Blender node/socket/... instance (`bl_instance`), with optional cache persistence. - - Notes: - **Accessing the internal `_*` attributes is likely an anti-pattern**. - - `CachedBLProperty` does not own the data; it only provides a convenient interface of running user-provided getter/setters. - This also applies to the `bpy.types.Property` entry created by `CachedBLProperty`, which should not be accessed directly. - - Attributes: - _getter_method: Method of `bl_instance` that computes the value. - _setter_method: Method of `bl_instance` that sets the value. - _persist: Whether to persist the value on a `bpy.types.Property` defined on `bl_instance`. - The name of this `bpy.types.Property` will be `cache__`. - _type: The type of the value, used by the persistent decoder. - """ - - def __init__(self, getter_method: PropGetMethod, persist: bool): - """Initialize the getter (and persistance) of the cached property. - - Notes: - - When `persist` is true, the return annotation of the getter mathod will be used to guide deserialization. - - Parameters: - getter_method: Method of `bl_instance` that computes the value. - persist: Whether to persist the value on a `bpy.types.Property` defined on `bl_instance`. - The name of this `bpy.types.Property` will be `cache__`. - """ - self._getter_method: PropGetMethod = getter_method - self._setter_method: PropSetMethod | None = None - - # Persistance - self._persist: bool = persist - self._type: type | None = ( - inspect.signature(getter_method).return_annotation if persist else None - ) - - # Check Non-Empty Type Annotation - ## For now, just presume that all types can be encoded/decoded. - if self._type is not None and self._type is inspect.Signature.empty: - msg = f'A CachedBLProperty was instantiated with "persist={persist}", but its getter method "{self._getter_method}" has no return type annotation' - raise TypeError(msg) - - def __set_name__(self, owner: type[BLInstance], name: str) -> None: - """Generates the property name from the name of the attribute that this descriptor is assigned to. - - Notes: - - Run by Python when setting an instance of this class to an attribute. - - Parameters: - owner: The class that contains an attribute assigned to an instance of this descriptor. - name: The name of the attribute that an instance of descriptor was assigned to. - """ - self.prop_name: str = name - self._bl_prop_name: str = f'blcache__{name}' - - # Define Blender Property (w/Update Sync) - owner.set_prop( - self._bl_prop_name, - bpy.props.StringProperty, - name=f'DO NOT USE: Cache for {self.prop_name}', - default='', - no_update=True, - ) - - def __get__( - self, bl_instance: BLInstance | None, owner: type[BLInstance] - ) -> typ.Any: - """Retrieves the property from a cache, or computes it and fills the cache(s). - - If `self._persist` is `True`, the persistent cache will be checked and filled after the non-persistent cache. - - Notes: - - The non-persistent cache keeps the object in memory. - - The persistent cache serializes the object and stores it as a string on the BLInstance. This is often fast enough, and has decent compatibility (courtesy `msgspec`), it isn't nearly as fast as the non-persistent cache, and there are gotchas. - - Parameters: - bl_instance: The Blender object this prop - """ - if bl_instance is None: - return None - if not bl_instance.instance_id: - log.debug( - "Can't Get CachedBLProperty: Instance ID not (yet) defined on BLInstance %s", - str(bl_instance), - ) - return None - - # Create Non-Persistent Cache Entry - ## Prefer explicit cache management to 'defaultdict' - if CACHE_NOPERSIST.get(bl_instance.instance_id) is None: - CACHE_NOPERSIST[bl_instance.instance_id] = {} - cache_nopersist = CACHE_NOPERSIST[bl_instance.instance_id] - - # Try Hit on Non-Persistent Cache - if (value := cache_nopersist.get(self._bl_prop_name)) is not None: - return value - - # Try Hit on Persistent Cache - ## Hit: Fill Non-Persistent Cache - if ( - self._persist - and (encoded_value := getattr(bl_instance, self._bl_prop_name)) != '' - ): - value = serialize.decode(self._type, encoded_value) - cache_nopersist[self._bl_prop_name] = value - return value - - # Compute Value - ## Fill Non-Persistent Cache - ## Fill Persistent Cache (maybe) - value = self._getter_method(bl_instance) - cache_nopersist[self._bl_prop_name] = value - if self._persist: - setattr( - bl_instance, self._bl_prop_name, serialize.encode(value).decode('utf-8') - ) - return value - - def __set__(self, bl_instance: BLInstance | None, value: typ.Any) -> None: - """Runs the user-provided setter, after invalidating the caches. - - Notes: - - This invalidates all caches without re-filling them. - - The caches will be re-filled on the first `__get__` invocation, which may be slow due to having to run the getter method. - - Parameters: - bl_instance: The Blender object this prop - """ - if bl_instance is None: - return - if not bl_instance.instance_id: - log.debug( - "Can't Set CachedBLProperty: Instance ID not (yet) defined on BLInstance %s", - str(bl_instance), - ) - return - - if value == Signal.InvalidateCache: - self._invalidate_cache(bl_instance) - return - - if self._setter_method is None: - msg = f'Tried to set "{value}" to "{self.prop_name}" on "{bl_instance.bl_label}", but a setter was not defined' - raise NotImplementedError(msg) - - # Invalidate Caches - self._invalidate_cache(bl_instance) - - # Set the Value - self._setter_method(bl_instance, value) - - def setter(self, setter_method: PropSetMethod) -> typ.Self: - """Decorator to add a setter to the cached property. - - Returns: - The same descriptor, so that use of the same method name for defining a setter won't change the semantics of the attribute. - - Examples: - Without the decor - ```python - class Test(bpy.types.Node): - bl_label = 'Default' - ... - def method(self) -> str: return self.bl_label - attr = CachedBLProperty(getter_method=method, persist=False) - - @attr.setter - def attr(self, value: str) -> None: - self.bl_label = 'Altered' - ``` - """ - # Validate Setter Signature - setter_sig = inspect.signature(setter_method) - - ## Parameter Length - if (sig_len := len(setter_sig.parameters)) != 2: # noqa: PLR2004 - msg = f'Setter method for "{self.prop_name}" should have 2 parameters, not "{sig_len}"' - raise TypeError(msg) - - ## Parameter Value Type - if (sig_ret_type := setter_sig.return_annotation) is not None: - msg = f'Setter method for "{self.prop_name}" return value type "{sig_ret_type}", but it should be "None" (omitting an annotation does not imply "None")' - raise TypeError(msg) - - self._setter_method = setter_method - return self - - def _invalidate_cache(self, bl_instance: BLInstance) -> None: - """Invalidates all caches that might be storing the computed property value. - - This is invoked by `__set__`. - - Notes: - Will not delete the `bpy.props.StringProperty`; instead, it will be set to ''. - - Parameters: - bl_instance: The instance of the Blender object that contains this property. - """ - # Invalidate Non-Persistent Cache - if CACHE_NOPERSIST.get(bl_instance.instance_id) is not None: - CACHE_NOPERSIST[bl_instance.instance_id].pop(self._bl_prop_name, None) - - # Invalidate Persistent Cache - if self._persist and getattr(bl_instance, self._bl_prop_name) != '': - setattr(bl_instance, self._bl_prop_name, '') - - -#################### -# - Property Decorators -#################### -def cached_bl_property(persist: bool = False): - """Decorator creating a descriptor that caches a computed attribute of a Blender node/socket. - - Many such `bl_instance`s rely on fast access to computed, cached properties, for example to ensure that `draw()` remains effectively non-blocking. - It is also sometimes desired that this cache persist on `bl_instance`, ex. in the case of loose sockets or cached web data. - - Notes: - - Unfortunately, `functools.cached_property` doesn't work, and can't handle persistance. - - Use `cached_attribute` instead if merely persisting the value is desired. - - Parameters: - persist: Whether or not to persist the cache value in the Blender object. - This should be used when the **source(s) of the computed value also persists with the Blender object**. - For example, this is especially helpful when caching information for use in `draw()` methods, so that reloading the file won't alter the cache. - - Examples: - ```python - class CustomNode(bpy.types.Node): - @bl_cache.cached(persist=True) - def computed_prop(self) -> ...: return ... - - print(bl_instance.prop) ## Computes first time - print(bl_instance.prop) ## Cached (after restart, will read from persistent cache) - ``` - """ - - def decorator(getter_method: typ.Callable[[BLInstance], None]) -> type: - return CachedBLProperty(getter_method=getter_method, persist=persist) - - return decorator - - -#################### -# - Attribute Descriptor -#################### -class BLField: - """A descriptor that allows persisting arbitrary types in Blender objects, with cached reads.""" - - def __init__( - self, - default_value: typ.Any = None, - use_prop_update: bool = True, - ## Static - prop_ui: bool = False, - prop_flags: set[ct.BLPropFlag] | None = None, - abs_min: int | float | None = None, - abs_max: int | float | None = None, - soft_min: int | float | None = None, - soft_max: int | float | None = None, - float_step: int | None = None, - float_prec: int | None = None, - str_secret: bool | None = None, - path_type: typ.Literal['dir', 'file'] | None = None, - ## Static / Dynamic - enum_many: bool | None = None, - ## Dynamic - str_cb: StrMethod | None = None, - enum_cb: EnumMethod | None = None, - ) -> typ.Self: - """Initializes and sets the attribute to a given default value. - - The attribute **must** declare a type annotation, and it **must** match the type of `default_value`. - - Parameters: - default_value: The default value to use if the value is read before it's set. - use_prop_update: Configures the BLField to run `bl_instance.on_prop_changed(attr_name)` whenever value is set. - This is done by setting the `update` method. - enum_cb: Method used to generate new enum elements whenever `Signal.ResetEnum` is presented. - matrix_rowmajor: Blender's UI stores matrices flattened, - - """ - log.debug( - 'Initializing BLField (default_value=%s, use_prop_update=%s)', - str(default_value), - str(use_prop_update), - ) - self._default_value: typ.Any = default_value - self._use_prop_update: bool = use_prop_update - - ## Static - self._prop_ui = prop_ui - self._prop_flags = prop_flags - self._abs_min = abs_min - self._abs_max = abs_max - self._soft_min = soft_min - self._soft_max = soft_max - self._float_step = float_step - self._float_prec = float_prec - self._str_secret = str_secret - self._path_type = path_type - - ## Static / Dynamic - self._enum_many = enum_many - - ## Dynamic - self._set_ser_default = False - self._str_cb = str_cb - self._enum_cb = enum_cb - - ## Type Coercion - self._coerce_output_to = None - - ## Vector/Matrix Identity - ## -> Matrix Shape assists in the workaround for Matrix Display Bug - self._is_vector = False - self._is_matrix = False - self._matrix_shape = None - - ## HUGE TODO: Persist these - self._str_cb_cache = {} - self._enum_cb_cache = {} - - #################### - # - Safe Callbacks - #################### - def _safe_str_cb( - self, _self: BLInstance, context: bpy.types.Context, edit_text: str - ): - """Wrapper around StringProperty.search which **guarantees** that returned strings will not be garbage collected. - - Regenerate by passing `Signal.ResetStrSearch`. - """ - if self._str_cb_cache.get(_self.instance_id) is None: - self._str_cb_cache[_self.instance_id] = self._str_cb( - _self, context, edit_text - ) - - return self._str_cb_cache[_self.instance_id] - - def _safe_enum_cb(self, _self: BLInstance, context: bpy.types.Context): - """Wrapper around EnumProperty.items callback, which **guarantees** that returned strings will not be garbage collected. - - The mechanism is simple: The user-generated callback is run once, then cached in the descriptor instance for subsequent use. - This guarantees that the user won't crash Blender by returning dynamically generated strings in the user-provided callback. - - The cost, however, is that user-provided callback won't run eagerly anymore. - Thus, whenever the user wants the items in the enum to update, they must manually set the descriptor attribute to the value `Signal.ResetEnumItems`. - """ - if self._enum_cb_cache.get(_self.instance_id) is None: - # Retrieve Dynamic Enum Items - enum_items = self._enum_cb(_self, context) - - # Ensure len(enum_items) >= 1 - ## There must always be one element to prevent invalid usage. - if len(enum_items) == 0: - self._enum_cb_cache[_self.instance_id] = [ - ( - 'NONE', - 'None', - 'No items...', - '', - 0 if not self._enum_many else 2**0, - ) - ] - else: - self._enum_cb_cache[_self.instance_id] = enum_items - - return self._enum_cb_cache[_self.instance_id] - - def __set_name__(self, owner: type[BLInstance], name: str) -> None: - """Sets up the descriptor on the class level, preparing it for per-instance use. - - - The type annotation of the attribute is noted, as it might later guide (de)serialization of the field. - - An appropriate `bpy.props.Property` is chosen for the type annotaiton, with a default-case fallback of `bpy.props.StringProperty` containing serialized data. - - Our getter/setter essentially reads/writes to a `bpy.props.StringProperty`, with - - and use them as user-provided getter/setter to internally define a normal non-persistent `CachedBLProperty`. - As a result, we can reuse almost all of the logic in `CachedBLProperty` - - Notes: - Run by Python when setting an instance of this class to an attribute. - - For StringProperty subtypes, see: - - Parameters: - owner: The class that contains an attribute assigned to an instance of this descriptor. - name: The name of the attribute that an instance of descriptor was assigned to. - """ - # Compute Name of Property - ## Internal name uses 'blfield__' to avoid unfortunate overlaps. - attr_name = name - bl_attr_name = f'blfield__{name}' - - owner.declare_blfield(attr_name, bl_attr_name, prop_ui=self._prop_ui) - - # Compute Type of Property - ## The type annotation of the BLField guides (de)serialization. - if (AttrType := inspect.get_annotations(owner).get(name)) is None: - msg = f'BLField "{self.prop_name}" must define a type annotation, but doesn\'t' - raise TypeError(msg) - - # Define Blender Property (w/Update Sync) - default_value = None - no_default_value = False - prop_is_serialized = False - kwargs_prop = {} - - ## Reusable Snippets - def _add_min_max_kwargs(): - nonlocal kwargs_prop ## I've heard legends of needing this! - kwargs_prop |= {'min': self._abs_min} if self._abs_min is not None else {} - kwargs_prop |= {'max': self._abs_max} if self._abs_max is not None else {} - kwargs_prop |= ( - {'soft_min': self._soft_min} if self._soft_min is not None else {} - ) - kwargs_prop |= ( - {'soft_max': self._soft_max} if self._soft_max is not None else {} - ) - - def _add_float_kwargs(): - nonlocal kwargs_prop - kwargs_prop |= ( - {'step': self._float_step} if self._float_step is not None else {} - ) - kwargs_prop |= ( - {'precision': self._float_prec} if self._float_prec is not None else {} - ) - - ## Property Flags - kwargs_prop |= { - 'options': self._prop_flags if self._prop_flags is not None else set() - } - - ## Scalar Bool - if AttrType is bool: - default_value = self._default_value - BLProp = bpy.props.BoolProperty - - ## Scalar Int - elif AttrType is int: - default_value = self._default_value - BLProp = bpy.props.IntProperty - _add_min_max_kwargs() - - ## Scalar Float - elif AttrType is float: - default_value = self._default_value - BLProp = bpy.props.FloatProperty - _add_min_max_kwargs() - _add_float_kwargs() - - ## Vector Bool - elif typ.get_origin(AttrType) is tuple and all( - T is bool for T in typ.get_args(AttrType) - ): - default_value = self._default_value - BLProp = bpy.props.BoolVectorProperty - kwargs_prop |= {'size': len(typ.get_args(AttrType))} - self._is_vector = True - - ## Vector Int - elif typ.get_origin(AttrType) is tuple and all( - T is int for T in typ.get_args(AttrType) - ): - default_value = self._default_value - BLProp = bpy.props.IntVectorProperty - _add_min_max_kwargs() - kwargs_prop |= {'size': len(typ.get_args(AttrType))} - self._is_vector = True - - ## Vector Float - elif typ.get_origin(AttrType) is tuple and all( - T is float for T in typ.get_args(AttrType) - ): - default_value = self._default_value - BLProp = bpy.props.FloatVectorProperty - _add_min_max_kwargs() - _add_float_kwargs() - kwargs_prop |= {'size': len(typ.get_args(AttrType))} - self._is_vector = True - - ## Matrix Bool - elif typ.get_origin(AttrType) is tuple and all( - all(V is bool for V in typ.get_args(T)) for T in typ.get_args(AttrType) - ): - # Workaround for Matrix Display Bug - ## - Also requires __get__ support to read consistently. - rows = len(typ.get_args(AttrType)) - cols = len(typ.get_args(typ.get_args(AttrType)[0])) - default_value = ( - np.array(self._default_value, dtype=bool) - .flatten() - .reshape([cols, rows]) - ).tolist() - BLProp = bpy.props.BoolVectorProperty - kwargs_prop |= {'size': (cols, rows), 'subtype': 'MATRIX'} - ## 'size' has column-major ordering (Matrix Display Bug). - self._is_matrix = True - self._matrix_shape = (rows, cols) - - ## Matrix Int - elif typ.get_origin(AttrType) is tuple and all( - all(V is int for V in typ.get_args(T)) for T in typ.get_args(AttrType) - ): - _add_min_max_kwargs() - rows = len(typ.get_args(AttrType)) - cols = len(typ.get_args(typ.get_args(AttrType)[0])) - default_value = ( - np.array(self._default_value, dtype=int).flatten().reshape([cols, rows]) - ).tolist() - BLProp = bpy.props.IntVectorProperty - kwargs_prop |= {'size': (cols, rows), 'subtype': 'MATRIX'} - self._is_matrix = True - self._matrix_shape = (rows, cols) - - ## Matrix Float - elif typ.get_origin(AttrType) is tuple and all( - all(V is float for V in typ.get_args(T)) for T in typ.get_args(AttrType) - ): - _add_min_max_kwargs() - _add_float_kwargs() - rows = len(typ.get_args(AttrType)) - cols = len(typ.get_args(typ.get_args(AttrType)[0])) - default_value = ( - np.array(self._default_value, dtype=float) - .flatten() - .reshape([cols, rows]) - ).tolist() - BLProp = bpy.props.FloatVectorProperty - kwargs_prop |= {'size': (cols, rows), 'subtype': 'MATRIX'} - self._is_matrix = True - self._matrix_shape = (rows, cols) - - ## Generic String - elif AttrType is str: - default_value = self._default_value - BLProp = bpy.props.StringProperty - if self._str_secret: - kwargs_prop |= {'subtype': 'PASSWORD'} - kwargs_prop['options'].add('SKIP_SAVE') - - if self._str_cb is not None: - kwargs_prop |= { - 'search': lambda _self, context, edit_text: self._safe_str_cb( - _self, context, edit_text - ) - } - - ## Path - elif AttrType is Path: - if self._path_type is None: - msg = 'Path BLField must define "path_type"' - raise ValueError(msg) - - default_value = self._default_value - BLProp = bpy.props.StringProperty - kwargs_prop |= { - 'subtype': 'FILE_PATH' if self._path_type == 'file' else 'DIR_PATH' - } - - ## StrEnum - elif ( - inspect.isclass(AttrType) - and issubclass(AttrType, enum.StrEnum) - and self._enum_cb is None - ): - default_value = self._default_value - BLProp = bpy.props.EnumProperty - kwargs_prop |= { - 'items': [ - ( - str(value), - AttrType.to_name(value), - AttrType.to_name(value), ## TODO: From AttrType.__doc__ - AttrType.to_icon(value), - i if not self._enum_many else 2**i, - ) - for i, value in enumerate(list(AttrType)) - ] - } - if self._enum_many: - kwargs_prop['options'].add('ENUM_FLAG') - self._coerce_output_to = AttrType - - ## Dynamic Enum - elif ( - AttrType is enum.Enum - or (inspect.isclass(AttrType) and issubclass(AttrType, enum.StrEnum)) - and self._enum_cb is not None - ): - if self._default_value is not None: - msg = 'When using dynamic enum, default value must be None' - raise ValueError(msg) - no_default_value = True - - BLProp = bpy.props.EnumProperty - kwargs_prop |= { - 'items': lambda _self, context: self._safe_enum_cb(_self, context), - } - if self._enum_many: - kwargs_prop['options'].add('ENUM_FLAG') - if AttrType is not enum.Enum: - self._coerce_output_to = AttrType - - ## BL Reference - elif AttrType in typ.get_args(ct.BLIDStruct): - default_value = self._default_value - BLProp = bpy.props.PointerProperty - - ## Serializable Object - else: - default_value = serialize.encode(self._default_value).decode('utf-8') - BLProp = bpy.props.StringProperty - prop_is_serialized = True - - # Set Default Value (probably) - if not no_default_value: - kwargs_prop |= {'default': default_value} - - # Set Blender Property on Class __annotations__ - owner.set_prop( - bl_attr_name, - BLProp, - # Update Callback Options - no_update=not self._use_prop_update, - update_with_name=attr_name, - # Property Options - name=('[JSON] ' if prop_is_serialized else '') + f'BLField: {attr_name}', - **kwargs_prop, - ) ## TODO: Mine description from owner class __doc__ - - # Define Property Getter - ## Serialized properties need to deserialize in the getter. - if prop_is_serialized: - - def getter(_self: BLInstance) -> AttrType: - return serialize.decode(AttrType, getattr(_self, bl_attr_name)) - else: - - def getter(_self: BLInstance) -> AttrType: - return getattr(_self, bl_attr_name) - - # Define Property Setter - ## Serialized properties need to serialize in the setter. - if prop_is_serialized: - - def setter(_self: BLInstance, value: AttrType) -> None: - encoded_value = serialize.encode(value).decode('utf-8') - setattr(_self, bl_attr_name, encoded_value) - else: - - def setter(_self: BLInstance, value: AttrType) -> None: - setattr(_self, bl_attr_name, value) - - # Initialize CachedBLProperty w/Getter and Setter - ## This is the usual descriptor assignment procedure. - self._cached_bl_property = CachedBLProperty(getter_method=getter, persist=False) - self._cached_bl_property.__set_name__(owner, name) - self._cached_bl_property.setter(setter) - - def __get__( - self, bl_instance: BLInstance | None, owner: type[BLInstance] - ) -> typ.Any: - if bl_instance is None: - return None - - value = self._cached_bl_property.__get__(bl_instance, owner) - - # enum.Enum: Cast Auto-Injected Dynamic Enum 'NONE' -> None - ## As far a Blender is concerned, dynamic enum props can't be empty. - ## -> Well, they can... But bad things happen. So they can't. - ## So in the interest of the user's sanity, we always ensure one entry. - ## -> This one entry always has the one, same, id: 'NONE'. - ## Of course, we often want to check for this "there was nothing" case. - ## -> Aka, we want to do a `None` check, semantically speaking. - ## -> ...But because it's a special thingy, we must check 'NONE'? - ## Nonsense. Let the user just check `None`, as Guido intended. - if self._enum_cb is not None and value == 'NONE': - ## TODO: Perhaps check if the unsafe callback was actually []. - ## -> In case the user themselves want to return 'NONE'. - ## -> Why would they do this? Because they are users! - return None - - # Sized Vectors/Matrices - ## Why not just yeet back a np.array? - ## -> Type-annotating a shaped numpy array is... "rough". - ## -> Type-annotation tuple[] of known shape is super easy. - ## -> Even list[] won't do; its size varies, after all! - ## -> Reject modernity. Return to tuple[]. - if self._is_vector: - ## -> tuple()ify the np.array to respect tuple[] type annotation. - return tuple(value) - - if self._is_matrix: - # Matrix Display Bug: Correctly Read Row-Major Values w/Reshape - return tuple( - map(tuple, np.array(value).flatten().reshape(self._matrix_shape)) - ) - - # Coerce Output - ## -> Mainly useful for getting the "real" StrEnum back. - if self._coerce_output_to is not None and value is not None: - if self._enum_many: - return {self._coerce_output_to(v) for v in value} - return self._coerce_output_to(value) - - return value - - def __set__(self, bl_instance: BLInstance | None, value: typ.Any) -> None: - if value == Signal.ResetEnumItems: - old_items = self._safe_enum_cb(bl_instance, None) - current_items = self._enum_cb(bl_instance, None) - - # Only Change if Changes Need Making - if old_items != current_items: - # Set Enum to First Item - ## Prevents the seemingly "missing" enum element bug. - ## -> Caused by the old int still trying to hang on after. - ## -> We can mitigate this by preemptively setting the enum. - ## -> Infinite recursion if we don't check current value. - ## -> May cause a hiccup (chains will trigger twice) - ## To work, there **must** be a guaranteed-available string at 0,0. - first_old_value = old_items[0][0] - current_value = self._cached_bl_property.__get__( - bl_instance, bl_instance.__class__ - ) - if current_value != first_old_value: - self._cached_bl_property.__set__(bl_instance, first_old_value) - - # Pop the Cached Enum Items - ## The next time Blender asks for the enum items, it'll update. - self._enum_cb_cache.pop(bl_instance.instance_id, None) - - # Invalidate the Getter Cache - ## The next time the user runs __get__, they'll get the new value. - self._cached_bl_property.__set__(bl_instance, Signal.InvalidateCache) - - elif value == Signal.ResetStrSearch: - old_items = self._safe_str_cb(bl_instance, None) - current_items = self._str_cb(bl_instance, None) - - # Only Change if Changes Need Making - if old_items != current_items: - # Set String to '' - ## Prevents the presence of an invalid value not in the new search. - ## -> Infinite recursion if we don't check current value for ''. - ## -> May cause a hiccup (chains will trigger twice) - current_value = self._cached_bl_property.__get__( - bl_instance, bl_instance.__class__ - ) - if current_value != '': - self._cached_bl_property.__set__(bl_instance, '') - - # Pop the Cached String Search Items - ## The next time Blender does a str search, it'll update. - self._str_cb_cache.pop(bl_instance.instance_id, None) - - else: - self._cached_bl_property.__set__(bl_instance, value) diff --git a/src/blender_maxwell/utils/bl_cache/__init__.py b/src/blender_maxwell/utils/bl_cache/__init__.py new file mode 100644 index 0000000..fa81d92 --- /dev/null +++ b/src/blender_maxwell/utils/bl_cache/__init__.py @@ -0,0 +1,36 @@ +# blender_maxwell +# Copyright (C) 2024 blender_maxwell Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Package providing various tools to handle cached data on Blender objects, especially nodes and node socket classes.""" + +from .bl_field import BLField +from .bl_prop import BLProp, BLPropType +from .cached_bl_property import CachedBLProperty, cached_bl_property +from .keyed_cache import KeyedCache, keyed_cache +from .managed_cache import invalidate_nonpersist_instance_id +from .signal import Signal + +__all__ = [ + 'BLField', + 'BLProp', + 'BLPropType', + 'CachedBLProperty', + 'cached_bl_property', + 'KeyedCache', + 'keyed_cache', + 'invalidate_nonpersist_instance_id', + 'Signal', +] diff --git a/src/blender_maxwell/utils/bl_cache/bl_field.py b/src/blender_maxwell/utils/bl_cache/bl_field.py new file mode 100644 index 0000000..1735c89 --- /dev/null +++ b/src/blender_maxwell/utils/bl_cache/bl_field.py @@ -0,0 +1,424 @@ +# blender_maxwell +# Copyright (C) 2024 blender_maxwell Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Implements various key caches on instances of Blender objects, especially nodes and sockets.""" + +import functools +import inspect +import typing as typ + +import bpy + +from blender_maxwell import contracts as ct +from blender_maxwell.utils import bl_instance, logger + +from .bl_prop import BLProp +from .bl_prop_type import BLPropType +from .signal import Signal + +log = logger.get(__name__) + + +StringPropSubType: typ.TypeAlias = typ.Literal[ + 'FILE_PATH', 'DIR_PATH', 'FILE_NAME', 'BYTE_STRING', 'PASSWORD', 'NONE' +] + +StrMethod: typ.TypeAlias = typ.Callable[ + [bl_instance.BLInstance, bpy.types.Context, str], list[tuple[str, str]] +] +EnumMethod: typ.TypeAlias = typ.Callable[ + [bl_instance.BLInstance, bpy.types.Context], list[ct.BLEnumElement] +] + +DEFAULT_ENUM_ITEMS_SINGLE = [('NONE', 'None', 'No items...', '', 0)] +DEFAULT_ENUM_ITEMS_MANY = [('NONE', 'None', 'No items...', '', 2**0)] + + +@functools.cache +def default_enum_items(enum_many: bool) -> list[ct.BLEnumElement]: + return DEFAULT_ENUM_ITEMS_MANY if enum_many else DEFAULT_ENUM_ITEMS_SINGLE + + +#################### +# - BLField +#################### +class BLField: + """A descriptor that allows persisting arbitrary types in Blender objects, with cached reads.""" + + def __init__( + self, + default_value: typ.Any = None, + use_prop_update: bool = True, + ## Static + prop_ui: bool = False, ## TODO: Remove + abs_min: int | float | None = None, + abs_max: int | float | None = None, + soft_min: int | float | None = None, + soft_max: int | float | None = None, + float_step: int | None = None, + float_prec: int | None = None, + str_secret: bool | None = None, + path_type: typ.Literal['dir', 'file'] | None = None, + # blptr_type: typ.Any | None = None, ## A Blender ID type + ## TODO: Test/Implement + ## Dynamic + str_cb: StrMethod | None = None, + enum_cb: EnumMethod | None = None, + cb_depends_on: set[str] | None = None, + ) -> typ.Self: + """Initializes and sets the attribute to a given default value. + + The attribute **must** declare a type annotation, and it **must** match the type of `default_value`. + + Parameters: + default_value: The default value to use if the value is read before it's set. + use_prop_update: If True, `BLField` will consent to `bl_instance.on_prop_changed(attr_name)` being run whenever the field is changed. + UI changes done to the property via Blender **always** trigger `bl_instance.on_bl_prop_changed`; however, the `BLField` decides whether `on_prop_changed` should be run as well. + That control is offered through `use_prop_update`. + abs_min: Sets the absolute minimum value of the property. + Only meaningful for numerical properties. + abs_max: Sets the absolute maximum value of the property. + Only meaningful for numerical properties. + soft_min: Sets a value which will feel like a minimum in the UI, but which can be overridden by setting a value directly. + In practice, "scrolling" through values will stop here. + Only meaningful for numerical properties. + soft_max: Sets a value which will feel like a maximum in the UI, but which can be overridden by setting a value directly. + In practice, "scrolling" through values will stop here. + Only meaningful for numerical properties. + float_step: Sets the interval (/100) of each step when "scrolling" through the values of a float property, aka. the speed. + Only meaningful for float-like properties. + float_step: Sets the decimal places of precision to display. + Only meaningful for float-like properties. + str_secret: Marks the string as "secret", which prevents its save-persistance, and causes the UI to display dots instead of characters. + **DO NOT** rely on this property for "real" security. + _If in doubt, this isn't good enough._ + Only meaningful for `str` properties. + path_type: Makes the path as pointing to a folder or to a file. + Only meaningful for `pathlib.Path` properties. + **NOTE**: No effort is made to make paths portable between operating systems. + Use with care. + str_cb: Method used to determine all valid strings, which presents to the user as a fuzzy-style search dropdown. + Only meaningful for `str` properties. + Results are not persisted, and must therefore re-run when reloading the file. + Otherwise, it is cached, but is re-run whenever `Signal.ResetStrSearch` is set. + enum_cb: Method used to determine all valid enum elements, which presents to the user as a dropdown. + The caveats with dynamic `bpy.props.EnumProperty`s are **absurdly sharp**. + Those caveats are entirely mitigated when using this callback, at the cost of manual resets. + Is re-run whenever `Signal.ResetEnumItems` is set, and otherwise cached both persistently and non-persistently. + cb_depends_on: Declares that `str_cb` / `enum_cb` should be regenerated whenever any of the given property names change. + This allows fully automating the invocation of `Signal.ResetEnumItems` / `Signal.ResetStrSearch` in common cases. + """ + log.debug( + 'Initializing BLField (default_value=%s, use_prop_update=%s)', + str(default_value), + str(use_prop_update), + ) + + self.use_dynamic_enum = enum_cb is not None + self.use_str_search = str_cb is not None + + ## TODO: Use prop_flags + self.prop_info = { + 'default': default_value, + 'use_prop_update': use_prop_update, + # Int* | Float*: Bounds + 'min': abs_min, + 'max': abs_max, + 'soft_min': soft_min, + 'soft_max': soft_max, + # Float*: UI + 'step': float_step, + 'precision': float_prec, + # BLPointer: ID Type + #'blptr_type': blptr_type, + # Str | Path | Enum: Flag Setters + 'str_secret': str_secret, + 'path_type': path_type, + # Search: Str + 'str_search': self.use_str_search, + 'safe_str_cb': lambda _self, context, edit_text: self.safe_str_cb( + _self, context, edit_text + ), + # Search: Enum + 'enum_dynamic': self.use_dynamic_enum, + 'safe_enum_cb': lambda _self, context: self.safe_enum_cb(_self, context), + } + + # BLProp + self.bl_prop: BLProp | None = None + self.bl_prop_enum_items: BLProp | None = None + self.bl_prop_str_search: BLProp | None = None + + self.enum_cb = enum_cb + self.str_cb = str_cb + + self.cb_depends_on: set[str] | None = cb_depends_on + + # Update Suppressing + self.suppress_update: dict[str, bool] = {} + + #################### + # - Descriptor Setup + #################### + def __set_name__(self, owner: type[bl_instance.BLInstance], name: str) -> None: + """Sets up this descriptor on the class, preparing it for per-instance use. + + A `BLProp` is constructed using this descriptor's attribute name on `owner`, and the `self.prop_info` previously created during `self.__init__()`. + Then, a corresponding / underlying `bpy.types.Property` is initialized on `owner` using `self.bl_prop.init_bl_type(owner)` + + Notes: + Run by Python when setting an instance of a "descriptor" class, to an attribute of another class (denoted `owner`). + For more, search for "Python descriptor protocol". + + Parameters: + owner: The class that contains an attribute assigned to an instance of this descriptor. + name: The name of the attribute that an instance of descriptor was assigned to. + """ + prop_type = inspect.get_annotations(owner).get(name) + self.bl_prop = BLProp( + name=name, + prop_info=self.prop_info, + prop_type=prop_type, + bl_prop_type=BLPropType.from_type(prop_type), + ) + + # Initialize Field on BLClass + self.bl_prop.init_bl_type( + owner, + enum_depends_on=self.cb_depends_on, + strsearch_depends_on=self.cb_depends_on, + ) + + # Dynamic Enum: Initialize Persistent Enum Items + if self.prop_info['enum_dynamic']: + self.bl_prop_enum_items = BLProp( + name=self.bl_prop.enum_cache_key, + prop_info={'default': [], 'use_prop_update': False}, + prop_type=list[ct.BLEnumElement], + bl_prop_type=BLPropType.Serialized, + ) + self.bl_prop_enum_items.init_bl_type(owner) + + # Searched Str: Initialize Persistent Str List + if self.prop_info['str_search']: + self.bl_prop_str_search = BLProp( + name=self.bl_prop.str_cache_key, + prop_info={'default': [], 'use_prop_update': False}, + prop_type=list[str], + bl_prop_type=BLPropType.Serialized, + ) + self.bl_prop_str_search.init_bl_type(owner) + + def __get__( + self, + bl_instance: bl_instance.BLInstance | None, + owner: type[bl_instance.BLInstance], + ) -> typ.Any: + """Retrieves the value described by the BLField. + + Notes: + Run by Python when the attribute described by the descriptor is accessed. + For more, search for "Python descriptor protocol". + + Parameters: + bl_instance: Instance that is accessing the attribute. + owner: The class that owns the instance. + """ + # Compute Value (if available) + cached_value = self.bl_prop.read_nonpersist(bl_instance) + if cached_value is Signal.CacheNotReady or cached_value is Signal.CacheEmpty: + if bl_instance is not None: + persisted_value = self.bl_prop.read(bl_instance) + self.bl_prop.write_nonpersist(bl_instance, persisted_value) + return persisted_value + return self.bl_prop.default_value ## TODO: Good idea? + return cached_value + + def suppress_next_update(self, bl_instance) -> None: + self.suppress_update[bl_instance.instance_id] = True + ## TODO: Make it a context manager to prevent the worst of surprises + + def __set__( + self, bl_instance: bl_instance.BLInstance | None, value: typ.Any + ) -> None: + """Sets the value described by the BLField. + + In general, any BLField modified in the UI will set `InvalidateCache` on this descriptor. + If `self.prop_info['use_prop_update']` is set, the method `bl_instance.on_prop_changed(self.bl_prop.name)` will then be called and start a `FlowKind.DataChanged` event chain. + + Notes: + Run by Python when the attribute described by the descriptor is set. + For more, search for "Python descriptor protocol". + + Parameters: + bl_instance: Instance that is accessing the attribute. + owner: The class that owns the instance. + """ + # Perform Update Chain + ## -> We still respect 'use_prop_update', since it is user-sourced. + if value is Signal.DoUpdate: + if self.prop_info['use_prop_update']: + bl_instance.on_prop_changed(self.bl_prop.name) + + # Invalidate Cache + ## -> This empties the non-persistent cache. + ## -> As a result, the value must be reloaded from the property. + ## The 'on_prop_changed' method on the bl_instance might also be called. + elif value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate: + self.bl_prop.invalidate_nonpersist(bl_instance) + + # Update Suppression + if self.suppress_update.get(bl_instance.instance_id): + self.suppress_update[bl_instance.instance_id] = False + + # ELSE: Trigger Update Chain + elif self.prop_info['use_prop_update'] and value is Signal.InvalidateCache: + bl_instance.on_prop_changed(self.bl_prop.name) + + # Reset Enum Items + elif value is Signal.ResetEnumItems: + # Retrieve Old Items + ## -> This is verbatim what is being persisted, currently. + ## -> len(0): Manually replaced w/fallback to guarantee >=len(1) + ## -> Fallback element is 'NONE'. + _old_items: list[ct.BLEnumElement] = self.bl_prop_enum_items.read( + bl_instance + ) + old_items = ( + _old_items + if _old_items + else default_enum_items(self.bl_prop.is_enum_many) + ) + + # Retrieve Current Items + ## -> len(0): Manually replaced w/fallback to guarantee >=len(1) + ## -> Manually replaced fallback element is 'NONE'. + _current_items: list[ct.BLEnumElement] = self.enum_cb(bl_instance, None) + current_items = ( + _current_items + if _current_items + else default_enum_items(self.bl_prop.is_enum_many) + ) + + # Compare Old | Current + ## -> We don't involve non-persistent caches (they lie!) + ## -> Since we persist the user callback directly, we can compare. + if old_items != current_items: + # Retrieve Old Enum Item + ## -> This is verbatim what is being used. + ## -> De-Coerce None -> 'NONE' to avoid special-cased search. + _old_item = self.bl_prop.read(bl_instance) + old_item = 'NONE' if _old_item is None else _old_item + + # Swap Enum Items + ## -> This is the hot stuff - the enum elements are overwritten. + ## -> The safe_enum_cb will pick up on this immediately. + self.suppress_next_update(bl_instance) + self.bl_prop_enum_items.write(bl_instance, current_items) + + # Old Item in Current Items + ## -> It's possible that the old enum key is in the new enum. + ## -> If so, the user will expect it to "remain". + ## -> Thus, we set it - Blender sees a change, user doesn't. + ## -> DO NOT trigger on_prop_changed (since "nothing changed"). + if any(old_item == item[0] for item in current_items): + self.suppress_next_update(bl_instance) + self.bl_prop.write(bl_instance, old_item) + ## -> TODO: Don't write if not needed. + + # Old Item Not in Current Items + ## -> In this case, fallback to the first current item. + ## -> DO trigger on_prop_changed (since it changed!) + else: + _first_current_item = current_items[0][0] + first_current_item = ( + _first_current_item if _first_current_item != 'NONE' else None + ) + + self.suppress_next_update(bl_instance) + self.bl_prop.write(bl_instance, first_current_item) + + if self.prop_info['use_prop_update']: + bl_instance.on_prop_changed(self.bl_prop.name) + + # Reset Str Search + elif value is Signal.ResetStrSearch: + self.bl_prop_str_search.invalidate_nonpersist(bl_instance) + + # General __set__ + else: + self.bl_prop.write(bl_instance, value) + + # Update Semantics + if self.suppress_update.get(bl_instance.instance_id): + self.suppress_update[bl_instance.instance_id] = False + + elif self.prop_info['use_prop_update']: + bl_instance.on_prop_changed(self.bl_prop.name) + + #################### + # - Safe Callbacks + #################### + def safe_str_cb( + self, _self: bl_instance.BLInstance, context: bpy.types.Context, edit_text: str + ): + """Wrapper around `StringProperty.search` which keeps a non-persistent cache around search results. + + Reset by setting the descriptor to `Signal.ResetStrSearch`. + """ + cached_items = self.bl_prop_str_search.read_nonpersist(_self) + if cached_items is not Signal.CacheNotReady: + if cached_items is Signal.CacheEmpty: + computed_items = self.str_cb(_self, context, edit_text) + self.bl_prop_str_search.write_nonpersist(_self, computed_items) + return computed_items + return cached_items + return [] + + def safe_enum_cb( + self, _self: bl_instance.BLInstance, context: bpy.types.Context + ) -> list[ct.BLEnumElement]: + """Wrapper around `EnumProperty.items` callback, which **guarantees** that returned strings will not be GCed by keeping a persistent + non-persistent cache. + + When a persistent cache exists, then the non-persistent cache will be filled at-will, since this is always guaranteed possible. + Otherwise, the persistent cache will only be regenerated when `Signal.ResetEnumItems` is run. + The original callback won't ever run other than then. + + Until then, `DEFAULT_ENUM_ITEMS_MANY` or `DEFAULT_ENUM_ITEMS_SINGLE` will be used as defaults (guaranteed to not dereference so long as the module is loaded). + """ + # Compute Value (if available) + cached_items = self.bl_prop_enum_items.read_nonpersist(_self) + if cached_items is Signal.CacheNotReady or cached_items is Signal.CacheEmpty: + if _self is not None: + persisted_items = self.bl_prop_enum_items.read(_self) + if not persisted_items: + computed_items = self.enum_cb(_self, context) + _items = computed_items + else: + _items = persisted_items + else: + computed_items = self.enum_cb(_self, context) + _items = computed_items + + # Fallback for Empty Persisted Items + ## -> Use [('NONE', ...)] + ## -> This guarantees that the enum items always has >=len(1) + items = _items if _items else default_enum_items(self.bl_prop.is_enum_many) + + # Write Items -> Non-Persistent Cache + self.bl_prop_enum_items.write_nonpersist(_self, items) + return items + return cached_items diff --git a/src/blender_maxwell/utils/bl_cache/bl_prop.py b/src/blender_maxwell/utils/bl_cache/bl_prop.py new file mode 100644 index 0000000..00c73c8 --- /dev/null +++ b/src/blender_maxwell/utils/bl_cache/bl_prop.py @@ -0,0 +1,235 @@ +# blender_maxwell +# Copyright (C) 2024 blender_maxwell Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Defines `BLProp`, a high-level wrapper for interacting with Blender properties.""" + +import dataclasses +import functools +import typing as typ + +from blender_maxwell.utils import bl_instance, logger + +from . import managed_cache +from .bl_prop_type import BLPropInfo, BLPropType +from .signal import Signal + +log = logger.get(__name__) + + +#################### +# - Blender Property (Abstraction) +#################### +@dataclasses.dataclass(kw_only=True, frozen=True) +class BLProp: + """A high-level wrapper encapsulating access to a Blender property. + + Attributes: + name: The name of the Blender property, as one uses it. + prop_info: Specifies the property's particular behavior, including subtype and UI. + prop_type: The type to associate with the property. + Especially relevant for structured deserialization. + bl_prop_type: Identifier encapsulating which Blender property used for data storage, and how. + """ + + name: str + prop_info: BLPropInfo ## TODO: Validate / Typing + prop_type: type + bl_prop_type: BLPropType + + #################### + # - Computed + #################### + @functools.cached_property + def bl_name(self): + """Deduces the actual attribute name at which the Blender property will be available.""" + return f'blfield__{self.name}' + + @functools.cached_property + def enum_cache_key(self): + """Deduces an attribute name for use by the persistent cache component of `EnumProperty.items`. + + For dynamic enums, a persistent cache is not enough - a non-persistent cache must also be used to guarantee that returned strings will not dereference. + **Letting dynamic enum strings dereference causes Blender to crash**. + + Use of a non-persistent cache alone introduces a massive startup burden, as _all_ of the potentially expensive `EnumProperty.items` methods must re-run. + Should any depend on ex. internet connectivity, which is no longer available, elaborate failure modes may trigger. + + By using this key, we can persist `items` for re-caching on startup, to reap the benefits of both schemes and make dynamic `EnumProperty` usable in practice. + """ + return self.name + '__enum_cache' + + @functools.cached_property + def str_cache_key(self): + """Deduce an internal name for string-search names distinct from the property name. + + Compared to dynamic enums, string-search names are very gentle. + However, the mechanism is otherwise almost same, so similar logic makes a lot of sense. + """ + return self.name + '__str_cache' + + @functools.cached_property + def display_name(self): + """Deduce a display name for the Blender property, assigned to the `name=` attribute.""" + return ( + '[JSON] ' if self.bl_prop_type == BLPropType.Serialized else '' + ) + f'BLField: {self.name}' + + @functools.cached_property + def is_enum_many(self): + return self.bl_prop_type in [BLPropType.SetEnum, BLPropType.SetDynEnum] + + #################### + # - Low-Level Methods + #################### + def encode(self, value: typ.Any): + """Encode a value for compatibility with this Blender property, using the encapsulated types. + + A convenience method for `BLPropType.encode()`. + """ + return self.bl_prop_type.encode(value) + + @functools.cached_property + def default_value(self) -> typ.Any: + return self.prop_info.get('default') + + def decode(self, value: typ.Any): + """Encode a value for compatibility with this Blender property, using the encapsulated types. + + A convenience method for `BLPropType.decode()`. + """ + return self.bl_prop_type.decode(value, self.prop_type) + + #################### + # - Initialization + #################### + def init_bl_type( + self, + bl_type: type[bl_instance.BLInstance], + depends_on: frozenset[str] = frozenset(), + enum_depends_on: frozenset[str] | None = None, + strsearch_depends_on: frozenset[str] | None = None, + ) -> None: + """Declare the Blender property on a Blender class, ensuring that the property will be available to all `bl_instance.BLInstance` respecting instances of that class. + + - **Declare BLField**: Runs `bl_type.declare_blfield()` to ensure that `on_prop_changed` will invalidate the cache for this property. + - **Set Property**: Runs `bl_type.set_prop()` to ensure that the Blender property will be available on instances of `bl_type`. + + Parameters: + obj_type: The exact object type that will be stored in the Blender property. + **Must** be chosen such that `BLPropType.from_type(obj_type) == self`. + """ + # Parse KWArgs for Blender Property + kwargs_prop = self.bl_prop_type.parse_kwargs( + self.prop_type, + self.prop_info, + ) + + # Set Blender Property + bl_type.declare_blfield( + self.name, + self.bl_name, + use_dynamic_enum=self.prop_info.get('enum_dynamic', False), + use_str_search=self.prop_info.get('str_search', False), + ) + bl_type.set_prop( + self.bl_name, + self.bl_prop_type.bl_prop, + # Property Options + name=self.display_name, + **kwargs_prop, + ) ## TODO: Parse __doc__ for property descs + + for src_prop_name in depends_on: + bl_type.declare_blfield_dep(src_prop_name, self.name) + + if self.prop_info.get('enum_dynamic', False) and enum_depends_on is not None: + for src_prop_name in enum_depends_on: + bl_type.declare_blfield_dep( + src_prop_name, self.name, method='reset_enum' + ) + + if self.prop_info.get('str_search', False) and strsearch_depends_on is not None: + for src_prop_name in strsearch_depends_on: + bl_type.declare_blfield_dep( + src_prop_name, self.name, method='reset_strsearch' + ) + + #################### + # - Instance Methods + #################### + def read_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> typ.Any: + """Read the non-persistent cache value for this property. + + Returns: + Generally, the cache value, with two exceptions. + + - `Signal.CacheNotReady`: When either `bl_instance` is None, or it doesn't yet have a unique `bl_instance.instance_id`. + Indicates that the instance is not yet ready for use. + For nodes, `init()` has not yet run. + For sockets, `preinit()` has not yet run. + + - `Signal.CacheEmpty`: When the cache has no entry. + A good idea might be to fill it immediately with `self.write_nonpersist(bl_instance)`. + """ + return managed_cache.read( + bl_instance, + self.bl_name, + use_nonpersist=True, + use_persist=False, + ) + + def read(self, bl_instance: bl_instance.BLInstance) -> typ.Any: + """Read the Blender property's particular value on the given `bl_instance`.""" + persisted_value = self.decode( + managed_cache.read( + bl_instance, + self.bl_name, + use_nonpersist=False, + use_persist=True, + ) + ) + if persisted_value is not Signal.CacheEmpty: + return persisted_value + + msg = f"{self.name}: Can't read BLProp from instance {bl_instance}" + raise ValueError(msg) + + def write(self, bl_instance: bl_instance.BLInstance, value: typ.Any) -> None: + managed_cache.write( + bl_instance, + self.bl_name, + self.encode(value), + use_nonpersist=False, + use_persist=True, + ) + self.write_nonpersist(bl_instance, value) + + def write_nonpersist( + self, bl_instance: bl_instance.BLInstance, value: typ.Any + ) -> None: + managed_cache.write( + bl_instance, + self.bl_name, + value, + use_nonpersist=True, + use_persist=False, + ) + + def invalidate_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> None: + managed_cache.invalidate_nonpersist( + bl_instance, + self.bl_name, + ) diff --git a/src/blender_maxwell/utils/bl_cache/bl_prop_type.py b/src/blender_maxwell/utils/bl_cache/bl_prop_type.py new file mode 100644 index 0000000..dd23ee1 --- /dev/null +++ b/src/blender_maxwell/utils/bl_cache/bl_prop_type.py @@ -0,0 +1,755 @@ +# blender_maxwell +# Copyright (C) 2024 blender_maxwell Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Defines `BLPropType`, which provides stronger lower-level interfaces for interacting with data that can be conformed to work with Blender properties.""" + +import builtins +import enum +import functools +import inspect +import pathlib +import typing as typ +from pathlib import Path + +import bpy +import numpy as np + +from blender_maxwell import contracts as ct +from blender_maxwell.utils import logger, serialize +from blender_maxwell.utils.staticproperty import staticproperty + +from .signal import Signal + +log = logger.get(__name__) + +#################### +# - Types +#################### +BLIDStructs = typ.get_args(ct.BLIDStruct) +Shape: typ.TypeAlias = None | tuple[int, ...] +BLPropInfo: typ.TypeAlias = dict[str, typ.Any] + + +@functools.cache +def _parse_vector_size(obj_type: type[tuple[int, ...]]) -> int: + """Parse the size of an arbitrarily sized generic tuple type, which is representing a vector. + + Parameters: + obj_type: The type of a flat, generic tuple integer, representing a static vector shape. + + Returns: + The length of any object that has the given type. + """ + return len(typ.get_args(obj_type)) + + +@functools.cache +def _parse_matrix_size(obj_type: type[tuple[int, ...], ...]) -> tuple[int, int]: + """Parse the rows and columns of an arbitrarily sized generic tuple-of-tuple type, which is representing a row-major matrix. + + Parameters: + obj_type: The type of a singly-nested, generic tuple integer, representing a static matrix shape. + + Returns: + The rows and columns of any object that has the given type. + """ + rows = len(typ.get_args(obj_type)) + cols = len(typ.get_args(typ.get_args(obj_type)[0])) + + for i, col_generic in enumerate(typ.get_args(obj_type)): + col_els = typ.get_args(col_generic) + if len(col_els) != cols: + msg = f'Value {obj_type} has mismatching column length {i} (to column 0)' + raise ValueError(msg) + + return (rows, cols) + + +def _is_strenum(T: type) -> bool: # noqa: N803 + return inspect.isclass(T) and issubclass(T, enum.StrEnum) + + +#################### +# - Blender Property Type +#################### +class BLPropType(enum.StrEnum): + """A type identifier which can be directly associated with a Blender property. + + For general use, the higher-level interface `BLProp` is more appropriate. + + This is a low-level interface to Blender properties, allowing for directly identifying and utilizing a subset of types that are trivially representable using Blender's property system. + This hard-coded approach is especially required when managing the nuances of UI methods. + + `BLPropType` should generally be treated as a "dumb" enum identifying the low-level representation of an object in a Blender property. + Use of `BLPropType.from_type` is encouraged; use of other methods is generally discouraged outside of higher-level encapsulating interfaces. + + + Attributes: + Bool: A boolean. + Int: An integer. + Float: A floating point number. + BoolVector: Between 2 and 32 booleans. + IntVector: Between 2 and 32 integers. + FloatVector: Between 2 and 32 floats. + BoolVector: 2D booleans of 2 - 32 elements per axis. + IntVector: 2D integers of 2 - 32 elements per axis. + FloatVector: 2D floats of 2 - 32 elements per axis. + Str: A simple string. + Path: A particular filesystem path. + SingleEnum: A single string value from a statically known `StrEnum`. + SetEnum: A set of string values, each from a statically known `StrEnum`. + SingleDynEnum: A single string value from a dynamically computed set of string values. + SetDynEnum: A set of string value, each from a dynamically computed set of string values. + BLPointer: A reference to a Blender object. + Blender manages correctly reconstructing this reference on startup, and the underlying pointer value is not stable. + Serialized: An arbitrary, serialized representation of an object. + """ + + # Scalar + Bool = enum.auto() + Int = enum.auto() + Float = enum.auto() + ## TODO: Support complex + + # Vector + BoolVector = enum.auto() + IntVector = enum.auto() + FloatVector = enum.auto() + + # Matrix + BoolMatrix = enum.auto() + IntMatrix = enum.auto() + FloatMatrix = enum.auto() + + ## TODO: Support jaxtyping JAX arrays (as serialized) directly? + + # String + Str = enum.auto() + Path = enum.auto() + ## TODO: OS checks for Path + + # Enums + SingleEnum = enum.auto() + SetEnum = enum.auto() + + SingleDynEnum = enum.auto() + SetDynEnum = enum.auto() + + # Special + BLPointer = enum.auto() + Serialized = enum.auto() + + #################### + # - Static + #################### + @staticproperty + def vector_types() -> frozenset[typ.Self]: + """The set of `BLPropType`s that are considered "vectors".""" + BPT = BLPropType + return frozenset({BPT.BoolVector, BPT.IntVector, BPT.FloatVector}) + + @staticproperty + def matrix_types() -> frozenset[typ.Self]: + """The set of `BLPropType`s that are considered "matrices".""" + BPT = BLPropType + return frozenset({BPT.BoolMatrix, BPT.IntMatrix, BPT.FloatMatrix}) + + #################### + # - Computed + #################### + @functools.cached_property + def is_vector(self) -> bool: + """Checks whether this `BLPropType` is considered a vector. + + Returns: + A boolean indicating "vectorness". + """ + return self in BLPropType.vector_types + + @functools.cached_property + def is_matrix(self) -> bool: + """Checks whether this `BLPropType` is considered a matrix. + + Returns: + A boolean indicating "matrixness". + """ + return self in BLPropType.matrix_types + + @functools.cached_property + def bl_prop(self) -> bpy.types.Property: + """Deduce which `bpy.props.*` type should implement this `BLPropType` in practice. + + In practice, `self.parse_kwargs()` collects arguments usable by the type returned by this property. + Thus, this property provides the key bridge between `BLPropType` and vanilla Blender properties. + + Returns: + A Blender property type, for use as a constructor. + """ + BPT = BLPropType + return { + # Scalar + BPT.Bool: bpy.props.BoolProperty, + BPT.Int: bpy.props.IntProperty, + BPT.Float: bpy.props.FloatProperty, + # Vector + BPT.BoolVector: bpy.props.BoolVectorProperty, + BPT.IntVector: bpy.props.IntVectorProperty, + BPT.FloatVector: bpy.props.FloatVectorProperty, + # Matrix + BPT.BoolMatrix: bpy.props.BoolVectorProperty, + BPT.IntMatrix: bpy.props.IntVectorProperty, + BPT.FloatMatrix: bpy.props.FloatVectorProperty, + # String + BPT.Str: bpy.props.StringProperty, + BPT.Path: bpy.props.StringProperty, + # Enum + BPT.SingleEnum: bpy.props.EnumProperty, + BPT.SetEnum: bpy.props.EnumProperty, + BPT.SingleDynEnum: bpy.props.EnumProperty, + BPT.SetDynEnum: bpy.props.EnumProperty, + # Special + BPT.BLPointer: bpy.props.PointerProperty, + BPT.Serialized: bpy.props.StringProperty, + }[self] + + @functools.cached_property + def primitive_type(self) -> type: + """The "primitive" type representable using this property. + + Generally, "primitive" types are Python standard library types. + However, exceptions may exist for a ubiquitously used type. + + Returns: + A type guaranteed to be representable as a Blender property via. `self.encode()`. + + Note that any relevant constraints on the type are not taken into account in this type. + For example, `SingleEnum` has `str`, even though all strings are not valid. + Similarly for ex. non-negative integers simply returning `int`. + """ + BPT = BLPropType + return { + # Scalar + BPT.Bool: bool, + BPT.Int: int, + BPT.Float: float, + # Vector + BPT.BoolVector: bool, + BPT.IntVector: int, + BPT.FloatVector: float, + # Matrix + BPT.BoolMatrix: bool, + BPT.IntMatrix: int, + BPT.FloatMatrix: float, + # String + BPT.Str: str, + BPT.Path: Path, + # Enum + BPT.SingleEnum: str, + BPT.SetEnum: set[str], + BPT.SingleDynEnum: str, + BPT.SetDynEnum: set[str], + # Special + BPT.BLPointer: None, + BPT.Serialized: str, + }[self] + + #################### + # - Parser Methods + #################### + def parse_size(self, obj_type: type) -> Shape: + """Retrieve the shape / shape of data associated with this `BLPropType`. + + Returns: + Vectors have `(size,)`. + Matrices have `(rows, cols)`. + + Otherwise, `None` indicates a single value/scalar. + """ + BPT = BLPropType + + match self: + case BPT.BoolVector | BPT.IntVector | BPT.FloatVector: + return _parse_vector_size(obj_type) + case BPT.BoolMatrix | BPT.IntMatrix | BPT.FloatMatrix: + return _parse_matrix_size(obj_type) + case _: + return None + + #################### + # - KWArg Parsers + #################### + @functools.cached_property + def required_info(self) -> list[str]: + """Retrieve a list of required keyword arguments to the constructor returned by `self.bl_prop`. + + Mainly useful via `self.check_info()`. + + Returns: + A list of required keys for the Blender property constructor. + """ + BPT = BLPropType + return { + # Scalar + BPT.Bool: ['default'], + BPT.Int: ['default'], + BPT.Float: ['default'], + # Vector + BPT.BoolVector: ['default'], + BPT.IntVector: ['default'], + BPT.FloatVector: ['default'], + # Matrix + BPT.BoolMatrix: ['default'], + BPT.IntMatrix: ['default'], + BPT.FloatMatrix: ['default'], + # String + BPT.Str: ['default', 'str_search'], + BPT.Path: ['default', 'path_type'], + # Enum + BPT.SingleEnum: ['default'], + BPT.SetEnum: ['default'], + BPT.SingleDynEnum: ['enum_dynamic'], + BPT.SetDynEnum: ['enum_dynamic'], + # Special + BPT.BLPointer: ['blptr_type'], + BPT.Serialized: [], + }[self] + + def check_info(self, prop_info: BLPropInfo) -> bool: + """Validate that a dictionary contains all required entries needed when creating a Blender property. + + Returns: + True if the provided dictionary is guaranteed to result in a valid Blender property when used as keyword arguments in the `self.bl_prop` constructor. + """ + return all( + required_info_key in prop_info for required_info_key in self.required_info + ) + + def parse_kwargs( # noqa: PLR0915, PLR0912, C901 + self, + obj_type: type, + prop_info: BLPropInfo, + ) -> BLPropInfo: + """Parse the kwargs dictionary used to construct the Blender property. + + Parameters: + obj_type: The exact object type that will be stored in the Blender property. + **Generally** should be chosen such that `BLPropType.from_type(obj_type) == self`. + prop_info: The property info. + **Must** contain keys such that `required_info` + + Returns: + Keyword arguments, which can be passed directly as to `self.bl_type` to construct a Blender property according to the `prop_info`. + + In total, creating a Blender property can be done simply using `self.bl_type(**parse_kwargs(...))`. + """ + BPT = BLPropType + + # Check Availability of Required Information + ## -> All required fields must be defined. + if not self.check_info(prop_info): + msg = f'{self} ({obj_type}): Required property attribute is missing from prop_info="{prop_info}"' + raise ValueError(msg) + + # Define Information -> KWArg Getter + def g_kwarg(name: str, force_key: str | None = None): + key = force_key if force_key is not None else name + return {key: prop_info[name]} if prop_info.get(name) is not None else {} + + # Encode Default Value + if prop_info.get('default', Signal.CacheEmpty) is not Signal.CacheEmpty: + encoded_default = {'default': self.encode(prop_info.get('default'))} + else: + encoded_default = {} + + # Assemble KWArgs + kwargs = {} + match self: + case BPT.Bool if obj_type is bool: + kwargs |= encoded_default + + case BPT.Int | BPT.IntVector | BPT.IntMatrix: + kwargs |= encoded_default + kwargs |= g_kwarg('abs_min') + kwargs |= g_kwarg('abs_max') + kwargs |= g_kwarg('soft_min') + kwargs |= g_kwarg('soft_max') + + case BPT.Float | BPT.FloatVector | BPT.FloatMatrix: + kwargs |= encoded_default + kwargs |= g_kwarg('abs_min') + kwargs |= g_kwarg('abs_max') + kwargs |= g_kwarg('soft_min') + kwargs |= g_kwarg('soft_max') + kwargs |= g_kwarg('step') + kwargs |= g_kwarg('precision') + + case BPT.Str if obj_type is str: + kwargs |= encoded_default + + # Str: Secret + if prop_info.get('str_secret'): + kwargs |= {'subtype': 'PASSWORD', 'options': {'SKIP_SAVE'}} + + # Str: Search + if prop_info.get('str_search'): + kwargs |= g_kwarg('safe_str_cb', force_key='search') + + case BPT.Path if obj_type is Path: + kwargs |= encoded_default + + # Path: File/Dir + if prop_info.get('path_type'): + kwargs |= { + 'subtype': ( + 'FILE_PATH' + if prop_info['path_type'] == 'file' + else 'DIR_PATH' + ) + } + + # Explicit Enums + case BPT.SingleEnum: + SubStrEnum = obj_type + + # Static | Dynamic Enum + ## -> Dynamic enums are responsible for respecting type. + if prop_info.get('enum_dynamic'): + kwargs |= g_kwarg('safe_enum_cb', force_key='items') + else: + kwargs |= encoded_default + kwargs |= { + 'items': [ + ## TODO: Parse __doc__ for item descs + ( + str(value), + SubStrEnum.to_name(value), + SubStrEnum.to_name(value), + SubStrEnum.to_icon(value), + i, + ) + for i, value in enumerate(list(obj_type)) + ] + } + + case BPT.SetEnum: + SubStrEnum = typ.get_args(obj_type)[0] + + # Enum Set: Use ENUM_FLAG option. + kwargs |= {'options': {'ENUM_FLAG'}} + + # Static | Dynamic Enum + ## -> Dynamic enums are responsible for respecting type. + if prop_info.get('enum_dynamic'): + kwargs |= g_kwarg('safe_enum_cb', force_key='items') + else: + kwargs |= encoded_default + kwargs |= { + 'items': [ + ## TODO: Parse __doc__ for item descs + ( + str(value), + SubStrEnum.to_name(value), + SubStrEnum.to_name(value), + SubStrEnum.to_icon(value), + 2**i, + ) + for i, value in enumerate(list(SubStrEnum)) + ] + } + + # Anonymous Enums + case BPT.SingleDynEnum: + kwargs |= g_kwarg('safe_enum_cb', force_key='items') + + case BPT.SetDynEnum: + kwargs |= g_kwarg('safe_enum_cb', force_key='items') + + # Enum Set: Use ENUM_FLAG option. + kwargs |= {'options': {'ENUM_FLAG'}} + + # BLPointer + case BPT.BLPointer: + kwargs |= encoded_default + + # BLPointer: ID Type + kwargs |= g_kwarg('blptr_type', force_key='type') + + # BLPointer + case BPT.Serialized: + kwargs |= encoded_default + + # Match Size + ## -> Matrices have inverted order to mitigate the Matrix Display Bug. + size = self.parse_size(obj_type) + if size is not None: + if self in [BPT.BoolVector, BPT.IntVector, BPT.FloatVector]: + kwargs |= {'size': size} + if self in [BPT.BoolMatrix, BPT.IntMatrix, BPT.FloatMatrix]: + kwargs |= {'size': size[::-1]} + + return kwargs + + #################### + # - Encode Value + #################### + def encode(self, value: typ.Any) -> typ.Any: # noqa: PLR0911 + """Transform a value to a form that can be directly written to a Blender property. + + Parameters: + value: A value which should be transformed into a form that can be written to the Blender property returned by `self.bl_type`. + + Returns: + A value that can be written directly to the Blender property returned by `self.bl_type`. + """ + BPT = BLPropType + match self: + # Scalars: Coerce Losslessly + ## -> We choose to be very strict, except for float.is_integer() -> int + case BPT.Bool if isinstance(value, bool): + return value + case BPT.Int if isinstance(value, int): + return value + case BPT.Int if isinstance(value, float) and value.is_integer(): + return int(value) + case BPT.Float if isinstance(value, int | float): + return float(value) + + # Vectors | Matrices: list() + ## -> We could use tuple(), but list() works just as fine when writing. + ## -> Later, we read back as tuple() to respect the type annotation. + ## -> Part of the workaround for the Matrix Display Bug happens here. + case BPT.BoolVector | BPT.IntVector | BPT.FloatVector: + return list(value) + case BPT.BoolMatrix | BPT.IntMatrix | BPT.FloatMatrix: + rows = len(value) + cols = len(value[0]) + return ( + np.array(value, dtype=self.primitive_type) + .flatten() + .reshape([cols, rows]) + ).tolist() + + # String + ## -> NOTE: This will happily encode StrEnums->str if an enum isn't requested. + case BPT.Str if isinstance(value, str): + return value + + # Path: Use Absolute-Resolved Stringified Path + ## -> TODO: Watch out for OS-dependence. + case BPT.Path if isinstance(value, Path): + return str(value.resolve()) + + # Empty Enums + ## -> Coerce None to 'NONE', since 'NONE' is injected by convention. + case ( + BPT.SingleEnum + | BPT.SetEnum + | BPT.SingleDynEnum + | BPT.SetDynEnum + ) if value is None: + return 'NONE' + + # Single Enum: Coerce to str + ## -> isinstance(StrEnum.Entry, str) always returns True; thus, a good sanity check. + ## -> Explicit/Dynamic both encode to str; only decode() coersion differentiates. + case BPT.SingleEnum | BPT.SingleDynEnum if isinstance(value, str): + return str(value) + + # Single Enum: Coerce to set[str] + case BPT.SetEnum | BPT.SetDynEnum if isinstance(value, set): + return {str(v) for v in value} + + # BLPointer: Don't Alter + case BPT.BLPointer if value in BLIDStructs or value is None: + return value + + # Serialized: Serialize To UTF-8 + ## -> TODO: Check serializability + case BPT.Serialized: + return serialize.encode(value).decode('utf-8') + + msg = f'{self}: No encoder defined for argument {value}' + raise NotImplementedError(msg) + + #################### + # - Decode Value + #################### + def decode(self, raw_value: typ.Any, obj_type: type) -> typ.Any: # noqa: PLR0911 + """Transform a raw value from a form read directly from the Blender property returned by `self.bl_type`, to its intended value of approximate type `obj_type`. + + Notes: + `obj_type` is only a hint - for example, `obj_type = enum.StrEnum` is an indicator for a dynamic enum. + Its purpose is to guide ex. sizing and `StrEnum` coersion, not to guarantee a particular output type. + + Parameters: + value: A value which should be transformed into a form that can be written to the Blender property returned by `self.bl_type`. + + Returns: + A value that can be written directly to the Blender property returned by `self.bl_type`. + """ + BPT = BLPropType + match self: + # Scalars: Inverse Coerce (~Losslessly) + ## -> We choose to be very strict, except for float.is_integer() -> int + case BPT.Bool if isinstance(raw_value, bool): + return raw_value + case BPT.Int if isinstance(raw_value, int): + return raw_value + case BPT.Int if isinstance(raw_value, float) and raw_value.is_integer(): + return int(raw_value) + case BPT.Float if isinstance(raw_value, float): + return float(raw_value) + + # Vectors | Matrices: tuple() to match declared type annotation. + ## -> Part of the workaround for the Matrix Display Bug happens here. + case BPT.BoolVector | BPT.IntVector | BPT.FloatVector: + return tuple(raw_value) + case BPT.BoolMatrix | BPT.IntMatrix | BPT.FloatMatrix: + rows, cols = self.parse_size(obj_type) + return tuple( + map(tuple, np.array(raw_value).flatten().reshape([rows, cols])) + ) + + # String + ## -> NOTE: This will happily decode StrEnums->str if an enum isn't requested. + case BPT.Str if isinstance(raw_value, str): + return raw_value + + # Path: Use 'Path(abspath(*))' + ## -> TODO: Watch out for OS-dependence. + case BPT.Path if isinstance(raw_value, str): + return Path(bpy.path.abspath(raw_value)) + + # Empty Enums + ## -> Coerce 'NONE' to None, since 'NONE' is injected by convention. + ## -> Using coerced 'NONE' as guaranteed len=0 element is extremely helpful. + case ( + BPT.SingleEnum + | BPT.SetEnum + | BPT.SingleDynEnum + | BPT.SetDynEnum + ) if raw_value in ['NONE']: + return None + + # Explicit Enum: Coerce to predefined StrEnum + ## -> This happens independent of whether there's a enum_cb. + case BPT.SingleEnum if isinstance(raw_value, str): + return obj_type(raw_value) + case BPT.SetEnum if isinstance(raw_value, set): + return {obj_type(v) for v in raw_value} + + ## Dynamic Enums: Nothing to coerce to. + ## -> The critical distinction is that dynamic enums can't be coerced beyond str. + ## -> All dynamic enums have an enum_cb, but this is merely a symptom of ^. + case BPT.SingleDynEnum if isinstance(raw_value, str): + return raw_value + case BPT.SetDynEnum if isinstance(raw_value, set): + return raw_value + + # BLPointer + ## -> None is always valid when it comes to BLPointers. + case BPT.BLPointer if raw_value in BLIDStructs or raw_value is None: + return raw_value + + # Serialized: Deserialize the Argument + case BPT.Serialized: + return serialize.decode(obj_type, raw_value) + + msg = f'{self}: No decoder defined for argument {raw_value}' + raise NotImplementedError(msg) + + #################### + # - Parse Type + #################### + @staticmethod + def from_type(obj_type: type) -> typ.Self: # noqa: PLR0911, PLR0912, C901 + """Select an appropriate `BLPropType` to store objects of the given type. + + Use of this method is especially handy when attempting to represent arbitrary, type-annotated objects using Blender properties. + For example, the ability of the `BLPropType` to be displayed in a UI is prioritized as much as possible in making this decision. + + Parameters: + obj_type: A type like `bool`, `str`, or custom classes. + + Returns: + A `BLPropType` capable of storing any object of `obj_type`. + """ + BPT = BLPropType + + # Match Simple + match obj_type: + case builtins.bool: + return BPT.Bool + case builtins.int: + return BPT.Int + case builtins.float: + return BPT.Float + case builtins.str: + return BPT.Str + case pathlib.Path: + return BPT.Path + case enum.StrEnum: + return BPT.SingleDynEnum + case _: + pass + + # Match Arrays + ## -> This deconstructs generic statements like ex. tuple[int, int] + typ_origin = typ.get_origin(obj_type) + typ_args = typ.get_args(obj_type) + if typ_origin is tuple and len(typ_args) > 0: + # Match Vectors + ## -> ONLY respect homogeneous types + if all(T is bool for T in typ_args): + return BPT.BoolVector + if all(T is int for T in typ_args): + return BPT.IntVector + if all(T is float for T in typ_args): + return BPT.FloatVector + + # Match Matrices + ## -> ONLY respect twice-nested homogeneous types + ## -> TODO: Explicitly require regularized shape, as in _parse_matrix_size + typ_args_args = [typ_arg for T0 in typ_args for typ_arg in typ.get_args(T0)] + if typ_args_args: + if all(T is bool for T in typ_args_args): + return BPT.BoolMatrix + if all(T is int for T in typ_args_args): + return BPT.IntMatrix + if all(T is float for T in typ_args_args): + return BPT.FloatMatrix + + # Match SetDynEnum + ## -> We can't do this in the match statement + if obj_type == set[enum.StrEnum]: + return BPT.SetDynEnum + + # Match Static Enums + ## -> Match Single w/Helper Function + if _is_strenum(obj_type): + return BPT.SingleEnum + + ## -> Match Set w/Helper Function + if typ_origin is set and len(typ_args) == 1 and _is_strenum(typ_args[0]): + return BPT.SetEnum + + # Match BLPointers + if obj_type in BLIDStructs: + return BPT.BLPointer + + # Fallback: Serializable Object + ## -> TODO: Check serializability. + return BPT.Serialized diff --git a/src/blender_maxwell/utils/bl_cache/cached_bl_property.py b/src/blender_maxwell/utils/bl_cache/cached_bl_property.py new file mode 100644 index 0000000..cc60a5d --- /dev/null +++ b/src/blender_maxwell/utils/bl_cache/cached_bl_property.py @@ -0,0 +1,246 @@ +# blender_maxwell +# Copyright (C) 2024 blender_maxwell Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Implements various key caches on instances of Blender objects, especially nodes and sockets.""" + +import inspect +import typing as typ + +from blender_maxwell.utils import bl_instance, logger, serialize + +from .bl_prop import BLProp +from .bl_prop_type import BLPropType +from .signal import Signal + +log = logger.get(__name__) + +#################### +# - Types +#################### +PropGetMethod: typ.TypeAlias = typ.Callable[ + [bl_instance.BLInstance], serialize.NaivelyEncodableType +] +PropSetMethod: typ.TypeAlias = typ.Callable[ + [bl_instance.BLInstance, serialize.NaivelyEncodableType], None +] + + +#################### +# - CachedBLProperty +#################### +class CachedBLProperty: + """A descriptor that caches a computed attribute of a Blender node/socket/... instance (`bl_instance`). + + Generally used via the associated decorator, `cached_bl_property`. + + Notes: + It's like `@cached_property`, but on each Blender Instance ID. + + Attributes: + getter_method: Method of `bl_instance` that computes the value. + setter_method: Method of `bl_instance` that sets the value. + """ + + def __init__( + self, + getter_method: PropGetMethod, + persist: bool = False, + depends_on: frozenset[str] = frozenset(), + ): + """Initialize the getter of the cached property. + + Parameters: + getter_method: Method of `bl_instance` that computes the value. + """ + self.getter_method: PropGetMethod = getter_method + self.setter_method: PropSetMethod | None = None + + self.persist: bool = persist + self.depends_on: frozenset[str] = depends_on + + self.bl_prop: BLProp | None = None + + self.decode_type: type = inspect.signature(getter_method).return_annotation + + # Check Non-Empty Type Annotation + ## For now, just presume that all types can be encoded/decoded. + if self.decode_type is inspect.Signature.empty: + msg = f'A CachedBLProperty was instantiated, but its getter method "{self.getter_method}" has no return type annotation' + raise TypeError(msg) + + def __set_name__(self, owner: type[bl_instance.BLInstance], name: str) -> None: + """Generates the property name from the name of the attribute that this descriptor is assigned to. + + Notes: + - Run by Python when setting an instance of this class to an attribute. + + Parameters: + owner: The class that contains an attribute assigned to an instance of this descriptor. + name: The name of the attribute that an instance of descriptor was assigned to. + """ + self.bl_prop = BLProp( + name=name, + prop_info={'use_prop_update': True}, + prop_type=self.decode_type, + bl_prop_type=BLPropType.Serialized, + ) + self.bl_prop.init_bl_type(owner, depends_on=self.depends_on) + + def __get__( + self, + bl_instance: bl_instance.BLInstance | None, + owner: type[bl_instance.BLInstance], + ) -> typ.Any: + """Retrieves the property from a cache, or computes it and fills the cache(s). + + Parameters: + bl_instance: The Blender object this prop + """ + cached_value = self.bl_prop.read_nonpersist(bl_instance) + if cached_value is Signal.CacheNotReady or cached_value is Signal.CacheEmpty: + if bl_instance is not None: + if self.persist: + value = self.bl_prop.read(bl_instance) + else: + value = self.getter_method(bl_instance) + + self.bl_prop.write_nonpersist(bl_instance, value) + return value + return Signal.CacheNotReady + return cached_value + + def __set__( + self, bl_instance: bl_instance.BLInstance | None, value: typ.Any + ) -> None: + """Runs the user-provided setter, after invalidating the caches. + + Notes: + - This invalidates all caches without re-filling them. + - The caches will be re-filled on the first `__get__` invocation, which may be slow due to having to run the getter method. + + Parameters: + bl_instance: The Blender object this prop + """ + if value is Signal.DoUpdate: + bl_instance.on_prop_changed(self.bl_prop.name) + + elif value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate: + # Invalidate Partner Non-Persistent Caches + ## -> Only for the invalidation case do we also invalidate partners. + if bl_instance is not None: + # Fill Caches + ## -> persist: Fill Persist and Non-Persist Cache + ## -> else: Fill Non-Persist Cache + if self.persist: + self.bl_prop.write(bl_instance, self.getter_method(bl_instance)) + + else: + self.bl_prop.write_nonpersist( + bl_instance, self.getter_method(bl_instance) + ) + + if value == Signal.InvalidateCache: + bl_instance.on_prop_changed(self.bl_prop.name) + + elif self.setter_method is not None: + # Run Setter + ## -> The user-provided setter should do any updating of partners. + if self.setter_method is not None: + self.setter_method(bl_instance, value) + + # Fill Non-Persistant (and maybe Persistent) Cache + if self.persist: + self.bl_prop.write(bl_instance, self.getter_method(bl_instance)) + + else: + self.bl_prop.write_nonpersist( + bl_instance, self.getter_method(bl_instance) + ) + bl_instance.on_prop_changed(self.bl_prop.name) + + else: + msg = f'Tried to set "{value}" to "{self.prop_name}" on "{bl_instance.bl_label}", but a setter was not defined' + raise NotImplementedError(msg) + + def setter(self, setter_method: PropSetMethod) -> typ.Self: + """Decorator to add a setter to the cached property. + + Returns: + The same descriptor, so that use of the same method name for defining a setter won't change the semantics of the attribute. + + Examples: + Without the decorator, it looks like this: + ```python + class Test(bpy.types.Node): + bl_label = 'Default' + ... + def method(self) -> str: return self.bl_label + attr = CachedBLProperty(getter_method=method) + + @attr.setter + def attr(self, value: str) -> None: + self.bl_label = 'Altered' + ``` + """ + # Validate Setter Signature + setter_sig = inspect.signature(setter_method) + + ## Parameter Length + if (sig_len := len(setter_sig.parameters)) != 2: # noqa: PLR2004 + msg = f'Setter method for "{self.prop_name}" should have 2 parameters, not "{sig_len}"' + raise TypeError(msg) + + ## Parameter Value Type + if (sig_ret_type := setter_sig.return_annotation) is not None: + msg = f'Setter method for "{self.prop_name}" return value type "{sig_ret_type}", but it should be "None" (omitting an annotation does not imply "None")' + raise TypeError(msg) + + self.setter_method = setter_method + return self + + +#################### +# - Decorator +#################### +def cached_bl_property( + persist: bool = False, + depends_on: frozenset[str] = frozenset(), +): + """Decorator creating a descriptor that caches a computed attribute of a Blender node/socket. + + Many such `bl_instance`s rely on fast access to computed, cached properties, for example to ensure that `draw()` remains effectively non-blocking. + + Notes: + - Unfortunately, `functools.cached_property` doesn't work. + - Use `cached_attribute` if not using a node/socket. + + Examples: + ```python + class CustomNode(bpy.types.Node): + @bl_cache.cached() + def computed_prop(self) -> ...: return ... + + print(bl_instance.prop) ## Computes first time + print(bl_instance.prop) ## Cached (after restart, will recompute) + ``` + """ + + def decorator(getter_method: typ.Callable[[bl_instance.BLInstance], None]) -> type: + return CachedBLProperty( + getter_method=getter_method, persist=persist, depends_on=depends_on + ) + + return decorator diff --git a/src/blender_maxwell/utils/bl_cache/keyed_cache.py b/src/blender_maxwell/utils/bl_cache/keyed_cache.py new file mode 100644 index 0000000..d1b8758 --- /dev/null +++ b/src/blender_maxwell/utils/bl_cache/keyed_cache.py @@ -0,0 +1,146 @@ +# blender_maxwell +# Copyright (C) 2024 blender_maxwell Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import functools +import inspect +import typing as typ + +from blender_maxwell.utils import bl_instance, logger, serialize + +log = logger.get(__name__) + + +class KeyedCache: + def __init__( + self, + func: typ.Callable, + exclude: set[str], + encode: set[str], + ): + # Function Information + self.func: typ.Callable = func + self.func_sig: inspect.Signature = inspect.signature(self.func) + + # Arg -> Key Information + self.exclude: set[str] = exclude + self.include: set[str] = set(self.func_sig.parameters.keys()) - exclude + self.encode: set[str] = encode + + # Cache Information + self.key_schema: tuple[str, ...] = tuple( + [ + arg_name + for arg_name in self.func_sig.parameters + if arg_name not in exclude + ] + ) + self.caches: dict[str | None, dict[tuple[typ.Any, ...], typ.Any]] = {} + + @property + def is_method(self): + return 'self' in self.exclude + + def cache(self, instance_id: str | None) -> dict[tuple[typ.Any, ...], typ.Any]: + if self.caches.get(instance_id) is None: + self.caches[instance_id] = {} + + return self.caches[instance_id] + + def _encode_key(self, arguments: dict[str, typ.Any]): + ## WARNING: Order of arguments matters. Arguments may contain 'exclude'd elements. + return tuple( + [ + ( + arg_value + if arg_name not in self.encode + else serialize.encode(arg_value) + ) + for arg_name, arg_value in arguments.items() + if arg_name in self.include + ] + ) + + def __get__( + self, + bl_instance: bl_instance.BLInstance | None, + owner: type[bl_instance.BLInstance], + ) -> typ.Callable: + _func = functools.partial(self, bl_instance) + _func.invalidate = functools.partial( + self.__class__.invalidate, self, bl_instance + ) + return _func + + def __call__(self, *args, **kwargs): + # Test Argument Bindability to Decorated Function + try: + bound_args = self.func_sig.bind(*args, **kwargs) + except TypeError as ex: + msg = f'Can\'t bind arguments (args={args}, kwargs={kwargs}) to @keyed_cache-decorated function "{self.func.__name__}" (signature: {self.func_sig})"' + raise ValueError(msg) from ex + + # Check that Parameters for Keying the Cache are Available + bound_args.apply_defaults() + all_arg_keys = set(bound_args.arguments.keys()) + if not self.include <= (all_arg_keys - self.exclude): + msg = f'Arguments spanning the keyed cached ({self.include}) are not available in the non-excluded arguments passed to "{self.func.__name__}": {all_arg_keys - self.exclude}' + raise ValueError(msg) + + # Create Keyed Cache Entry + key = self._encode_key(bound_args.arguments) + cache = self.cache(args[0].instance_id if self.is_method else None) + if (value := cache.get(key)) is None: + value = self.func(*args, **kwargs) + cache[key] = value + + return value + + def invalidate( + self, + bl_instance: bl_instance.BLInstance | None, + **arguments: dict[str, typ.Any], + ) -> dict[str, typ.Any]: + # Determine Wildcard Arguments + wildcard_arguments = { + arg_name for arg_name, arg_value in arguments.items() if arg_value is ... + } + + # Compute Keys to Invalidate + arguments_hashable = { + arg_name: serialize.encode(arg_value) + if arg_name in self.encode and arg_name not in wildcard_arguments + else arg_value + for arg_name, arg_value in arguments.items() + } + cache = self.cache(bl_instance.instance_id if self.is_method else None) + for key in list(cache.keys()): + if all( + arguments_hashable.get(arg_name) == arg_value + for arg_name, arg_value in zip(self.key_schema, key, strict=True) + if arg_name not in wildcard_arguments + ): + cache.pop(key) + + +def keyed_cache(exclude: set[str], encode: set[str] = frozenset()) -> typ.Callable: + def decorator(func: typ.Callable) -> typ.Callable: + return KeyedCache( + func, + exclude=exclude, + encode=encode, + ) + + return decorator diff --git a/src/blender_maxwell/utils/bl_cache/managed_cache.py b/src/blender_maxwell/utils/bl_cache/managed_cache.py new file mode 100644 index 0000000..ae9f6ea --- /dev/null +++ b/src/blender_maxwell/utils/bl_cache/managed_cache.py @@ -0,0 +1,171 @@ +# blender_maxwell +# Copyright (C) 2024 blender_maxwell Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Implements various key caches on instances of Blender objects, especially nodes and sockets.""" + +## TODO: Note that persist=True on cached_bl_property may cause a draw method to try and write to a Blender property, which Blender disallows. + +import typing as typ + +from blender_maxwell import contracts as ct +from blender_maxwell.utils import bl_instance, logger + +from .signal import Signal + +log = logger.get(__name__) + + +#################### +# - Global Variables +#################### +_CACHE_NONPERSIST: dict[bl_instance.InstanceID, dict[typ.Hashable, typ.Any]] = {} + + +#################### +# - Create/Invalidate +#################### +def bl_instance_nonpersist_cache( + bl_instance: bl_instance.BLInstance, +) -> dict[typ.Hashable, typ.Any]: + """Retrieve the non-persistent cache of a BLInstance.""" + # Create Non-Persistent Cache Entry + ## Prefer explicit cache management to 'defaultdict' + if _CACHE_NONPERSIST.get(bl_instance.instance_id) is None: + _CACHE_NONPERSIST[bl_instance.instance_id] = {} + + return _CACHE_NONPERSIST[bl_instance.instance_id] + + +def invalidate_nonpersist_instance_id(instance_id: bl_instance.InstanceID) -> None: + """Invalidate any `instance_id` that might be utilizing cache space in `_CACHE_NONPERSIST`. + + Notes: + This should be run by the `instance_id` owner in its `free()` method. + + Parameters: + instance_id: The ID of the Blender object instance that's being freed. + """ + _CACHE_NONPERSIST.pop(instance_id, None) + + +#################### +# - Access +#################### +def read( + bl_instance: bl_instance.BLInstance | None, + key: typ.Hashable, + use_nonpersist: bool = True, + use_persist: bool = False, +) -> typ.Any | typ.Literal[Signal.CacheNotReady, Signal.CacheEmpty]: + """Read the cache associated with a Blender Instance, without writing to it. + + Attributes: + key: The name to read from the instance-specific cache. + use_nonpersist: If true, will always try the non-persistent cache first. + use_persist: If true, will always try accessing the attribute `bl_instance,key`, where `key` is the value of the same-named parameter. + Generally, such an attribute should be a `bpy.types.Property`. + + Return: + The cache hit, if any; else `Signal.CacheEmpty`. + """ + # Check BLInstance Readiness + if bl_instance is None: + return Signal.CacheNotReady + + # Try Hit on Persistent Cache + if use_persist: + value = getattr(bl_instance, key, Signal.CacheEmpty) + if value is not Signal.CacheEmpty: + return value + + # Check if Instance ID is Available + if not bl_instance.instance_id: + log.debug( + "Can't Get CachedBLProperty: Instance ID not (yet) defined on bl_instance.BLInstance %s", + str(bl_instance), + ) + return Signal.CacheNotReady + + # Try Hit on Non-Persistent Cache + if use_nonpersist: + cache_nonpersist = bl_instance_nonpersist_cache(bl_instance) + value = cache_nonpersist.get(key, Signal.CacheEmpty) + if value is not Signal.CacheEmpty: + return value + + return Signal.CacheEmpty + + +def write( + bl_instance: bl_instance.BLInstance, + key: typ.Hashable, + value: typ.Any, ## TODO: "Serializable" type + use_nonpersist: bool = True, + use_persist: bool = False, +) -> None: + """Write to the cache associated with a Blender Instance. + + Attributes: + key: The name to write to the instance-specific cache. + use_nonpersist: If true, will always write to the non-persistent cache first. + use_persist: If true, will always write to attribute `bl_instance.key`, where `key` is the value of the same-named parameter. + Generally, such an attribute should be a `bpy.types.Property`. + call_on_prop_changed: Whether to trigger `bl_instance.on_prop_changed()` with the + """ + # Check BLInstance Readiness + if bl_instance is None: + return + + # Try Write on Persistent Cache + if use_persist: + # log.critical('%s: Writing %s to %s.', str(bl_instance), str(value), str(key)) + setattr(bl_instance, key, value) + + if not bl_instance.instance_id: + log.debug( + "Can't Get CachedBLProperty: Instance ID not (yet) defined on bl_instance.BLInstance %s", + str(bl_instance), + ) + return + + # Try Write on Non-Persistent Cache + if use_nonpersist: + cache_nonpersist = bl_instance_nonpersist_cache(bl_instance) + cache_nonpersist[key] = value + + +def invalidate_nonpersist( + bl_instance: bl_instance.BLInstance, + key: typ.Hashable, +) -> None: + """Invalidate a particular key of the non-persistent cache. + + **Persistent caches can't be invalidated without writing to them**. + To get the same effect, consider using `write()` to write its default value (which must be manually tracked). + """ + # Check BLInstance Readiness + if bl_instance is None: + return + if not bl_instance.instance_id: + log.debug( + "Can't Get CachedBLProperty: Instance ID not (yet) defined on bl_instance.BLInstance %s", + str(bl_instance), + ) + return + + # Retrieve Non-Persistent Cache + cache_nonpersist = bl_instance_nonpersist_cache(bl_instance) + cache_nonpersist.pop(key, None) diff --git a/src/blender_maxwell/utils/bl_cache/signal.py b/src/blender_maxwell/utils/bl_cache/signal.py new file mode 100644 index 0000000..08c337d --- /dev/null +++ b/src/blender_maxwell/utils/bl_cache/signal.py @@ -0,0 +1,61 @@ +# blender_maxwell +# Copyright (C) 2024 blender_maxwell Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import enum +import uuid + + +class Signal(enum.StrEnum): + """A value used to signal the descriptor via its `__set__`. + + Such a signal **must** be entirely unique: Even a well-thought-out string could conceivably produce a very nasty bug, where instead of setting a descriptor-managed attribute, the user would inadvertently signal the descriptor. + + To make it effectively impossible to confuse any other object whatsoever with a signal, the enum values are set to per-session `uuid.uuid4()`. + + Notes: + **Do not** use this enum for anything other than directly signalling a `bl_cache` descriptor via its setter. + + **Do not** store this enum `Signal` in a variable or method binding that survives longer than the session. + + **Do not** persist this enum; the values will change whenever `bl_cache` is (re)loaded. + + Attributes: + CacheNotReady: The cache isn't yet ready to be used. + Generally, this is because the `BLInstance` isn't made yet. + CacheEmpty: The cache has no information to offer. + + InvalidateCache: The cache should be invalidated. + InvalidateCacheNoUpdate: The cache should be invalidated, but no update method should be run. + DoUpdate: Any update method that the cache triggers on change should be run. + An update is **not guaranteeed** to be run, merely requested. + + ResetEnumItems: Cached dynamic enum items should be recomputed on next use. + ResetStrSearch: Cached string-search items should be recomputed on next use. + """ + + # Cache Management + CacheNotReady: str = str(uuid.uuid4()) + CacheEmpty: str = str(uuid.uuid4()) + + # Invalidation + InvalidateCache: str = str(uuid.uuid4()) + InvalidateCacheNoUpdate: str = str(uuid.uuid4()) + DoUpdate: str = str(uuid.uuid4()) + + # Reset Signals + ## -> Invalidates data adjascent to fields. + ResetEnumItems: str = str(uuid.uuid4()) + ResetStrSearch: str = str(uuid.uuid4()) diff --git a/src/blender_maxwell/utils/bl_instance.py b/src/blender_maxwell/utils/bl_instance.py new file mode 100644 index 0000000..2c8e40d --- /dev/null +++ b/src/blender_maxwell/utils/bl_instance.py @@ -0,0 +1,299 @@ +# blender_maxwell +# Copyright (C) 2024 blender_maxwell Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import typing as typ +import uuid +from types import MappingProxyType + +import bpy + +from blender_maxwell.utils import bl_cache, logger + +InstanceID: typ.TypeAlias = str ## Stringified UUID4 + +log = logger.get(__name__) + + +class BLInstance: + """An instance of a blender object, ex. nodes/sockets. + + Used as a common base of functionality for nodes/sockets, especially when it comes to the magic introduced by `bl_cache`. + + Notes: + All the `@classmethod`s are designed to be invoked with `cls` as the subclass of `BLInstance`, not `BLInstance` itself. + + For practical reasons, introducing a metaclass here is not a good idea, and thus `abc.ABC` can't be used. + To this end, only `self.on_prop_changed` needs a subclass implementation. + It's a little sharp, but managable. + + Inheritance schemes like this are generally not enjoyable. + However, the way Blender's node/socket classes are structured makes it the most practical way design for the functionality encapsulated here. + + Attributes: + instance_id: Stringified UUID4 that uniquely identifies an instance, among all active instances on all active classes. + """ + + #################### + # - Attributes + #################### + instance_id: bpy.props.StringProperty(default='') + + blfields: typ.ClassVar[dict[str, str]] = MappingProxyType({}) + blfield_deps: typ.ClassVar[dict[str, list[str]]] = MappingProxyType({}) + + blfields_dynamic_enum: typ.ClassVar[set[str]] = frozenset() + blfield_dynamic_enum_deps: typ.ClassVar[dict[str, list[str]]] = MappingProxyType({}) + + blfields_str_search: typ.ClassVar[set[str]] = frozenset() + blfield_str_search_deps: typ.ClassVar[dict[str, list[str]]] = MappingProxyType({}) + + #################### + # - Runtime Instance Management + #################### + def reset_instance_id(self) -> None: + """Reset the Instance ID of a BLInstance. + + The Instance ID is used to index the instance-specific cache, since Blender doesn't always directly support keeping changing data on node/socket instances. + + Notes: + Should be run whenever the instance is copied, so that the copy will index its own cache. + + The Instance ID is a `UUID4`, which is globally unique, negating the need for extraneous overlap-checks. + """ + self.instance_id = str(uuid.uuid4()) + self.regenerate_dynamic_field_persistance() + + @classmethod + def assert_attrs_valid(cls, mandatory_props: set[str]) -> None: + """Asserts that all mandatory attributes are defined on the class. + + The list of mandatory objects is generally sourced from a global variable, `MANDATORY_PROPS`, which should be passed to this function while running `__init_subclass__`. + + Raises: + ValueError: If a mandatory attribute defined in `base.MANDATORY_PROPS` is not defined on the class. + """ + for cls_attr in mandatory_props: + if not hasattr(cls, cls_attr): + msg = f'Node class {cls} does not define mandatory attribute "{cls_attr}".' + raise ValueError(msg) + + #################### + # - Field Registration + #################### + @classmethod + def declare_blfield( + cls, + attr_name: str, + bl_attr_name: str, + use_dynamic_enum: bool = False, + use_str_search: bool = False, + ) -> None: + """Declare the existance of a (cached) field and any properties affecting its invalidation. + + Primarily, the `attr_name -> bl_attr_name` map will be available via the `cls.blfields` dictionary. + Thus, for use in UIs (where `bl_attr_name` must be used), one can use `cls.blfields[attr_name]`. + + Parameters: + attr_name: The name of the attribute accessible via the instance. + bl_attr_name: The name of the attribute containing the Blender property. + This is used both as a persistant cache for `attr_name`, as well as (possibly) the data altered by the user from the UI. + use_dynamic_enum: Will mark `attr_name` as a dynamic enum. + Allows `self.regenerate_dynamic_field_persistance` to reset this property, whenever all dynamic `EnumProperty`s are reset at once. + use_str_searc: The name of the attribute containing the Blender property. + Allows `self.regenerate_dynamic_field_persistance` to reset this property, whenever all searched `StringProperty`s are reset at once. + """ + cls.blfields = cls.blfields | {attr_name: bl_attr_name} + + if use_dynamic_enum: + cls.blfields_dynamic_enum = cls.blfields_dynamic_enum | {attr_name} + + if use_str_search: + cls.blfields_str_search = cls.blfields_str_search | {attr_name} + + @classmethod + def declare_blfield_dep( + cls, + src_prop_name: str, + dst_prop_name: str, + method: typ.Literal[ + 'invalidate', 'reset_enum', 'reset_strsearch' + ] = 'invalidate', + ) -> None: + """Declare that `prop_name` relies on another property. + + This is critical for cached, computed properties that must invalidate their cache whenever any of the data they rely on changes. + In practice, a chain of invalidation emerges naturally when this is put together, managed internally for performance. + + Notes: + If the relevant `*_deps` dictionary is not defined on `cls`, we manually create it. + This shadows the relevant `BLInstance` attribute, which is an immutable `MappingProxyType` on purpose, precisely to prevent the situation of altering data that shouldn't be common to all classes inheriting from `BLInstance`. + + Not clean, but it works. + + Parameters: + dep_prop_name: The property that should, whenever changed, also invalidate the cache of `prop_name`. + prop_name: The property that relies on another property. + """ + match method: + case 'invalidate': + if not cls.blfield_deps: + cls.blfield_deps = {} + deps = cls.blfield_deps + case 'reset_enum': + if not cls.blfield_dynamic_enum_deps: + cls.blfield_dynamic_enum_deps = {} + deps = cls.blfield_dynamic_enum_deps + case 'reset_strsearch': + if not cls.blfield_str_search_deps: + cls.blfield_str_search_deps = {} + deps = cls.blfield_str_search_deps + + if deps.get(src_prop_name) is None: + deps[src_prop_name] = [] + + deps[src_prop_name].append(dst_prop_name) + + @classmethod + def set_prop( + cls, + bl_prop_name: str, + prop: bpy.types.Property, + **kwargs, + ) -> None: + """Adds a Blender property via `__annotations__`, so that it will be initialized on all subclasses. + + **All Blender properties trigger an update method** when updated from the UI, in order to invalidate the non-persistent cache of the associated `BLField`. + Specifically, this behavior happens in `on_bl_prop_changed()`. + + However, whether anything else happens after that invalidation is entirely up to the particular `BLField`. + Thus, `BLField` is put in charge of how/when updates occur. + + Notes: + In general, Blender properties can't be set on classes directly. + They must be added as type annotations, which Blender will read and understand. + + This is essentially a convenience method to encapsulate this unexpected behavior, as well as constrain the behavior of the `update` method somewhat. + + Parameters: + bl_prop_name: The name of the property to set, as accessible from Blender. + Generally, from code, the user would access the wrapping `BLField` instead of directly accessing the `bl_prop_name` attribute. + prop: The `bpy.types.Property` to instantiate and attach.. + kwargs: Constructor arguments to pass to the Blender property. + There are many mostly-documented nuances with these. + The methods of `bl_cache.BLPropType` are designed to provide more strict, helpful abstractions for practical use. + """ + cls.__annotations__[bl_prop_name] = prop( + update=lambda self, context: self.on_bl_prop_changed(bl_prop_name, context), + **kwargs, + ) + + #################### + # - Runtime Field Management + #################### + def regenerate_dynamic_field_persistance(self): + """Regenerate the persisted data of all dynamic enums and str search BLFields. + + In practice, this sets special "signal" values: + - **Dynamic Enums**: The signal value `bl_cache.Signal.ResetEnumItems` will be set, causing `BLField.__set__` to regenerate the enum items using the user-provided callback. + - **Searched Strings**: The signal value `bl_cache.Signal.ResetStrSearch` will be set, causing `BLField.__set__` to regenerate the available search strings using the user-provided callback. + """ + # Generate Enum Items + ## -> This guarantees that the items are persisted from the start. + for dyn_enum_prop_name in self.blfields_dynamic_enum: + setattr(self, dyn_enum_prop_name, bl_cache.Signal.ResetEnumItems) + + # Generate Str Search Items + ## -> Match dynamic enum semantics + for str_search_prop_name in self.blfields_str_search: + setattr(self, str_search_prop_name, bl_cache.Signal.ResetStrSearch) + + def on_bl_prop_changed(self, bl_prop_name: str, _: bpy.types.Context) -> None: + """Called when a property has been updated via the Blender UI. + + The only effect is to invalidate the non-persistent cache of the associated BLField. + The BLField then decides whether to take any other action, ex. calling `self.on_prop_changed()`. + """ + ## TODO: What about non-Blender set properties? + + # Strip the Internal Prefix + ## -> TODO: This is a bit of a hack. Use a contracts constant. + prop_name = bl_prop_name.removeprefix('blfield__') + # log.debug( + # 'Callback on Property %s (stripped: %s)', + # bl_prop_name, + # prop_name, + # ) + # log.debug( + # 'Dependencies (PROP: %s) (ENUM: %s) (SEAR: %s)', + # self.blfield_deps, + # self.blfield_dynamic_enum_deps, + # self.blfield_str_search_deps, + # ) + + # Invalidate Property Cache + ## -> Only the non-persistent cache is regenerated. + ## -> The BLField decides whether to trigger `on_prop_changed`. + if prop_name in self.blfields: + # RULE: =1 DataChanged per Dependency Chain + ## -> We MUST invalidate the cache, but might not want to update. + ## -> Update should only be triggered when ==0 dependents. + setattr(self, prop_name, bl_cache.Signal.InvalidateCacheNoUpdate) + + # Invalidate Dependent Properties (incl. DynEnums and StrSearch) + ## -> NOTE: Dependent props may also trigger `on_prop_changed`. + ## -> Meaning, don't use extraneous dependencies (as usual). + for deps, invalidate_signal in zip( + [ + self.blfield_deps, + self.blfield_dynamic_enum_deps, + self.blfield_str_search_deps, + ], + [ + bl_cache.Signal.InvalidateCache, + bl_cache.Signal.ResetEnumItems, + bl_cache.Signal.ResetStrSearch, + ], + strict=True, + ): + if prop_name in deps: + for dst_prop_name in deps[prop_name]: + # log.debug( + # 'Property %s is invalidating %s', + # prop_name, + # dst_prop_name, + # ) + setattr( + self, + dst_prop_name, + invalidate_signal, + ) + + # Do Update AFTER Dependencies + ## -> Yes, update will run once per dependency. + ## -> Don't abuse dependencies :) + ## -> If no-update is important, use_prop_update is still respected. + setattr(self, prop_name, bl_cache.Signal.DoUpdate) + + def on_prop_changed(self, prop_name: str) -> None: + """Triggers changes/an event chain based on a changed property. + + In general, the `BLField` descriptor associated with `prop_name` decides whether this method should be called whenever `__set__` is used. + An indirect consequence of this is that `self.on_bl_prop_changed`, which is _always_ triggered, may only _sometimes_ result in `on_prop_changed` being called, at the discretion of the relevant `BLField`. + + Notes: + **Must** be overridden on all `BLInstance` subclasses. + """ + raise NotImplementedError diff --git a/src/blender_maxwell/utils/extra_sympy_units.py b/src/blender_maxwell/utils/extra_sympy_units.py index 1011a2a..13106e1 100644 --- a/src/blender_maxwell/utils/extra_sympy_units.py +++ b/src/blender_maxwell/utils/extra_sympy_units.py @@ -96,6 +96,26 @@ class MathType(enum.StrEnum): }[self] ) + def coerce_compatible_pyobj( + self, pyobj: bool | int | Fraction | float | complex + ) -> bool | int | Fraction | float | complex: + MT = MathType + match self: + case MT.Bool: + return pyobj + case MT.Integer: + return int(pyobj) + case MT.Rational if isinstance(pyobj, int): + return Fraction(pyobj, 1) + case MT.Rational if isinstance(pyobj, Fraction): + return pyobj + case MT.Real: + return float(pyobj) + case MT.Complex if isinstance(pyobj, int | Fraction): + return complex(float(pyobj), 0) + case MT.Complex if isinstance(pyobj, float): + return complex(pyobj, 0) + @staticmethod def from_expr(sp_obj: SympyType) -> type: if isinstance(sp_obj, sp.MatrixBase): @@ -124,21 +144,31 @@ class MathType(enum.StrEnum): raise ValueError(msg) @staticmethod - def from_pytype(dtype) -> type: + def from_pytype(dtype: type) -> type: return { bool: MathType.Bool, int: MathType.Integer, + Fraction: MathType.Rational, float: MathType.Real, complex: MathType.Complex, }[dtype] + @staticmethod + def has_mathtype(obj: typ.Any) -> typ.Literal['pytype', 'expr'] | None: + if isinstance(obj, bool | int | Fraction | float | complex): + return 'pytype' + if isinstance(obj, sp.Basic | sp.MatrixBase | sp.MutableDenseMatrix): + return 'expr' + + return None + @property def pytype(self) -> type: MT = MathType return { MT.Bool: bool, MT.Integer: int, - MT.Rational: float, + MT.Rational: Fraction, MT.Real: float, MT.Complex: complex, }[self] @@ -209,8 +239,20 @@ class NumberSize1D(enum.StrEnum): ) @staticmethod - def supports_shape(shape: tuple[int, ...] | None): - return shape is None or (len(shape) == 1 and shape[0] in [2, 3]) + def has_shape(shape: tuple[int, ...] | None): + return shape in [None, (2,), (3,), (4,), (2, 1), (3, 1), (4, 1)] + + def supports_shape(self, shape: tuple[int, ...] | None): + NS = NumberSize1D + match self: + case NS.Scalar: + return shape is None + case NS.Vec2: + return shape in ((2,), (2, 1)) + case NS.Vec3: + return shape in ((3,), (3, 1)) + case NS.Vec4: + return shape in ((4,), (4, 1)) @staticmethod def from_shape(shape: tuple[typ.Literal[2, 3]] | None) -> typ.Self: @@ -220,6 +262,9 @@ class NumberSize1D(enum.StrEnum): (2,): NS.Vec2, (3,): NS.Vec3, (4,): NS.Vec4, + (2, 1): NS.Vec2, + (3, 1): NS.Vec3, + (4, 1): NS.Vec4, }[shape] @property @@ -233,6 +278,14 @@ class NumberSize1D(enum.StrEnum): }[self] +def symbol_range(sym: sp.Symbol) -> str: + return f'{sym.name} ∈ ' + ( + 'ℂ' + if sym.is_complex + else ('ℝ' if sym.is_real else ('ℤ' if sym.is_integer else '?')) + ) + + #################### # - Unit Dimensions #################### @@ -749,7 +802,7 @@ def scale_to_unit(sp_obj: SympyType, unit: spu.Quantity) -> Number: Raises: ValueError: If the result of unit-conversion and -stripping still has units, as determined by `uses_units()`. """ - unitless_expr = spu.convert_to(sp_obj, unit) / unit + unitless_expr = spu.convert_to(sp_obj, unit) / unit if unit is not None else sp_obj if not uses_units(unitless_expr): return unitless_expr @@ -800,6 +853,9 @@ def unit_str_to_unit(unit_str: str) -> Unit | None: class PhysicalType(enum.StrEnum): """Type identifiers for expressions with both `MathType` and a unit, aka a "physical" type.""" + # Unitless + NonPhysical = enum.auto() + # Global Time = enum.auto() Angle = enum.auto() @@ -845,10 +901,11 @@ class PhysicalType(enum.StrEnum): AngularWaveVector = enum.auto() PoyntingVector = enum.auto() - @property + @functools.cached_property def unit_dim(self): PT = PhysicalType return { + PT.NonPhysical: None, # Global PT.Time: Dims.time, PT.Angle: Dims.angle, @@ -894,10 +951,11 @@ class PhysicalType(enum.StrEnum): PT.PoyntingVector: Dims.power / Dims.length**2, }[self] - @property + @functools.cached_property def default_unit(self) -> list[Unit]: PT = PhysicalType return { + PT.NonPhysical: None, # Global PT.Time: spu.picosecond, PT.Angle: spu.radian, @@ -942,10 +1000,11 @@ class PhysicalType(enum.StrEnum): PT.AngularWaveVector: spu.radian * terahertz, }[self] - @property + @functools.cached_property def valid_units(self) -> list[Unit]: PT = PhysicalType return { + PT.NonPhysical: [None], # Global PT.Time: [ femtosecond, @@ -1101,12 +1160,13 @@ class PhysicalType(enum.StrEnum): for physical_type in list(PhysicalType): if unit in physical_type.valid_units: return physical_type + ## TODO: Optimize - msg = f'No PhysicalType found for unit {unit}' + msg = f'Could not determine PhysicalType for {unit}' raise ValueError(msg) - @property - def valid_shapes(self): + @functools.cached_property + def valid_shapes(self) -> list[typ.Literal[(3,), (2,)] | None]: PT = PhysicalType overrides = { # Cartesian @@ -1133,7 +1193,7 @@ class PhysicalType(enum.StrEnum): return overrides.get(self, [None]) - @property + @functools.cached_property def valid_mathtypes(self) -> list[MathType]: """Returns a list of valid mathematical types, especially whether it can be real- or complex-valued. @@ -1157,6 +1217,7 @@ class PhysicalType(enum.StrEnum): MT = MathType PT = PhysicalType overrides = { + PT.NonPhysical: list(MT), ## Support All # Cartesian PT.Freq: [MT.Real, MT.Complex], ## Im -> Growth/Damping PT.AngFreq: [MT.Real, MT.Complex], ## Im -> Growth/Damping @@ -1187,6 +1248,8 @@ class PhysicalType(enum.StrEnum): @staticmethod def to_name(value: typ.Self) -> str: + if value is PhysicalType.NonPhysical: + return 'Unitless' return PhysicalType(value).name @staticmethod diff --git a/src/blender_maxwell/utils/staticproperty.py b/src/blender_maxwell/utils/staticproperty.py index 2618f12..8f34634 100644 --- a/src/blender_maxwell/utils/staticproperty.py +++ b/src/blender_maxwell/utils/staticproperty.py @@ -21,7 +21,7 @@ class staticproperty(property): # noqa: N801 The decorated method must take no arguments whatsoever, including `self`/`cls`. Examples: - Use as usual: + Exactly as you'd expect. ```python class Spam: @staticproperty