diff --git a/src/blender_maxwell/assets/structures/primitives/box.blend b/src/blender_maxwell/assets/structures/primitives/box.blend index 755b978..2ee7e2a 100644 --- a/src/blender_maxwell/assets/structures/primitives/box.blend +++ b/src/blender_maxwell/assets/structures/primitives/box.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59d82a5231448784c9b1107fa439ff500e376b9e4ee906a95022476a8b7755d8 -size 852005 +oid sha256:79bdbdae875b5f005f2e981c1d0e9c303a52bf7b97e1744c4ef73e155d41c75b +size 918460 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 b1d9b58..0828da2 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 @@ -46,10 +46,11 @@ from .flow_kinds import ( ArrayFlow, CapabilitiesFlow, FlowKind, - InfoFlow, - RangeFlow, FuncFlow, + InfoFlow, ParamsFlow, + PreviewsFlow, + RangeFlow, ScalingMode, ValueFlow, ) @@ -118,6 +119,7 @@ __all__ = [ 'CapabilitiesFlow', 'FlowKind', 'InfoFlow', + 'PreviewsFlow', 'RangeFlow', 'FuncFlow', 'ParamsFlow', diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/bl_socket_types.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/bl_socket_types.py index 46e62eb..244e54b 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/bl_socket_types.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/bl_socket_types.py @@ -230,7 +230,6 @@ class BLSocketType(enum.StrEnum): return { # Blender # Basic - BLST.Bool: MT.Bool, # Float BLST.Float: MT.Real, BLST.FloatAngle: MT.Real, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_events.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_events.py index d8bdffc..083873f 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_events.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_events.py @@ -35,10 +35,6 @@ class FlowEvent(enum.StrEnum): This event can lock a subset of the node tree graph. DisableLock: Indicates that the node/socket should disable locking. This event can unlock part of a locked subgraph. - ShowPreview: Indicates that the node/socket should enable its primary preview. - This should be used if a more specific preview-esque event doesn't apply. - ShowPlot: Indicates that the node/socket should enable its plotted preview. - This should generally be used if the node is rendering to an image, for viewing through the Blender image editor. LinkChanged: Indicates that a link to a node/socket was added/removed. Is translated to `DataChanged` on sockets before propagation. DataChanged: Indicates that data flowing through a node/socket was altered. @@ -50,15 +46,12 @@ class FlowEvent(enum.StrEnum): EnableLock = enum.auto() DisableLock = enum.auto() - # Preview Events - ShowPreview = enum.auto() - ShowPlot = enum.auto() - # Data Events LinkChanged = enum.auto() DataChanged = enum.auto() # Non-Triggered Events + ShowPlot = enum.auto() OutputRequested = enum.auto() # Properties @@ -79,9 +72,6 @@ class FlowEvent(enum.StrEnum): # Lock Events FlowEvent.EnableLock: 'input', FlowEvent.DisableLock: 'input', - # Preview Events - FlowEvent.ShowPreview: 'input', - FlowEvent.ShowPlot: 'input', # Data Events FlowEvent.LinkChanged: 'output', FlowEvent.DataChanged: 'output', 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 a050944..619fdb7 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 @@ -21,6 +21,7 @@ from .info import InfoFlow from .lazy_func import FuncFlow from .lazy_range import RangeFlow, ScalingMode from .params import ParamsFlow +from .previews import PreviewsFlow from .value import ValueFlow __all__ = [ @@ -32,5 +33,6 @@ __all__ = [ 'ScalingMode', 'FuncFlow', 'ParamsFlow', + 'PreviewsFlow', '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 d75afd9..0303908 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 @@ -21,7 +21,6 @@ import typing as typ import jaxtyping as jtyp import numpy as np import sympy as sp -import sympy.physics.units as spu from blender_maxwell.utils import extra_sympy_units as spux from blender_maxwell.utils import logger @@ -117,13 +116,19 @@ class ArrayFlow: new_unit: An (optional) new unit to scale the result to. """ # Compile JAX-Compatible Rescale Function + ## -> Generally, we try to keep things nice and rational. + ## -> However, too-large ints may cause JAX to suffer from an overflow. + ## -> Jax works in 32-bit domain by default, for performance. + ## -> While it can be adjusted, that would also have tradeoffs. + ## -> Instead, a quick .n() turns all the big-ints into floats. + ## -> Not super satisfying, but hey - it's all numerical anyway. a = self.mathtype.sp_symbol_a rescale_expr = ( spux.scale_to_unit(rescale_func(a * self.unit), new_unit) if self.unit is not None else rescale_func(a) ) - _rescale_func = sp.lambdify(a, rescale_expr, 'jax') + _rescale_func = sp.lambdify(a, rescale_expr.n(), 'jax') values = _rescale_func(self.values) # Return ArrayFlow diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/capabilities.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/capabilities.py index 6a7e804..800b4d4 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/capabilities.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/capabilities.py @@ -24,9 +24,30 @@ from .flow_kinds import FlowKind @dataclasses.dataclass(frozen=True, kw_only=True) class CapabilitiesFlow: + """Describes the compatibility relationship between two sockets, which governs whether they can be linked. + + By default, socket type (which may impact color) and active `FlowKind` (which impacts shape) must match in order for two sockets to be compatible. + + However, in many cases, more expressiveness beyond this naive constraint is desirable. + For example: + + - Allow any socket to be linked to the `ViewerNode` input. + - Allow only _angled_ sources to be passed as inputs to the input-derived `BlochBoundCond` node. + - Allow `Expr:Value` to connect to `Expr:Func`, but only allow the converse if `PhysicalType`, `MathType`, and `Size` match. + + In many cases, it's desirable + + """ + + # Defaults socket_type: SocketType active_kind: FlowKind + + # Relationships allow_out_to_in: dict[FlowKind, FlowKind] = dataclasses.field(default_factory=dict) + allow_out_to_in_if_matches: dict[FlowKind, (FlowKind, bool)] = dataclasses.field( + default_factory=dict + ) is_universal: bool = False @@ -41,12 +62,19 @@ class CapabilitiesFlow: def is_compatible_with(self, other: typ.Self) -> bool: return other.is_universal or ( - self.socket_type == other.socket_type + self.socket_type is other.socket_type and ( - self.active_kind == other.active_kind + self.active_kind is other.active_kind or ( other.active_kind in other.allow_out_to_in - and self.active_kind == other.allow_out_to_in[other.active_kind] + and self.active_kind is other.allow_out_to_in[other.active_kind] + ) + or ( + other.active_kind in other.allow_out_to_in_if_matches + and self.active_kind + is other.allow_out_to_in_if_matches[other.active_kind][0] + and self.allow_out_to_in_if_matches[other.active_kind][1] + == other.allow_out_to_in_if_matches[other.active_kind][1] ) ) # == Constraint 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 d4058e9..8db2fc9 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 @@ -15,6 +15,7 @@ # along with this program. If not, see . import enum +import functools import typing as typ from blender_maxwell.utils import extra_sympy_units as spux @@ -23,6 +24,17 @@ from blender_maxwell.utils.staticproperty import staticproperty log = logger.get(__name__) +_PROPERTY_NAMES = { + 'capabilities', + 'previews', + 'value', + 'array', + 'lazy_range', + 'lazy_func', + 'params', + 'info', +} + class FlowKind(enum.StrEnum): """Defines a kind of data that can flow between nodes. @@ -50,14 +62,15 @@ class FlowKind(enum.StrEnum): """ Capabilities = enum.auto() + Previews = enum.auto() # Values Value = enum.auto() ## 'value' Array = enum.auto() ## 'array' # Lazy - Func = enum.auto() ## 'lazy_func' Range = enum.auto() ## 'lazy_range' + Func = enum.auto() ## 'lazy_func' # Auxiliary Params = enum.auto() ## 'params' @@ -70,12 +83,13 @@ class FlowKind(enum.StrEnum): def to_name(v: typ.Self) -> str: return { FlowKind.Capabilities: 'Capabilities', + FlowKind.Previews: 'Previews', # Values FlowKind.Value: 'Value', FlowKind.Array: 'Array', # Lazy - FlowKind.Range: 'Range', FlowKind.Func: 'Func', + FlowKind.Range: 'Range', # Auxiliary FlowKind.Params: 'Params', FlowKind.Info: 'Info', @@ -88,6 +102,48 @@ class FlowKind(enum.StrEnum): #################### # - Static Properties #################### + @staticproperty + def property_names() -> set[str]: + """Set of strings for (socket) properties associated with a `FlowKind`. + + Usable for optimized O(1) lookup, to check whether a property name can be converted to a `FlowKind`. + To actually retrieve the `FlowKind` from one of these names, use `FlowKind.from_property_name()`. + """ + return _PROPERTY_NAMES + + @functools.cache + @staticmethod + def from_property_name(prop_name: str) -> typ.Self: + """Retrieve the `FlowKind` associated with a particular property name. + + Parameters: + prop_name: The name of the property. + **Must** be a string defined in `FlowKind.property_names`. + """ + return { + 'capabilities': FlowKind.Capabilities, + 'previews': FlowKind.Previews, + 'value': FlowKind.Value, + 'array': FlowKind.Array, + 'lazy_range': FlowKind.Range, + 'lazy_func': FlowKind.Func, + 'params': FlowKind.Params, + 'info': FlowKind.Info, + }[prop_name] + + @functools.cached_property + def property_name(self) -> typ.Self: + """Retrieve the `FlowKind` associated with a particular property name. + + Parameters: + prop_name: The name of the property. + **Must** be a string defined in `FlowKind.property_names`. + """ + return { + FlowKind.from_property_name(prop_name): prop_name + for prop_name in FlowKind.property_names + }[self] + @staticproperty def active_kinds() -> list[typ.Self]: """Return a list of `FlowKind`s that are able to be considered "active". @@ -121,6 +177,7 @@ class FlowKind(enum.StrEnum): #################### # - Class Methods #################### + ## TODO: Remove this (only events uses it). @classmethod def scale_to_unit_system( cls, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/info.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/info.py index d93fb76..43dbfe5 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/info.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/info.py @@ -93,7 +93,7 @@ class InfoFlow: return list(self.dims.keys())[idx] return None - def dim_by_name(self, dim_name: str) -> int: + def dim_by_name(self, dim_name: str, optional: bool = False) -> int | None: """The integer axis occupied by the dimension. Can be used to index `.shape` of the represented raw array. @@ -102,6 +102,9 @@ class InfoFlow: if len(dims_with_name) == 1: return dims_with_name[0] + if optional: + return None + msg = f'Dim name {dim_name} not found in InfoFlow (or >1 found)' raise ValueError(msg) @@ -127,14 +130,15 @@ class InfoFlow: return False def is_idx_uniform(self, dim: sim_symbols.SimSymbol) -> bool: - """Whether the (int) dim has explicitly uniform indexing. + """Whether the given dim has explicitly uniform indexing. This is needed primarily to check whether a Fourier Transform can be meaningfully performed on the data over the dimension's axis. In practice, we've decided that only `RangeFlow` really truly _guarantees_ uniform indexing. While `ArrayFlow` may be uniform in practice, it's a very expensive to check, and it's far better to enforce that the user perform that check and opt for a `RangeFlow` instead, at the time of dimension definition. """ - return isinstance(self.dims[dim], RangeFlow) and self.dims[dim].scaling == 'lin' + dim_idx = self.dims[dim] + return isinstance(dim_idx, RangeFlow) and dim_idx.scaling == 'lin' def dim_axis(self, dim: sim_symbols.SimSymbol) -> int: """The integer axis occupied by the dimension. @@ -194,7 +198,7 @@ class InfoFlow: return { dim.name_pretty: { 'length': str(len(dim_idx)) if dim_idx is not None else 'āˆž', - 'mathtype': dim.mathtype.label_pretty, + 'mathtype': dim.mathtype_size_label, 'unit': dim.unit_label, } for dim, dim_idx in self.dims.items() @@ -315,27 +319,23 @@ class InfoFlow: op: typ.Callable[[spux.SympyExpr, spux.SympyExpr], spux.SympyExpr], unit_op: typ.Callable[[spux.SympyExpr, spux.SympyExpr], spux.SympyExpr], ) -> spux.SympyExpr: - if self.dims == other.dims: - sym_name = sim_symbols.SimSymbolName.Expr - expr = op(self.output.sp_symbol_phy, other.output.sp_symbol_phy) - unit_expr = unit_op(self.output.unit_factor, other.output.unit_factor) + sym_name = sim_symbols.SimSymbolName.Expr + expr = op(self.output.sp_symbol_phy, other.output.sp_symbol_phy) + unit_expr = unit_op(self.output.unit_factor, other.output.unit_factor) + ## TODO: Handle per-cell matrix units? - return InfoFlow( - dims=self.dims, - output=sim_symbols.SimSymbol.from_expr(sym_name, expr, unit_expr), - pinned_values=self.pinned_values, - ) - - msg = f'InfoFlow: operate_output cannot be used when dimensions are not identical ({self.dims} | {other.dims}).' - raise ValueError(msg) + return InfoFlow( + dims=self.dims, + output=sim_symbols.SimSymbol.from_expr(sym_name, expr, unit_expr), + pinned_values=self.pinned_values, + ) #################### # - Operations: Fold #################### def fold_last_input(self): """Fold the last input dimension into the output.""" - last_key = list(self.dims.keys())[-1] - last_idx = list(self.dims.values())[-1] + last_idx = self.dims[self.last_dim] rows = self.output.rows cols = self.output.cols @@ -351,7 +351,9 @@ class InfoFlow: return InfoFlow( dims={ - dim: dim_idx for dim, dim_idx in self.dims.items() if dim != last_key + dim: dim_idx + for dim, dim_idx in self.dims.items() + if dim != self.last_dim }, output=new_output, pinned_values=self.pinned_values, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_func.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_func.py index 0baf232..52c0fb2 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_func.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_func.py @@ -20,10 +20,14 @@ import typing as typ from types import MappingProxyType import jax +import jaxtyping as jtyp from blender_maxwell.utils import extra_sympy_units as spux from blender_maxwell.utils import logger, sim_symbols +from .array import ArrayFlow +from .info import InfoFlow +from .lazy_range import RangeFlow from .params import ParamsFlow log = logger.get(__name__) @@ -314,14 +318,66 @@ class FuncFlow: ) -> typ.Self: if self.supports_jax: return self.func_jax( - *params.scaled_func_args(self.func_args, symbol_values), - *params.scaled_func_kwargs(self.func_args, symbol_values), + *params.scaled_func_args(symbol_values), + **params.scaled_func_kwargs(symbol_values), ) return self.func( - *params.scaled_func_args(self.func_kwargs, symbol_values), - *params.scaled_func_kwargs(self.func_kwargs, symbol_values), + *params.scaled_func_args(symbol_values), + **params.scaled_func_kwargs(symbol_values), ) + def realize_as_data( + self, + info: InfoFlow, + params: ParamsFlow, + symbol_values: dict[sim_symbols.SimSymbol, spux.SympyExpr] = MappingProxyType( + {} + ), + ) -> dict[sim_symbols.SimSymbol, jtyp.Inexact[jtyp.Array, '...']]: + """Realize as an ordered dictionary mapping each realized `self.dims` entry, with the last entry containing all output data as mapped from the `self.output`.""" + data = {} + for dim, dim_idx in info.dims.items(): + # Continuous Index (*) + ## -> Continuous dimensions **must** be symbols in ParamsFlow. + ## -> ...Since the output data shape is parameterized by it. + if info.has_idx_cont(dim): + if dim in params.symbols: + # Scalar Realization + ## -> Conform & cast the sympy expr to the dimension. + if isinstance(symbol_values[dim], spux.SympyType): + data |= {dim: dim.scale(symbol_values[dim])} + + # Array Realization + ## -> Scale the array to the dimension's unit & get values. + if isinstance(symbol_values[dim], RangeFlow | ArrayFlow): + data |= { + dim: symbol_values[dim].rescale_to_unit(dim.unit).values + } + else: + msg = f'ParamsFlow does not contain dimension symbol {dim} (info={info}, params={params})' + raise RuntimeError(msg) + + # Discrete Index (Q|R) + ## -> Realize ArrayFlow|RangeFlow + if info.has_idx_discrete(dim): + data |= {dim: dim_idx.values} + + # Labelled Index (Z) + ## -> Passthrough the string labels. + if info.has_idx_labels(dim): + data |= {dim: dim_idx} + + return data | {info.output: self.realize(params, symbol_values=symbol_values)} + + # return { + # dim: ( + # dim_idx + # if info.has_idx_cont(dim) or info.has_idx_labels(dim) + # else ?? + # ) + # for dim, dim_idx in self.dims + # } | {info.output: output_data} + #################### # - Composition Operations #################### diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_range.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_range.py index a81cd5f..55cc3b5 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_range.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/lazy_range.py @@ -218,9 +218,6 @@ class RangeFlow: ) return combined_mathtype - #################### - # - Methods - #################### @property def ideal_midpoint(self) -> spux.SympyExpr: return (self.stop + self.start) / 2 @@ -229,6 +226,41 @@ class RangeFlow: def ideal_range(self) -> spux.SympyExpr: return self.stop - self.start + #################### + # - Methods + #################### + @staticmethod + def try_from_array( + array: ArrayFlow, uniformity_tolerance: float = 1e-9 + ) -> ArrayFlow | typ.Self: + """Attempt to create a RangeFlow from a potentially uniform ArrayFlow, falling back to that same ArrayFlow if it isn't uniform. + + For functional (ex. Fourier Transform) and memory-related reasons, it's important to be explicit about the uniformity of index elements. + For this reason, only `RangeFlow`s are considered uniform - `ArrayFlow`s are not, as it's expensive to check in a hot loop, while `RangeFlow`s have this property simply by existing. + + Of course, real-world data sources may not come in a memory-efficient configuration, even if they are, in fact, monotonically increasing with uniform finite differences. + This method bridges that gap: If (within `uniformity_tolerance`) **all** finite differences are the same, then the `ArrayFlow` can be converted losslessly to a `RangeFlow. + **Otherwise**, the `ArrayFlow` is returned verbatim. + + Notes: + A few other checks are also performed to guarantee the semantics of a resulting `RangeFlow`: The array must be sorted, there must be at least two values, and the first value must be strictly smaller than the last value. + """ + diffs = jnp.diff(array.values) + + if ( + jnp.all(jnp.abs(diffs - diffs[0]) < uniformity_tolerance) + and len(array.values) > 2 # noqa: PLR2004 + and array.values[0] < array.values[-1] + and array.is_sorted + ): + return RangeFlow( + start=sp.S(array.values[0]), + stop=sp.S(array.values[-1]), + steps=len(array.values), + unit=array.unit, + ) + return array + def rescale( self, rescale_func, reverse: bool = False, new_unit: spux.Unit | None = None ) -> typ.Self: @@ -612,8 +644,8 @@ class RangeFlow: symbols=self.symbols, ) return RangeFlow( - start=self.start * unit, - stop=self.stop * unit, + start=self.start, + stop=self.stop, steps=self.steps, scaling=self.scaling, unit=unit, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/params.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/params.py index cd2e8ae..fdc0568 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/params.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/params.py @@ -31,8 +31,6 @@ from .expr_info import ExprInfo from .flow_kinds import FlowKind from .lazy_range import RangeFlow -# from .info import InfoFlow - log = logger.get(__name__) @@ -44,11 +42,18 @@ class ParamsFlow: All symbols valid for use in the expression. """ + arg_targets: list[sim_symbols.SimSymbol] = dataclasses.field(default_factory=list) + kwarg_targets: list[str, sim_symbols.SimSymbol] = dataclasses.field( + default_factory=dict + ) + func_args: list[spux.SympyExpr] = dataclasses.field(default_factory=list) func_kwargs: dict[str, spux.SympyExpr] = dataclasses.field(default_factory=dict) symbols: frozenset[sim_symbols.SimSymbol] = frozenset() + is_differentiable: bool = False + #################### # - Symbols #################### @@ -76,8 +81,9 @@ class ParamsFlow: #################### # - JIT'ed Callables for Numerical Function Arguments #################### + @functools.cached_property def func_args_n( - self, target_syms: list[sim_symbols.SimSymbol] + self, ) -> list[ typ.Callable[ [int | float | complex | jtyp.Inexact[jtyp.Array, '...'], ...], @@ -86,15 +92,12 @@ class ParamsFlow: ]: """Callable functions for evaluating each `self.func_args` entry numerically. - Before simplification, each `self.func_args` entry will be conformed to the corresponding (by-index) `SimSymbol` in `target_syms`. + Before simplification, each `self.func_args` entry will be conformed to the corresponding (by-index) `SimSymbol` in `self.target_syms`. Notes: Before using any `sympy` expressions as arguments to the returned callablees, they **must** be fully conformed and scaled to the corresponding `self.symbols` entry using that entry's `SimSymbol.scale()` method. This ensures conformance to the `SimSymbol` properties (like units), as well as adherance to a numerical type identity compatible with `sp.lambdify()`. - - Parameters: - target_syms: `SimSymbol`s describing how a particular `ParamsFlow` function argument should be scaled when performing a purely numerical insertion. """ return [ sp.lambdify( @@ -102,11 +105,14 @@ class ParamsFlow: target_sym.conform(func_arg, strip_unit=True), 'jax', ) - for func_arg, target_sym in zip(self.func_args, target_syms, strict=True) + for func_arg, target_sym in zip( + self.func_args, self.arg_targets, strict=True + ) ] + @functools.cached_property def func_kwargs_n( - self, target_syms: dict[str, sim_symbols.SimSymbol] + self, ) -> dict[ str, typ.Callable[ @@ -120,12 +126,12 @@ class ParamsFlow: This ensures conformance to the `SimSymbol` properties, as well as adherance to a numerical type identity compatible with `sp.lambdify()` """ return { - func_arg_key: sp.lambdify( + key: sp.lambdify( self.sorted_sp_symbols, - target_syms[func_arg_key].scale(func_arg), + self.kwarg_targets[key].conform(func_arg, strip_unit=True), 'jax', ) - for func_arg_key, func_arg in self.func_kwargs.items() + for key, func_arg in self.func_kwargs.items() } #################### @@ -182,7 +188,6 @@ class ParamsFlow: #################### def scaled_func_args( self, - target_syms: list[sim_symbols.SimSymbol] = (), symbol_values: dict[sim_symbols.SimSymbol, spux.SympyExpr] = MappingProxyType( {} ), @@ -196,32 +201,24 @@ class ParamsFlow: 1. Conform Symbols: Arbitrary `sympy` expressions passed as `symbol_values` must first be conformed to match the ex. units of `SimSymbol`s found in `self.symbols`, before they can be used. 2. Conform Function Arguments: Arbitrary `sympy` expressions encoded in `self.func_args` must, **after** inserting the conformed numerical symbols, themselves be conformed to the expected ex. units of the function that they are to be used within. - **`ParamsFlow` doesn't contain information about the `SimSymbol`s that `self.func_args` are expected to conform to** (on purpose). - Therefore, the user is required to pass a `target_syms` with identical length to `self.func_args`, describing the `SimSymbol`s to conform the function arguments to. Our implementation attempts to utilize simple, powerful primitives to accomplish this in roughly three steps: 1. **Realize Symbols**: Particular passed symbolic values `symbol_values`, which are arbitrary `sympy` expressions, are conformed to the definitions in `self.symbols` (ex. to match units), then cast to numerical values (pure Python / jax array). - 2. **Lazy Function Arguments**: Stored function arguments `self.func_args`, which are arbitrary `sympy` expressions, are conformed to the definitions in `target_syms` (ex. to match units), then cast to numerical values (pure Python / jax array). + 2. **Lazy Function Arguments**: Stored function arguments `self.func_args`, which are arbitrary `sympy` expressions, are conformed to the definitions in `self.target_syms` (ex. to match units), then cast to numerical values (pure Python / jax array). _Technically, this happens as part of `self.func_args_n`._ 3. **Numerical Evaluation**: The numerical values for each symbol are passed as parameters to each (callable) element of `self.func_args_n`, which produces a correct numerical value for each function argument. Parameters: - target_syms: `SimSymbol`s describing how the function arguments returned by this method are intended to be used. - **Generally**, the parallel `FuncFlow.func_args` should be inserted here, and guarantees correct results when this output is inserted into `FuncFlow.func(...)`. - symbol_values: Particular values for all symbols in `self.symbols`, which will be conformed and used to compute the function arguments (before they are conformed to `target_syms`). + symbol_values: Particular values for all symbols in `self.symbols`, which will be conformed and used to compute the function arguments (before they are conformed to `self.target_syms`). """ realized_symbols = list(self.realize_symbols(symbol_values).values()) - return [ - func_arg_n(*realized_symbols) - for func_arg_n in self.func_args_n(target_syms) - ] + return [func_arg_n(*realized_symbols) for func_arg_n in self.func_args_n] def scaled_func_kwargs( self, - target_syms: list[sim_symbols.SimSymbol] = (), symbol_values: dict[spux.Symbol, spux.SympyExpr] = MappingProxyType({}), ) -> dict[ str, int | float | Fraction | float | complex | jtyp.Shaped[jtyp.Array, '...'] @@ -233,7 +230,7 @@ class ParamsFlow: realized_symbols = self.realize_symbols(symbol_values) return { func_arg_name: func_arg_n(**realized_symbols) - for func_arg_name, func_arg_n in self.func_kwargs_n(target_syms).items() + for func_arg_name, func_arg_n in self.func_kwargs_n.items() } #################### @@ -249,27 +246,41 @@ class ParamsFlow: The next composed function will receive a tuple of two arrays, instead of just one, allowing binary operations to occur. """ return ParamsFlow( + arg_targets=self.arg_targets + other.arg_targets, + kwarg_targets=self.kwarg_targets | other.kwarg_targets, func_args=self.func_args + other.func_args, func_kwargs=self.func_kwargs | other.func_kwargs, symbols=self.symbols | other.symbols, + is_differentiable=self.is_differentiable & other.is_differentiable, ) def compose_within( self, + enclosing_arg_targets: list[sim_symbols.SimSymbol] = (), + enclosing_kwarg_targets: list[sim_symbols.SimSymbol] = (), enclosing_func_args: list[spux.SympyExpr] = (), enclosing_func_kwargs: dict[str, spux.SympyExpr] = MappingProxyType({}), - enclosing_symbols: frozenset[spux.Symbol] = frozenset(), + enclosing_symbols: frozenset[sim_symbols.SimSymbol] = frozenset(), + enclosing_is_differentiable: bool = False, ) -> typ.Self: return ParamsFlow( + arg_targets=self.arg_targets + list(enclosing_arg_targets), + kwarg_targets=self.kwarg_targets | dict(enclosing_kwarg_targets), func_args=self.func_args + list(enclosing_func_args), func_kwargs=self.func_kwargs | dict(enclosing_func_kwargs), symbols=self.symbols | enclosing_symbols, + is_differentiable=( + self.is_differentiable + if not enclosing_symbols + else (self.is_differentiable & enclosing_is_differentiable) + ), ) #################### # - Generate ExprSocketDef #################### - def sym_expr_infos(self, use_range: bool = False) -> dict[str, ExprInfo]: + @functools.cached_property + def sym_expr_infos(self) -> dict[str, ExprInfo]: """Generate keyword arguments for defining all `ExprSocket`s needed to realize all `self.symbols`. Many nodes need actual data, and as such, they require that the user select actual values for any symbols in the `ParamsFlow`. @@ -284,28 +295,16 @@ class ParamsFlow: } ``` - Parameters: - info: The InfoFlow associated with the `Expr` being realized. - Each symbol in `self.symbols` **must** have an associated same-named dimension in `info`. - use_range: Causes the - The `ExprInfo`s can be directly defererenced `**expr_info`) """ for sym in self.sorted_symbols: - if use_range and sym.mathtype is spux.MathType.Complex: - msg = 'No support for complex range in ExprInfo' - raise NotImplementedError(msg) - if use_range and (sym.rows > 1 or sym.cols > 1): - msg = 'No support for non-scalar elements of range in ExprInfo' - raise NotImplementedError(msg) if sym.rows > 3 or sym.cols > 1: msg = 'No support for >Vec3 / Matrix values in ExprInfo' raise NotImplementedError(msg) return { - sym.name: { - 'active_kind': FlowKind.Value if not use_range else FlowKind.Range, - 'default_steps': 50, + sym: { + 'default_steps': 25, } | sym.expr_info for sym in self.sorted_symbols diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/previews.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/previews.py new file mode 100644 index 0000000..92b76e3 --- /dev/null +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_kinds/previews.py @@ -0,0 +1,193 @@ +# 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 bpy +import pydantic as pyd + +from blender_maxwell.utils import logger + +IMAGE_AREA_TYPE = 'IMAGE_EDITOR' +IMAGE_SPACE_TYPE = 'IMAGE_EDITOR' + +log = logger.get(__name__) + +#################### +# - Global Collection Handling +#################### +MANAGED_COLLECTION_NAME = 'BLMaxwell' +PREVIEW_COLLECTION_NAME = 'BLMaxwell Visible' + + +def collection(collection_name: str, view_layer_exclude: bool) -> bpy.types.Collection: + # Init the "Managed Collection" + # Ensure Collection exists (and is in the Scene collection) + if collection_name not in bpy.data.collections: + collection = bpy.data.collections.new(collection_name) + bpy.context.scene.collection.children.link(collection) + else: + collection = bpy.data.collections[collection_name] + + ## Ensure synced View Layer exclusion + if ( + layer_collection := bpy.context.view_layer.layer_collection.children[ + collection_name + ] + ).exclude != view_layer_exclude: + layer_collection.exclude = view_layer_exclude + + return collection + + +def managed_collection() -> bpy.types.Collection: + return collection(MANAGED_COLLECTION_NAME, view_layer_exclude=True) + + +def preview_collection() -> bpy.types.Collection: + return collection(PREVIEW_COLLECTION_NAME, view_layer_exclude=False) + + +#################### +# - Global Collection Handling +#################### +class PreviewsFlow(pyd.BaseModel): + """Represents global references to previewable entries.""" + + model_config = pyd.ConfigDict(frozen=True) + + bl_image_name: str | None = None + bl_object_names: frozenset[str] = frozenset() + + #################### + # - Operations + #################### + def __or__(self, other: typ.Self) -> typ.Self: + return PreviewsFlow( + bl_image_name=other.bl_image_name, + bl_object_names=self.bl_object_names | other.bl_object_names, + ) + + #################### + # - Image Editor UX + #################### + @classmethod + def preview_area(cls) -> bpy.types.Area | None: + """Deduces a Blender UI area that can be used for image preview. + + Returns: + A Blender UI area, if an appropriate one is visible; else `None`, + """ + valid_areas = [ + area for area in bpy.context.screen.areas if area.type == IMAGE_AREA_TYPE + ] + if valid_areas: + return valid_areas[0] + return None + + @classmethod + def preview_space(cls) -> bpy.types.SpaceProperties | None: + """Deduces a Blender UI space, within `self.preview_area`, that can be used for image preview. + + Returns: + A Blender UI space within `self.preview_area`, if it isn't None; else, `None`. + """ + preview_area = cls.preview_area() + if preview_area is not None: + valid_spaces = [ + space for space in preview_area.spaces if space.type == IMAGE_SPACE_TYPE + ] + if valid_spaces: + return valid_spaces[0] + return None + return None + + #################### + # - Preview Plot + #################### + @classmethod + def hide_image_preview(cls) -> None: + """Show all image previews in the first available image editor. + + If no image editors are visible, then nothing happens. + """ + preview_space = cls.preview_space() + if preview_space is not None and preview_space.image is not None: + cls.preview_space().image = None + + def update_image_preview(self) -> None: + """Show the image preview in the first available image editor. + + If no image editors are visible, then nothing happens. + """ + preview_space = self.preview_space() + if self.bl_image_name is not None: + bl_image = bpy.data.images.get(self.bl_image_name) + + # Replace Preview Space Image + if ( + bl_image is not None + and preview_space is not None + and preview_space.image is not bl_image + ): + preview_space.image = bl_image + + # Remove Image + if bl_image is None and preview_space.image is not None: + preview_space.image = None + elif preview_space.image is not None: + preview_space.image = None + + #################### + # - Preview Objects + #################### + @classmethod + def hide_bl_object_previews(cls) -> None: + """Hide all previewed Blender objects.""" + for bl_object_name in [obj.name for obj in preview_collection().objects]: + bl_object = bpy.data.objects.get(bl_object_name) + if bl_object is not None and bl_object.name in preview_collection().objects: + preview_collection().objects.unlink(bl_object) + + def update_bl_object_previews(self) -> frozenset[str]: + """Preview objects that need previewing and unpreview objects that need unpreviewing. + + Designed to utilize the least possible amount of operations to achieve a desired set of previewed objects, from the current set of previewed objects. + """ + # Deduce Change in Previewed Objects + ## -> Examine the preview collection for all currently previewed names. + ## -> Preview names that shouldn't exist should be added. + ## -> Preview names that should exist should be removed. + previewed_bl_objects = {obj.name for obj in preview_collection().objects} + bl_objects_to_remove = previewed_bl_objects - self.bl_object_names + bl_objects_to_add = self.bl_object_names - previewed_bl_objects + + # Remove Previews + ## -> Never presume that bl_object is already defined. + for bl_object_name in bl_objects_to_remove: + bl_object = bpy.data.objects.get(bl_object_name) + if bl_object is not None and bl_object.name in preview_collection().objects: + preview_collection().objects.unlink(bl_object) + + # Add Previews + ## -> Never presume that bl_object is already defined. + for bl_object_name in bl_objects_to_add: + bl_object = bpy.data.objects.get(bl_object_name) + if ( + bl_object is not None + and bl_object.name not in preview_collection().objects + ): + preview_collection().objects.link(bl_object) diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/node_types.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/node_types.py index 0102a5f..fc12eeb 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/node_types.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/node_types.py @@ -38,6 +38,7 @@ class NodeType(blender_type_enum.BlenderTypeEnum): Scene = enum.auto() ## Inputs / Constants ExprConstant = enum.auto() + SymbolConstant = enum.auto() ScientificConstant = enum.auto() UnitSystemConstant = enum.auto() BlenderConstant = enum.auto() 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 a702e43..6243641 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 @@ -19,7 +19,7 @@ import typing as typ import bpy -from blender_maxwell.utils import logger +from blender_maxwell.utils import logger, serialize from . import contracts as ct from .managed_objs.managed_bl_image import ManagedBLImage 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 4240d60..0c9c8d0 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 @@ -86,11 +86,13 @@ def extract_info(monitor_data, monitor_attr: str) -> ct.InfoFlow | None: # noqa if xarr is None: return None - def mk_idx_array(axis: str) -> ct.ArrayFlow: - return ct.ArrayFlow( - values=xarr.get_index(axis).values, - unit=symbols[axis].unit, - is_sorted=True, + def mk_idx_array(axis: str) -> ct.RangeFlow | ct.ArrayFlow: + return ct.RangeFlow.try_from_array( + ct.ArrayFlow( + values=xarr.get_index(axis).values, + unit=symbols[axis].unit, + is_sorted=True, + ) ) # Compute InfoFlow from XArray 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 f9a33c1..c90dbe5 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 @@ -124,12 +124,12 @@ class FilterOperation(enum.StrEnum): # - Computed Properties #################### @property - def func_args(self) -> list[spux.MathType]: + def func_args(self) -> list[sim_symbols.SimSymbol]: FO = FilterOperation return { # Pin - FO.Pin: [spux.MathType.Integer], - FO.PinIdx: [spux.MathType.Integer], + FO.Pin: [sim_symbols.idx(None)], + FO.PinIdx: [sim_symbols.idx(None)], }.get(self, []) #################### @@ -155,10 +155,10 @@ class FilterOperation(enum.StrEnum): match self: # Slice case FO.Slice: - return [dim for dim in info.dims if not dim.has_idx_labels(dim)] + return [dim for dim in info.dims if not info.has_idx_labels(dim)] case FO.SliceIdx: - return [dim for dim in info.dims if not dim.has_idx_labels(dim)] + return [dim for dim in info.dims if not info.has_idx_labels(dim)] # Pin case FO.PinLen1: @@ -272,10 +272,15 @@ class FilterMathNode(base.MaxwellSimNode): # - Properties: Expr InfoFlow #################### @events.on_value_changed( + # Trigger socket_name={'Expr'}, + # Loaded input_sockets={'Expr'}, input_socket_kinds={'Expr': ct.FlowKind.Info}, input_sockets_optional={'Expr': True}, + # Flow + ## -> See docs in TransformMathNode + stop_propagation=True, ) def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 has_info = not ct.FlowSignal.check(input_sockets['Expr']) @@ -593,11 +598,17 @@ class FilterMathNode(base.MaxwellSimNode): pinned_value, require_sorted=True ) - return params.compose_within(enclosing_func_args=[nearest_idx_to_value]) + return params.compose_within( + enclosing_arg_targets=[sim_symbols.idx(None)], + enclosing_func_args=[sp.S(nearest_idx_to_value)], + ) # Pin by-Index if props['operation'] is FilterOperation.PinIdx and has_pinned_axis: - return params.compose_within(enclosing_func_args=[pinned_axis]) + return params.compose_within( + enclosing_arg_targets=[sim_symbols.idx(None)], + enclosing_func_args=[sp.S(pinned_axis)], + ) return params 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 fde2938..7529a18 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 @@ -236,7 +236,7 @@ class MapOperation(enum.StrEnum): MO.Sinc: lambda expr: sp.sinc(expr), # By Vector # Vector -> Number - MO.Norm2: lambda expr: sp.sqrt(expr.T @ expr), + MO.Norm2: lambda expr: sp.sqrt(expr.T @ expr)[0], # By Matrix # Matrix -> Number MO.Det: lambda expr: sp.det(expr), @@ -467,10 +467,15 @@ class MapMathNode(base.MaxwellSimNode): # - Properties #################### @events.on_value_changed( + # Trigger socket_name={'Expr'}, + # Loaded input_sockets={'Expr'}, input_socket_kinds={'Expr': ct.FlowKind.Info}, input_sockets_optional={'Expr': True}, + # Flow + ## -> See docs in TransformMathNode + stop_propagation=True, ) def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 has_info = not ct.FlowSignal.check(input_sockets['Expr']) 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 703ef77..578be8f 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 @@ -210,7 +210,7 @@ class BinaryOperation(enum.StrEnum): ): ops += [BO.Cross] - return ops + return ops_el_el + ops ## Vector | Matrix case (1, 2): @@ -374,10 +374,15 @@ class OperateMathNode(base.MaxwellSimNode): # - Properties #################### @events.on_value_changed( + # Trigger socket_name={'Expr L', 'Expr R'}, + # Loaded input_sockets={'Expr L', 'Expr R'}, input_socket_kinds={'Expr L': ct.FlowKind.Info, 'Expr R': ct.FlowKind.Info}, input_sockets_optional={'Expr L': True, 'Expr R': True}, + # Flow + ## -> See docs in TransformMathNode + stop_propagation=True, ) def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 has_info_l = not ct.FlowSignal.check(input_sockets['Expr L']) 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 ec9bfd1..f869326 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 @@ -17,7 +17,6 @@ """Declares `TransformMathNode`.""" import enum -import functools import typing as typ import bpy @@ -39,13 +38,25 @@ log = logger.get(__name__) # - Operation Enum #################### class TransformOperation(enum.StrEnum): - """Valid operations for the `MapMathNode`. + """Valid operations for the `TransformMathNode`. Attributes: - FreqToVacWL: Transform frequency axes to be indexed by vacuum wavelength. - VacWLToFreq: Transform vacuum wavelength axes to be indexed by frequency. - FFT: Compute the fourier transform of the input expression. - InvFFT: Compute the inverse fourier transform of the input expression. + FreqToVacWL: Transform an frequency dimension to vacuum wavelength. + VacWLToFreq: Transform a vacuum wavelength dimension to frequency. + ConvertIdxUnit: Convert the unit of a dimension to a compatible unit. + SetIdxUnit: Set all properties of a dimension. + FirstColToFirstIdx: Extract the first data column and set the first dimension's index array equal to it. + **For 2D integer-indexed data only**. + + IntDimToComplex: Fold a last length-2 integer dimension into the output, transforming it from a real-like type to complex type. + DimToVec: Fold the last dimension into the scalar output, creating a vector output type. + DimsToMat: Fold the last two dimensions into the scalar output, creating a matrix output type. + FT: Compute the 1D fourier transform along a dimension. + New dimensional bounds are computing using the Nyquist Limit. + For higher dimensions, simply repeat along more dimensions. + InvFT1D: Compute the inverse 1D fourier transform along a dimension. + New dimensional bounds are computing using the Nyquist Limit. + For higher dimensions, simply repeat along more dimensions. """ # Covariant Transform @@ -79,7 +90,7 @@ class TransformOperation(enum.StrEnum): TO.VacWLToFreq: 'Ī»įµ„ ā†’ š‘“', TO.ConvertIdxUnit: 'Convert Dim', TO.SetIdxUnit: 'Set Dim', - TO.FirstColToFirstIdx: '1st Col ā†’ Dim', + TO.FirstColToFirstIdx: '1st Col ā†’ 1st Dim', # Fold TO.IntDimToComplex: 'ā†’ ā„‚', TO.DimToVec: 'ā†’ Vector', @@ -87,10 +98,14 @@ class TransformOperation(enum.StrEnum): ## TODO: Vector to new last-dim integer ## TODO: Matrix to two last-dim integers # Fourier - TO.FT1D: 'ā†’ š‘“', - TO.InvFT1D: 'š‘“ ā†’', + TO.FT1D: 'FT', + TO.InvFT1D: 'iFT', }[value] + @property + def name(self) -> str: + return TransformOperation.to_name(self) + @staticmethod def to_icon(_: typ.Self) -> str: return '' @@ -108,49 +123,32 @@ class TransformOperation(enum.StrEnum): #################### # - Methods #################### - @property - def num_dim_inputs(self) -> None: - """The number of axes that should be passed as inputs to `func_jax` when evaluating it. - - Especially useful for `ParamFlow`, when deciding whether to pass an integer-axis argument based on a user-selected dimension. - """ - TO = TransformOperation - return { - # Covariant Transform - TO.FreqToVacWL: 1, - TO.VacWLToFreq: 1, - TO.ConvertIdxUnit: 1, - TO.SetIdxUnit: 1, - TO.FirstColToFirstIdx: 0, - # Fold - TO.IntDimToComplex: 0, - TO.DimToVec: 0, - TO.DimsToMat: 0, - ## TODO: Vector to new last-dim integer - ## TODO: Matrix to two last-dim integers - # Fourier - TO.FT1D: 1, - TO.InvFT1D: 1, - }[self] - def valid_dims(self, info: ct.InfoFlow) -> list[typ.Self]: TO = TransformOperation match self: - case TO.FreqToVacWL | TO.FT1D: + case TO.FreqToVacWL: return [ dim for dim in info.dims if dim.physical_type is spux.PhysicalType.Freq ] - case TO.VacWLToFreq | TO.InvFT1D: + case TO.VacWLToFreq: return [ dim for dim in info.dims if dim.physical_type is spux.PhysicalType.Length ] - case TO.ConvertIdxUnit | TO.SetIdxUnit: + case TO.ConvertIdxUnit: + return [ + dim + for dim in info.dims + if not info.has_idx_labels(dim) + and spux.PhysicalType.from_unit(dim.unit, optional=True) is not None + ] + + case TO.SetIdxUnit: return [dim for dim in info.dims if not info.has_idx_labels(dim)] ## ColDimToComplex: Implicit Last Dimension @@ -198,13 +196,11 @@ class TransformOperation(enum.StrEnum): # Fold ## Last Dim -> Complex if ( - info.dims - # Output is Int|Rat|Real + len(info.dims) >= 1 and ( info.output.mathtype in [spux.MathType.Integer, spux.MathType.Rational, spux.MathType.Real] ) - # Last Axis is Integer of Length 2 and info.last_dim.mathtype is spux.MathType.Integer and info.has_idx_labels(info.last_dim) and len(info.dims[info.last_dim]) == 2 # noqa: PLR2004 @@ -231,14 +227,13 @@ class TransformOperation(enum.StrEnum): #################### # - Function Properties #################### - @functools.cached_property - def jax_func(self): + def jax_func(self, axis: int | None = None): TO = TransformOperation return { # Covariant Transform ## -> Freq <-> WL is a rescale (noop) AND flip (not noop). - TO.FreqToVacWL: lambda expr, axis: jnp.flip(expr, axis=axis), - TO.VacWLToFreq: lambda expr, axis: jnp.flip(expr, axis=axis), + TO.FreqToVacWL: lambda expr: jnp.flip(expr, axis=axis), + TO.VacWLToFreq: lambda expr: jnp.flip(expr, axis=axis), TO.ConvertIdxUnit: lambda expr: expr, TO.SetIdxUnit: lambda expr: expr, TO.FirstColToFirstIdx: lambda expr: jnp.delete(expr, 0, axis=1), @@ -250,8 +245,8 @@ class TransformOperation(enum.StrEnum): TO.DimToVec: lambda expr: expr, TO.DimsToMat: lambda expr: expr, # Fourier - TO.FT1D: lambda expr, axis: jnp.fft(expr, axis=axis), - TO.InvFT1D: lambda expr, axis: jnp.ifft(expr, axis=axis), + TO.FT1D: lambda expr: jnp.fft(expr, axis=axis), + TO.InvFT1D: lambda expr: jnp.ifft(expr, axis=axis), }[self] def transform_info( @@ -268,25 +263,21 @@ class TransformOperation(enum.StrEnum): # Covariant Transform TO.FreqToVacWL: lambda: info.replace_dim( (f_dim := dim), - [ - sim_symbols.wl(unit), - info.dims[f_dim].rescale( - lambda el: sci_constants.vac_speed_of_light / el, - reverse=True, - new_unit=unit, - ), - ], + sim_symbols.wl(unit), + info.dims[f_dim].rescale( + lambda el: sci_constants.vac_speed_of_light / el, + reverse=True, + new_unit=unit, + ), ), TO.VacWLToFreq: lambda: info.replace_dim( (wl_dim := dim), - [ - sim_symbols.freq(unit), - info.dims[wl_dim].rescale( - lambda el: sci_constants.vac_speed_of_light / el, - reverse=True, - new_unit=unit, - ), - ], + sim_symbols.freq(unit), + info.dims[wl_dim].rescale( + lambda el: sci_constants.vac_speed_of_light / el, + reverse=True, + new_unit=unit, + ), ), TO.ConvertIdxUnit: lambda: info.replace_dim( dim, @@ -300,7 +291,9 @@ class TransformOperation(enum.StrEnum): TO.SetIdxUnit: lambda: info.replace_dim( dim, dim.update( - sym_name=new_dim_name, physical_type=physical_type, unit=unit + sym_name=new_dim_name, + physical_type=physical_type, + unit=unit, ), ( info.dims[dim].correct_unit(unit) @@ -311,10 +304,12 @@ class TransformOperation(enum.StrEnum): TO.FirstColToFirstIdx: lambda: info.replace_dim( info.first_dim, info.first_dim.update( + sym_name=new_dim_name, mathtype=spux.MathType.from_jax_array(data_col), + physical_type=physical_type, unit=unit, ), - ct.ArrayFlow(values=data_col, unit=unit), + ct.RangeFlow.try_from_array(ct.ArrayFlow(values=data_col, unit=unit)), ).slice_dim(info.last_dim, (1, len(info.dims[info.last_dim]), 1)), # Fold TO.IntDimToComplex: lambda: info.delete_dim(info.last_dim).update_output( @@ -380,10 +375,18 @@ class TransformMathNode(base.MaxwellSimNode): # - Properties: Expr InfoFlow #################### @events.on_value_changed( + # Trigger socket_name={'Expr'}, + # Loaded input_sockets={'Expr'}, input_socket_kinds={'Expr': ct.FlowKind.Info}, input_sockets_optional={'Expr': True}, + # Flow + ## -> Expr wants to emit DataChanged, which is usually fine. + ## -> However, this node sets `expr_info`, which causes DC to emit. + ## -> One action should emit one DataChanged pipe. + ## -> Therefore, defer responsibility for DataChanged to self.expr_info. + stop_propagation=True, ) def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 has_info = not ct.FlowSignal.check(input_sockets['Expr']) @@ -440,7 +443,7 @@ class TransformMathNode(base.MaxwellSimNode): @bl_cache.cached_bl_property(depends_on={'expr_info', 'active_dim'}) def dim(self) -> sim_symbols.SimSymbol | None: if self.expr_info is not None and self.active_dim is not None: - return self.expr_info.dim_by_name(self.active_dim) + return self.expr_info.dim_by_name(self.active_dim, optional=True) return None #################### @@ -454,48 +457,52 @@ class TransformMathNode(base.MaxwellSimNode): ) active_new_unit: enum.StrEnum = bl_cache.BLField( enum_cb=lambda self, _: self.search_units(), - cb_depends_on={'dim', 'new_physical_type'}, + cb_depends_on={'dim', 'new_physical_type', 'operation'}, ) def search_units(self) -> list[ct.BLEnumElement]: - if self.dim is not None: - if self.dim.physical_type is not spux.PhysicalType.NonPhysical: - unit_name = sp.sstr(self.dim.unit) - return [ - ( - sp.sstr(unit), - spux.sp_to_str(unit), - sp.sstr(unit), - '', - 0, - ) - for unit in self.dim.physical_type.valid_units - ] - - if self.dim.unit is not None: - unit_name = sp.sstr(self.dim.unit) - return [ - ( - unit_name, - spux.sp_to_str(self.dim.unit), - unit_name, - '', - 0, - ) - ] - if self.new_physical_type is not spux.PhysicalType.NonPhysical: - return [ - ( - sp.sstr(unit), - spux.sp_to_str(unit), - sp.sstr(unit), - '', - i, + TO = TransformOperation + match self.operation: + # Covariant Transform + case TO.ConvertIdxUnit if self.dim is not None: + physical_type = spux.PhysicalType.from_unit( + self.dim.unit, optional=True ) - for i, unit in enumerate(self.new_physical_type.valid_units) - ] + if physical_type is not None: + valid_units = physical_type.valid_units + else: + valid_units = [] - return [] + case TO.FreqToVacWL if self.dim is not None: + valid_units = spux.PhysicalType.Length.valid_units + + case TO.VacWLToFreq if self.dim is not None: + valid_units = spux.PhysicalType.Freq.valid_units + + case TO.SetIdxUnit if ( + self.dim is not None + and self.new_physical_type is not spux.PhysicalType.NonPhysical + ): + valid_units = self.new_physical_type.valid_units + + case TO.FirstColToFirstIdx if ( + self.new_physical_type is not spux.PhysicalType.NonPhysical + ): + valid_units = self.new_physical_type.valid_units + + case _: + valid_units = [] + + return [ + ( + sp.sstr(unit), + spux.sp_to_str(unit), + sp.sstr(unit), + '', + i, + ) + for i, unit in enumerate(valid_units) + ] @bl_cache.cached_bl_property(depends_on={'active_new_unit'}) def new_unit(self) -> spux.Unit: @@ -507,30 +514,85 @@ class TransformMathNode(base.MaxwellSimNode): #################### # - UI #################### - def draw_label(self): - if self.operation is not None: - return 'T: ' + TransformOperation.to_name(self.operation) + @bl_cache.cached_bl_property(depends_on={'new_unit'}) + def new_unit_str(self) -> str: + if self.new_unit is None: + return '' + return spux.sp_to_str(self.new_unit) - return self.bl_label + def draw_label(self): + TO = TransformOperation + match self.operation: + case TO.FreqToVacWL if self.dim is not None: + return f'T: {self.dim.name_pretty} | š‘“ ā†’ {self.new_unit_str}' + + case TO.VacWLToFreq if self.dim is not None: + return f'T: {self.dim.name_pretty} | Ī»įµ„ ā†’ {self.new_unit_str}' + + case TO.ConvertIdxUnit if self.dim is not None: + return f'T: {self.dim.name_pretty} ā†’ {self.new_unit_str}' + + case TO.SetIdxUnit if self.dim is not None: + return f'T: {self.dim.name_pretty} ā†’ {self.new_name.name_pretty} | {self.new_unit_str}' + + case ( + TO.IntDimToComplex + | TO.DimToVec + | TO.DimsToMat + ) if self.expr_info is not None and self.expr_info.dims: + return f'T: {self.expr_info.last_dim.name_unit_label} {self.operation.name}' + + case TO.FT1D if self.dim is not None: + return f'T: FT[{self.dim.name_unit_label}]' + + case TO.InvFT1D if self.dim is not None: + return f'T: iFT[{self.dim.name_unit_label}]' + + case _: + if self.operation is not None: + return f'T: {self.operation.name}' + return self.bl_label def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout) -> None: layout.prop(self, self.blfields['operation'], text='') - if self.operation is not None and self.operation.num_dim_inputs == 1: - TO = TransformOperation - layout.prop(self, self.blfields['active_dim'], text='') + TO = TransformOperation + match self.operation: + case TO.ConvertIdxUnit: + row = layout.row(align=True) + row.prop(self, self.blfields['active_dim'], text='') + row.prop(self, self.blfields['active_new_unit'], text='') - if self.operation in [TO.ConvertIdxUnit, TO.SetIdxUnit]: + case TO.FreqToVacWL: + row = layout.row(align=True) + row.prop(self, self.blfields['active_dim'], text='') + row.prop(self, self.blfields['active_new_unit'], text='') + + case TO.VacWLToFreq: + row = layout.row(align=True) + row.prop(self, self.blfields['active_dim'], text='') + row.prop(self, self.blfields['active_new_unit'], text='') + + case TO.SetIdxUnit: + row = layout.row(align=True) + row.prop(self, self.blfields['active_dim'], text='') + row.prop(self, self.blfields['new_name'], text='') + + row = layout.row(align=True) + row.prop(self, self.blfields['new_physical_type'], text='') + row.prop(self, self.blfields['active_new_unit'], text='') + + case TO.FirstColToFirstIdx: col = layout.column(align=True) - if self.operation is TransformOperation.ConvertIdxUnit: - col.prop(self, self.blfields['active_new_unit'], text='') + row = col.row(align=True) + row.prop(self, self.blfields['new_name'], text='') + row.prop(self, self.blfields['active_new_unit'], text='') - if self.operation is TransformOperation.SetIdxUnit: - col.prop(self, self.blfields['new_physical_type'], text='') + row = col.row(align=True) + row.prop(self, self.blfields['new_physical_type'], text='') - row = col.row(align=True) - row.prop(self, self.blfields['new_name'], text='') - row.prop(self, self.blfields['active_new_unit'], text='') + case TO.FT1D | TO.InvFT1D: + layout.prop(self, self.blfields['active_dim'], text='') #################### # - Compute: Func / Array @@ -538,23 +600,43 @@ class TransformMathNode(base.MaxwellSimNode): @events.computes_output_socket( 'Expr', kind=ct.FlowKind.Func, - props={'operation'}, + # Loaded + props={'operation', 'dim'}, input_sockets={'Expr'}, input_socket_kinds={ - 'Expr': ct.FlowKind.Func, + 'Expr': {ct.FlowKind.Func, ct.FlowKind.Info}, }, ) def compute_func(self, props, input_sockets) -> ct.FuncFlow | ct.FlowSignal: + """Transform the input `InfoFlow` depending on the transform operation.""" + TO = TransformOperation operation = props['operation'] - lazy_func = input_sockets['Expr'] + lazy_func = input_sockets['Expr'][ct.FlowKind.Func] + info = input_sockets['Expr'][ct.FlowKind.Info] + has_info = not ct.FlowSignal.check(info) has_lazy_func = not ct.FlowSignal.check(lazy_func) - if has_lazy_func and operation is not None: - return lazy_func.compose_within( - operation.jax_func, - supports_jax=True, - ) + if operation is not None and has_lazy_func and has_info: + # Retrieve Properties + dim = props['dim'] + + # Match Pattern by Operation + match operation: + case TO.FreqToVacWL | TO.VacWLToFreq | TO.FT1D | TO.InvFT1D: + if dim is not None and info.has_idx_discrete(dim): + return lazy_func.compose_within( + operation.jax_func(axis=info.dim_axis(dim)), + supports_jax=True, + ) + return ct.FlowSignal.FlowPending + + case _: + return lazy_func.compose_within( + operation.jax_func(), + supports_jax=True, + ) + return ct.FlowSignal.FlowPending #################### @@ -563,54 +645,101 @@ class TransformMathNode(base.MaxwellSimNode): @events.computes_output_socket( 'Expr', kind=ct.FlowKind.Info, + # Loaded props={'operation', 'dim', 'new_name', 'new_unit', 'new_physical_type'}, input_sockets={'Expr'}, input_socket_kinds={ 'Expr': {ct.FlowKind.Func, ct.FlowKind.Info, ct.FlowKind.Params} }, ) - def compute_info( + def compute_info( # noqa: PLR0911 self, props: dict, input_sockets: dict ) -> ct.InfoFlow | typ.Literal[ct.FlowSignal.FlowPending]: + """Transform the input `InfoFlow` depending on the transform operation.""" + TO = TransformOperation operation = props['operation'] info = input_sockets['Expr'][ct.FlowKind.Info] has_info = not ct.FlowSignal.check(info) - - dim = props['dim'] - new_name = props['new_name'] - new_unit = props['new_unit'] - new_physical_type = props['new_physical_type'] if has_info and operation is not None: - # First Column to First Index - ## -> We have to evaluate the lazy function at this point. - ## -> It's the only way to get at the column data. - if operation is TransformOperation.FirstColToFirstIdx: - lazy_func = input_sockets['Expr'][ct.FlowKind.Func] - params = input_sockets['Expr'][ct.FlowKind.Params] - has_lazy_func = not ct.FlowSignal.check(lazy_func) - has_params = not ct.FlowSignal.check(lazy_func) + # Retrieve Properties + dim = props['dim'] + new_name = props['new_name'] + new_unit = props['new_unit'] + new_physical_type = props['new_physical_type'] - if has_lazy_func and has_params and not params.symbols: + # Retrieve Expression Data + lazy_func = input_sockets['Expr'][ct.FlowKind.Func] + params = input_sockets['Expr'][ct.FlowKind.Params] + + has_lazy_func = not ct.FlowSignal.check(lazy_func) + has_params = not ct.FlowSignal.check(lazy_func) + + # Match Pattern by Operation + match operation: + # Covariant Transform + ## -> Needs: Dim, Unit + case TO.ConvertIdxUnit if dim is not None and new_unit is not None: + physical_type = spux.PhysicalType.from_unit(dim.unit, optional=True) + if ( + physical_type is not None + and new_unit in physical_type.valid_units + ): + return operation.transform_info(info, dim=dim, unit=new_unit) + return ct.FlowSignal.FlowPending + + case TO.FreqToVacWL if dim is not None and new_unit is not None and new_unit in spux.PhysicalType.Length.valid_units: + return operation.transform_info(info, dim=dim, unit=new_unit) + + case TO.VacWLToFreq if dim is not None and new_unit is not None and new_unit in spux.PhysicalType.Freq.valid_units: + return operation.transform_info(info, dim=dim, unit=new_unit) + + ## -> Needs: Dim, Unit, Physical Type + case TO.SetIdxUnit if ( + dim is not None + and new_physical_type is not None + and new_unit in new_physical_type.valid_units + ): + return operation.transform_info( + info, + dim=dim, + new_dim_name=new_name, + unit=new_unit, + physical_type=new_physical_type, + ) + + ## -> Needs: Data Column, Name, Unit, Physical Type + ## -> We have to evaluate the lazy function at this point. + ## -> It's the only way to get at the column's data. + case TO.FirstColToFirstIdx if ( + has_lazy_func + and has_params + and not params.symbols + and new_name is not None + and new_physical_type is not None + and new_unit in new_physical_type.valid_units + ): data = lazy_func.realize(params) - if data.shape is not None and len(data.shape) == 2: + if data.shape is not None and len(data.shape) == 2: # noqa: PLR2004 data_col = data[:, 0] - return operation.transform_info(info, data_col=data_col) - return ct.FlowSignal.FlowPending + return operation.transform_info( + info, + new_dim_name=new_name, + data_col=data_col, + unit=new_unit, + physical_type=new_physical_type, + ) - # Check Not-Yet-Updated Dimension - ## - Operation changes before dimensions. - ## - If InfoFlow is requested in this interim, big problem. - if dim is None and operation.num_dim_inputs > 0: - return ct.FlowSignal.FlowPending + # Fold + ## -> Needs: Nothing + case TO.IntDimToComplex | TO.DimToVec | TO.DimsToMat: + return operation.transform_info(info) + + # Fourier + ## -> Needs: Dimension + case TO.FT1D | TO.InvFT1D if dim is not None: + return operation.transform_info(info, dim=dim) - return operation.transform_info( - info, - dim=dim, - new_dim_name=new_name, - unit=new_unit, - physical_type=new_physical_type, - ) return ct.FlowSignal.FlowPending #################### @@ -619,30 +748,19 @@ class TransformMathNode(base.MaxwellSimNode): @events.computes_output_socket( 'Expr', kind=ct.FlowKind.Params, - props={'operation', 'dim'}, + # Loaded + props={'operation'}, input_sockets={'Expr'}, - input_socket_kinds={'Expr': {ct.FlowKind.Params, ct.FlowKind.Info}}, + input_socket_kinds={'Expr': ct.FlowKind.Params}, ) def compute_params(self, props, input_sockets) -> ct.ParamsFlow | ct.FlowSignal: - 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) - operation = props['operation'] - dim = props['dim'] - if has_info and has_params and operation is not None: - # Axis Required: Insert by-Dimension - ## -> Some transformations ex. FT require setting an axis. - ## -> The user selects which dimension the op should be done along. - ## -> This dimension is converted to an axis integer. - ## -> Finally, we pass the argument via params. - if operation.num_dim_inputs == 1: - axis = info.dim_axis(dim) if dim is not None else None - return params.compose_within(enclosing_func_args=[axis]) + params = input_sockets['Expr'] + has_params = not ct.FlowSignal.check(params) + if has_params and operation is not None: return params + return ct.FlowSignal.FlowPending 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 378fe24..b901275 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 @@ -104,6 +104,7 @@ class VizMode(enum.StrEnum): """Given the input `InfoFlow`, deduce which visualization modes are valid to use with the described data.""" Z = spux.MathType.Integer R = spux.MathType.Real + C = spux.MathType.Complex VM = VizMode return { @@ -115,6 +116,9 @@ class VizMode(enum.StrEnum): VM.Points2D, VM.Bar, ], + ((R,), (1, 1, C)): [ + VM.Curve2D, + ], ((R, Z), (1, 1, R)): [ VM.Curves2D, VM.FilledCurves2D, @@ -231,10 +235,15 @@ class VizNode(base.MaxwellSimNode): ## - Properties ##################### @events.on_value_changed( + # Trigger socket_name={'Expr'}, + # Loaded input_sockets={'Expr'}, input_socket_kinds={'Expr': ct.FlowKind.Info}, input_sockets_optional={'Expr': True}, + # Flow + ## -> See docs in TransformMathNode + stop_propagation=True, ) def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 has_info = not ct.FlowSignal.check(input_sockets['Expr']) @@ -326,7 +335,7 @@ class VizNode(base.MaxwellSimNode): if self.viz_target is VizTarget.Plot2D: row = col.row(align=True) row.alignment = 'CENTER' - row.label(text='Width/Height/DPI') + row.label(text='Width | Height | DPI') row = col.row(align=True) row.prop(self, self.blfields['plot_width'], text='') @@ -339,8 +348,10 @@ class VizNode(base.MaxwellSimNode): # - Events #################### @events.on_value_changed( + # Trigger socket_name='Expr', run_on_init=True, + # Loaded input_sockets={'Expr'}, input_socket_kinds={'Expr': {ct.FlowKind.Info, ct.FlowKind.Params}}, input_sockets_optional={'Expr': True}, @@ -355,14 +366,19 @@ class VizNode(base.MaxwellSimNode): # Declare Loose Sockets that Realize Symbols ## -> This happens if Params contains not-yet-realized symbols. if has_info and has_params and params.symbols: - if set(self.loose_input_sockets) != { - sym.name for sym in params.symbols if sym in info.dims - }: + if set(self.loose_input_sockets) != {sym.name for sym in params.symbols}: self.loose_input_sockets = { - dim_name: sockets.ExprSocketDef(**expr_info) - for dim_name, expr_info in params.sym_expr_infos( - use_range=True - ).items() + sym.name: sockets.ExprSocketDef( + **( + expr_info + | { + 'active_kind': ct.FlowKind.Range + if sym in info.dims + else ct.FlowKind.Value + } + ) + ) + for sym, expr_info in params.sym_expr_infos.items() } elif self.loose_input_sockets: @@ -373,9 +389,10 @@ class VizNode(base.MaxwellSimNode): ##################### @events.computes_output_socket( 'Preview', - kind=ct.FlowKind.Value, + kind=ct.FlowKind.Previews, # Loaded props={ + 'sim_node_name', 'viz_mode', 'viz_target', 'colormap', @@ -391,7 +408,7 @@ class VizNode(base.MaxwellSimNode): ) def compute_dummy_value(self, props, input_sockets, loose_input_sockets): """Needed for the plot to regenerate in the viewer.""" - return ct.FlowSignal.NoFlow + return ct.PreviewsFlow(bl_image_name=props['sim_node_name']) ##################### ## - On Show Plot @@ -416,6 +433,7 @@ class VizNode(base.MaxwellSimNode): def on_show_plot( self, managed_objs, props, input_sockets, loose_input_sockets ) -> None: + log.critical('Show Plot (too many times)') lazy_func = input_sockets['Expr'][ct.FlowKind.Func] info = input_sockets['Expr'][ct.FlowKind.Info] params = input_sockets['Expr'][ct.FlowKind.Params] @@ -427,23 +445,17 @@ class VizNode(base.MaxwellSimNode): viz_mode = props['viz_mode'] viz_target = props['viz_target'] if has_info and has_params and viz_mode is not None and viz_target is not None: - # Realize Data w/Realized Symbols + # Retrieve Data ## -> The loose input socket values are user-selected symbol values. - ## -> These expressions are used to realize the lazy data. - ## -> `.realize()` ensures all ex. units are correctly conformed. - realized_syms = { - sym: loose_input_sockets[sym.name] for sym in params.sorted_symbols - } - output_data = lazy_func.realize(params, symbol_values=realized_syms) - - data = { - dim: ( - realized_syms[dim].values - if dim in realized_syms - else info.dims[dim] - ) - for dim in info.dims - } | {info.output: output_data} + ## -> These are used to get rid of symbols in the ParamsFlow. + ## -> What's left is a dictionary from SimSymbol -> Data + data = lazy_func.realize_as_data( + info, + params, + symbol_values={ + sym: loose_input_sockets[sym.name] for sym in params.sorted_symbols + }, + ) # Match Viz Type & Perform Visualization ## -> Viz Target determines how to plot. @@ -459,7 +471,6 @@ class VizNode(base.MaxwellSimNode): width_inches=plot_width, height_inches=plot_height, dpi=plot_dpi, - bl_select=True, ) case VizTarget.Pixels: @@ -468,7 +479,6 @@ class VizNode(base.MaxwellSimNode): plot.map_2d_to_image( data, colormap=colormap, - bl_select=True, ) case VizTarget.PixelsPlane: 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 d12d20b..b81504e 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 @@ -21,6 +21,7 @@ Attributes: """ import enum +import functools import typing as typ from collections import defaultdict from types import MappingProxyType @@ -62,7 +63,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): Used as a node-specific cache index. sim_node_name: A unique human-readable name identifying the node. Used when naming managed objects and exporting. - preview_active: Whether the preview (if any) is currently active. locked: Whether the node is currently 'locked' aka. non-editable. """ @@ -98,7 +98,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): 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 @@ -264,35 +263,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): ## 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 - if len(self.event_methods_by_event[ct.FlowEvent.ShowPlot]) > 1: - ## TODO: Is this check good enough? - ## TODO: Set icon/indicator/something to make it clear which node is being previewed. - node_tree.report_show_plot(self) - - @events.on_show_preview() - def _on_show_preview(self): - node_tree = self.id_data - node_tree.report_show_preview(self) - - # Set Preview to Active - ## Implicitly triggers any @on_value_changed for preview_active. - if not self.preview_active: - self.preview_active = True - - @events.on_value_changed( - prop_name='preview_active', props={'preview_active'}, stop_propagation=True - ) - def _on_preview_changed(self, props): - if not props['preview_active']: - for mobj in self.managed_objs.values(): - mobj.hide_preview() - #################### # - Events: Lock #################### @@ -521,14 +491,17 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): return { ct.FlowEvent.EnableLock: lambda *_: True, ct.FlowEvent.DisableLock: lambda *_: True, - ct.FlowEvent.DataChanged: lambda event_method, socket_name, prop_name, _: ( + ct.FlowEvent.DataChanged: lambda event_method, socket_name, prop_names, _: ( ( socket_name and socket_name in event_method.callback_info.on_changed_sockets ) or ( - prop_name - and prop_name in event_method.callback_info.on_changed_props + prop_names + and any( + prop_name in event_method.callback_info.on_changed_props + for prop_name in prop_names + ) ) or ( socket_name @@ -536,6 +509,7 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): and socket_name in self.loose_input_sockets ) ), + # Non-Triggered ct.FlowEvent.OutputRequested: lambda output_socket_method, output_socket_name, _, @@ -546,7 +520,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): == output_socket_method.callback_info.output_socket_name ) ), - ct.FlowEvent.ShowPreview: lambda *_: True, ct.FlowEvent.ShowPlot: lambda *_: True, } @@ -595,6 +568,9 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): bl_socket = self.inputs.get(input_socket_name) if bl_socket is not None: if bl_socket.instance_id: + if kind is ct.FlowKind.Previews: + return bl_socket.compute_data(kind=kind) + return ( ct.FlowKind.scale_to_unit_system( kind, @@ -610,12 +586,10 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): ## -> Anyone needing results will need to wait on preinit(). return ct.FlowSignal.FlowInitializing - # if optional: + if kind is ct.FlowKind.Previews: + return ct.PreviewsFlow() return ct.FlowSignal.NoFlow - msg = f'{self.sim_node_name}: Input socket "{input_socket_name}" cannot be computed, as it is not an active input socket' - raise ValueError(msg) - #################### # - Compute Event: Output Socket #################### @@ -638,33 +612,64 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): The value of the output socket, as computed by the dedicated method registered using the `@computes_output_socket` decorator. """ - if self.outputs.get(output_socket_name) is None: - if optional: - return None + # Previews: Aggregate All Input Sockets + ## -> All PreviewsFlows on all input sockets are combined. + ## -> Output Socket Methods can add additional PreviewsFlows. + if kind is ct.FlowKind.Previews: + input_previews = functools.reduce( + lambda a, b: a | b, + [ + self._compute_input( + socket, kind=ct.FlowKind.Previews, unit_system=None + ) + for socket in [bl_socket.name for bl_socket in self.inputs] + ], + ct.PreviewsFlow(), + ) - msg = f"Can't compute nonexistent output socket name {output_socket_name}, as it's not currently active" - raise RuntimeError(msg) + # No Output Socket: No Flow + ## -> All PreviewsFlows on all input sockets are combined. + ## -> Output Socket Methods can add additional PreviewsFlows. + if self.outputs.get(output_socket_name) is None: + return ct.FlowSignal.NoFlow output_socket_methods = self.filtered_event_methods_by_event( ct.FlowEvent.OutputRequested, (output_socket_name, None, kind), ) - # Run (=1) Method - if output_socket_methods: - if len(output_socket_methods) > 1: - msg = f'More than one method found for ({output_socket_name}, {kind.value!s}.' - raise RuntimeError(msg) + # Exactly One Output Socket Method + ## -> All PreviewsFlows on all input sockets are combined. + ## -> Output Socket Methods can add additional PreviewsFlows. + if len(output_socket_methods) == 1: + res = output_socket_methods[0](self) - return output_socket_methods[0](self) + # Res is PreviewsFlow: Concatenate + ## -> This will add the elements within the returned PreviewsFluw. + if kind is ct.FlowKind.Previews and not ct.FlowSignal.check(res): + input_previews |= res - # Auxiliary Fallbacks + return res + + # > One Output Socket Method: Error + if len(output_socket_methods) > 1: + msg = ( + f'More than one method found for ({output_socket_name}, {kind.value!s}.' + ) + raise RuntimeError(msg) + + if kind is ct.FlowKind.Previews: + return input_previews return ct.FlowSignal.NoFlow - # if optional or kind in [ct.FlowKind.Info, ct.FlowKind.Params]: - # return ct.FlowSignal.NoFlow - # msg = f'No output method for ({output_socket_name}, {kind})' - # raise ValueError(msg) + #################### + # - Plot + #################### + def compute_plot(self): + plot_methods = self.filtered_event_methods_by_event(ct.FlowEvent.ShowPlot, ()) + + for plot_method in plot_methods: + plot_method(self) #################### # - Event Trigger @@ -674,11 +679,11 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): method_info: events.InfoOutputRequested, input_socket_name: ct.SocketName | None, input_socket_kinds: set[ct.FlowKind] | None, - prop_name: str | None, + prop_names: set[str] | None, ) -> bool: return ( - prop_name is not None - and prop_name in method_info.depon_props + prop_names is not None + and any(prop_name in method_info.depon_props for prop_name in prop_names) or input_socket_name is not None and ( input_socket_name in method_info.depon_input_sockets @@ -704,41 +709,63 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): ) @bl_cache.cached_bl_property() - def _dependent_outputs( + def output_socket_invalidates( self, ) -> dict[ tuple[ct.SocketName, ct.FlowKind], set[tuple[ct.SocketName, ct.FlowKind]] ]: - ## TODO: Cleanup - ## TODO: Detect cycles? - ## TODO: Networkx? + """Deduce which output socket | `FlowKind` combos are altered in response to a given output socket | `FlowKind` combo. + + Returns: + A dictionary, wher eeach key is a tuple representing an output socket name and its flow kind that has been altered, and each value is a set of tuples representing output socket names and flow kind. + + Indexing by any particular `(output_socket_name, flow_kind)` will produce a set of all `{(output_socket_name, flow_kind)}` that rely on it. + """ altered_to_invalidated = defaultdict(set) + + # Iterate ALL Methods that Compute Output Sockets + ## -> We call it the "altered method". + ## -> Our approach will be to deduce what relies on it. output_requested_methods = self.event_methods_by_event[ ct.FlowEvent.OutputRequested ] - for altered_method in output_requested_methods: altered_info = altered_method.callback_info altered_key = (altered_info.output_socket_name, altered_info.kind) + # Inner: Iterate ALL Methods that Compute Output Sockets + ## -> We call it the "invalidated method". + ## -> While O(n^2), it runs only once per-node, and is then cached. + ## -> `n` is rarely so large as to be a startup-time concern. + ## -> Thus, in this case, using a simple implementation is better. for invalidated_method in output_requested_methods: invalidated_info = invalidated_method.callback_info + # Check #0: Inv. Socket depends on Altered Socket + ## -> Simply check if the altered name is in the dependencies. if ( altered_info.output_socket_name in invalidated_info.depon_output_sockets ): + # Check #2: FlowKinds Match + ## -> Case 1: Single Altered Kind was Requested by Inv + ## -> Case 2: Altered Kind in set[Requested Kinds] is + ## -> Case 3: Altered Kind is FlowKind.Value + ## This encapsulates the actual events decorator semantics. is_same_kind = ( altered_info.kind - == ( + is ( _kind := invalidated_info.depon_output_socket_kinds.get( altered_info.output_socket_name ) ) or (isinstance(_kind, set) and altered_info.kind in _kind) - or altered_info.kind == ct.FlowKind.Value + or altered_info.kind is ct.FlowKind.Value ) + # Check Success: Add Invalidated (name,kind) to Altered Set + ## -> We've now confirmed a dependency. + ## -> Thus, this name|kind should be included. if is_same_kind: invalidated_key = ( invalidated_info.output_socket_name, @@ -753,7 +780,7 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): event: ct.FlowEvent, socket_name: ct.SocketName | None = None, socket_kinds: set[ct.FlowKind] | None = None, - prop_name: ct.SocketName | None = None, + prop_names: set[str] | None = None, ) -> None: """Recursively triggers events forwards or backwards along the node tree, allowing nodes in the update path to react. @@ -770,124 +797,141 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): socket_name: The input socket that was altered, if any, in order to trigger this event. pop_name: The property that was altered, if any, in order to trigger this event. """ - log.debug( - '%s: Triggered Event %s (socket_name=%s, socket_kinds=%s, prop_name=%s)', - self.sim_node_name, - event, - str(socket_name), - str(socket_kinds), - str(prop_name), - ) - # Outflow Socket Kinds - ## -> Something has happened! - ## -> The effect is yet to be determined... - ## -> We will watch for which kinds actually invalidate. - ## -> ...Then ONLY propagate kinds that have an invalidated outsck. - ## -> This way, kinds get "their own" invalidation chains. - ## -> ...While still respecting "crossovers". - altered_socket_kinds = set() + # log.debug( + # '[%s] [%s] Triggered (socket_name=%s, socket_kinds=%s, prop_names=%s)', + # self.sim_node_name, + # event, + # str(socket_name), + # str(socket_kinds), + # str(prop_names), + # ) # Invalidate Caches on DataChanged + ## -> socket_kinds MUST NOT be None + ## -> Trigger direction is always 'forwards' for DataChanged + ## -> Track which FlowKinds are actually altered per-output-socket. + altered_socket_kinds: dict[ct.SocketName, set[ct.FlowKind]] = defaultdict(set) if event is ct.FlowEvent.DataChanged: - input_socket_name = socket_name ## Trigger direction is forwards + in_sckname = socket_name - # Invalidate Input Socket Cache - if input_socket_name is not None: - if socket_kinds is None: + # Clear Input Socket Cache(s) + ## -> The input socket cache for each altered FlowKinds is cleared. + ## -> Since it's non-persistent, it will be lazily re-filled. + if in_sckname is not None: + for in_kind in socket_kinds: + # log.debug( + # '![%s] Clear Input Socket Cache (%s, %s)', + # self.sim_node_name, + # in_sckname, + # in_kind, + # ) self._compute_input.invalidate( - input_socket_name=input_socket_name, - kind=..., + input_socket_name=in_sckname, + kind=in_kind, unit_system=..., ) - else: - for socket_kind in socket_kinds: - self._compute_input.invalidate( - input_socket_name=input_socket_name, - kind=socket_kind, - unit_system=..., - ) - # Invalidate Output Socket Cache + # Clear Output Socket Cache(s) for output_socket_method in self.event_methods_by_event[ ct.FlowEvent.OutputRequested ]: + # Determine Consequences of Changed (Socket|Kind) / Prop + ## -> Each '@computes_output_socket' declares data to load. + ## -> Compare what was changed to what each output socket needs. + ## -> IF what is needed, was changed, THEN: + ## --- The output socket needs recomputing. method_info = output_socket_method.callback_info if self._should_recompute_output_socket( - method_info, socket_name, socket_kinds, prop_name + method_info, socket_name, socket_kinds, prop_names ): out_sckname = method_info.output_socket_name - kind = method_info.kind + out_kind = method_info.kind - # Invalidate Output Directly - # log.critical( - # '[%s] Invalidating: (%s, %s)', + # log.debug( + # '![%s] Clear Output Socket Cache (%s, %s)', # self.sim_node_name, # out_sckname, - # str(kind), + # out_kind, # ) - altered_socket_kinds.add(kind) self.compute_output.invalidate( output_socket_name=out_sckname, - kind=kind, + kind=out_kind, ) + altered_socket_kinds[out_sckname].add(out_kind) - # Invalidate Any Dependent Outputs - if ( - dep_outs := self._dependent_outputs.get((out_sckname, kind)) - ) is not None: - for dep_out in dep_outs: - # log.critical( - # '![%s] Invalidating: (%s, %s)', + # Invalidate Dependent Output Sockets + ## -> Other outscks may depend on the altered outsck. + ## -> The property 'output_socket_invalidates' encodes this. + ## -> The property 'output_socket_invalidates' encodes this. + cleared_outscks_kinds = self.output_socket_invalidates.get( + (out_sckname, out_kind) + ) + if cleared_outscks_kinds is not None: + for dep_out_sckname, dep_out_kind in cleared_outscks_kinds: + # log.debug( + # '!![%s] Clear Output Socket Cache (%s, %s)', # self.sim_node_name, - # dep_out[0], - # dep_out[1], + # out_sckname, + # out_kind, # ) - altered_socket_kinds.add(dep_out[1]) self.compute_output.invalidate( - output_socket_name=dep_out[0], - kind=dep_out[1], + output_socket_name=dep_out_sckname, + kind=dep_out_kind, ) + altered_socket_kinds[dep_out_sckname].add(dep_out_kind) # Run Triggered Event Methods + ## -> A triggered event method may request to stop propagation. + ## -> A triggered event method may request to stop propagation. stop_propagation = False triggered_event_methods = self.filtered_event_methods_by_event( - event, (socket_name, prop_name, None) + event, (socket_name, prop_names, None) ) for event_method in triggered_event_methods: stop_propagation |= event_method.stop_propagation - # log.critical( - # '%s: Running %s', + # log.debug( + # '![%s] Running: %s', # self.sim_node_name, # str(event_method.callback_info), # ) event_method(self) - # DataChanged Propagation Stop: No Altered Socket Kinds - ## -> If no FlowKinds were altered, then propagation makes no sense. - ## -> Semantically, **nothing has changed** == no DataChanged! - if event is ct.FlowEvent.DataChanged and not altered_socket_kinds: - return - - # Constrain ShowPlot to First Node: Workaround - if event is ct.FlowEvent.ShowPlot: - return - - # Propagate Event to All Sockets in "Trigger Direction" + # Propagate Event + ## -> If 'stop_propagation' was tripped, don't propagate. + ## -> If no sockets were altered during DataChanged, don't propagate. + ## -> Each FlowEvent decides whether to flow forwards/backwards. ## -> The trigger chain goes node/socket/socket/node/socket/... + ## -> Unlinked sockets naturally stop the propagation. if not stop_propagation: direc = ct.FlowEvent.flow_direction[event] - triggered_sockets = self._bl_sockets(direc=direc) - for bl_socket in triggered_sockets: - if direc == 'output' and not bl_socket.is_linked: - continue + for bl_socket in self._bl_sockets(direc=direc): + # DataChanged: Propagate Altered SocketKinds + ## -> Only altered FlowKinds for the socket will propagate. + ## -> In this way, we guarantee no extraneous (noop) flow. + if event is ct.FlowEvent.DataChanged: + if bl_socket.name in altered_socket_kinds: + # log.debug( + # '![%s] [%s] Propagating (direction=%s, altered_socket_kinds=%s)', + # self.sim_node_name, + # event, + # direc, + # altered_socket_kinds[bl_socket.name], + # ) + bl_socket.trigger_event( + event, socket_kinds=altered_socket_kinds[bl_socket.name] + ) - # log.critical( - # '![%s] Propagating: (%s, %s)', - # self.sim_node_name, - # event, - # altered_socket_kinds, - # ) - bl_socket.trigger_event(event, socket_kinds=altered_socket_kinds) + ## -> Otherwise, do nothing - guarantee no extraneous flow. + + # Propagate Normally + else: + # log.debug( + # '![%s] [%s] Propagating (direction=%s)', + # self.sim_node_name, + # event, + # direc, + # ) + bl_socket.trigger_event(event) #################### # - Property Event: On Update @@ -903,18 +947,22 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance): Parameters: prop_name: The name of the property that changed. """ - # All Attributes: Trigger Event - ## -> This declares that the single property has changed. - ## -> This should happen first, in case dependents need a cache. - if hasattr(self, prop_name): - self.trigger_event(ct.FlowEvent.DataChanged, prop_name=prop_name) - # BLField Attributes: Invalidate BLField Dependents - ## -> Dependent props will generally also trigger on_prop_changed. - ## -> The recursion ends with the depschain. + ## -> All invalidated blfields will have their caches cleared. + ## -> The (topologically) ordered list of cleared blfields is returned. ## -> WARNING: The chain is not checked for ex. cycles. if prop_name in self.blfields: - self.invalidate_blfield_deps(prop_name) + cleared_blfields = self.clear_blfields_after(prop_name) + + # log.debug( + # '%s (Node): Set of Cleared BLFields: %s', + # self.bl_label, + # str(cleared_blfields), + # ) + self.trigger_event( + ct.FlowEvent.DataChanged, + prop_names={prop_name for prop_name, _ in cleared_blfields}, + ) #################### # - UI Methods diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/__init__.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/__init__.py index f02646f..502529a 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/__init__.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/__init__.py @@ -18,15 +18,18 @@ from . import ( blender_constant, expr_constant, scientific_constant, + symbol_constant, ) BL_REGISTER = [ *expr_constant.BL_REGISTER, + *symbol_constant.BL_REGISTER, *scientific_constant.BL_REGISTER, *blender_constant.BL_REGISTER, ] BL_NODES = { **expr_constant.BL_NODES, + **symbol_constant.BL_NODES, **scientific_constant.BL_NODES, **blender_constant.BL_NODES, } diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/scientific_constant.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/scientific_constant.py index 4870cac..8aac265 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/scientific_constant.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/scientific_constant.py @@ -76,13 +76,13 @@ class ScientificConstantNode(base.MaxwellSimNode): """Retrieve a symbol for the scientific constant.""" if self.sci_constant is not None and self.sci_constant_info is not None: unit = self.sci_constant_info['units'] - return sim_symbols.SimSymbol( - sym_name=self.sci_constant_name, - mathtype=spux.MathType.from_expr(self.sci_constant), - # physical_type= ## TODO: Formalize unit w/o physical_type - unit=unit, + return sim_symbols.SimSymbol.from_expr( + self.sci_constant_name, + self.sci_constant, + unit, is_constant=True, ) + return None #################### @@ -125,7 +125,7 @@ class ScientificConstantNode(base.MaxwellSimNode): if self.sci_constant_info: row = _col.row(align=True) # row.alignment = 'CENTER' - row.label(text=f'{self.sci_constant_info["units"]}') + row.label(text=f'{spux.sp_to_str(self.sci_constant_info["units"].n(4))}') row = _col.row(align=True) # row.alignment = 'CENTER' @@ -184,13 +184,18 @@ class ScientificConstantNode(base.MaxwellSimNode): @events.computes_output_socket( 'Expr', kind=ct.FlowKind.Params, - props={'sci_constant'}, + props={'sci_constant', 'sci_constant_sym'}, ) def compute_params(self, props: dict) -> typ.Any: sci_constant = props['sci_constant'] + sci_constant_sym = props['sci_constant_sym'] - if sci_constant is not None: - return ct.ParamsFlow(func_args=[sci_constant]) + if sci_constant is not None and sci_constant_sym is not None: + return ct.ParamsFlow( + arg_targets=[sci_constant_sym], + func_args=[sci_constant], + is_differentiable=True, + ) return ct.FlowSignal.FlowPending diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/symbol_constant.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/symbol_constant.py new file mode 100644 index 0000000..240f16e --- /dev/null +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/constants/symbol_constant.py @@ -0,0 +1,266 @@ +# 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 typing as typ + +import bpy +import sympy as sp + +from blender_maxwell.utils import bl_cache, logger, sim_symbols +from blender_maxwell.utils import extra_sympy_units as spux + +from .... import contracts as ct +from .... import sockets +from ... import base, events + +log = logger.get(__name__) + + +class SymbolConstantNode(base.MaxwellSimNode): + node_type = ct.NodeType.SymbolConstant + bl_label = 'Symbol' + + input_sockets: typ.ClassVar = {} + output_sockets: typ.ClassVar = { + 'Expr': sockets.ExprSocketDef( + active_kind=ct.FlowKind.Func, + show_info_columns=True, + ), + } + + #################### + # - Socket Interface + #################### + sym_name: sim_symbols.SimSymbolName = bl_cache.BLField( + sim_symbols.SimSymbolName.Constant + ) + + 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) + + #################### + # - Properties: Unit + #################### + active_unit: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.search_valid_units(), + cb_depends_on={'physical_type'}, + ) + + 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(depends_on={'active_unit'}) + def 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_unit is not None: + return spux.unit_str_to_unit(self.active_unit) + + return None + + @property + def unit_factor(self) -> spux.Unit | None: + """Like `self.unit`, except `1` instead of `None` when unitless.""" + return sp.Integer(1) if self.unit is None else self.unit + + #################### + # - Domain + #################### + interval_finite_z: tuple[int, int] = bl_cache.BLField((0, 1)) + interval_finite_q: tuple[tuple[int, int], tuple[int, int]] = bl_cache.BLField( + ((0, 1), (1, 1)) + ) + interval_finite_re: tuple[float, float] = bl_cache.BLField((0.0, 1.0)) + interval_inf: tuple[bool, bool] = bl_cache.BLField((True, True)) + interval_closed: tuple[bool, bool] = bl_cache.BLField((True, True)) + + interval_finite_im: tuple[float, float] = bl_cache.BLField((0.0, 1.0)) + interval_inf_im: tuple[bool, bool] = bl_cache.BLField((True, True)) + interval_closed_im: tuple[bool, bool] = bl_cache.BLField((True, True)) + + #################### + # - Computed Properties + #################### + @bl_cache.cached_bl_property( + depends_on={ + 'sym_name', + 'size', + 'mathtype', + 'physical_type', + 'unit', + 'interval_finite_z', + 'interval_finite_q', + 'interval_finite_re', + 'interval_inf', + 'interval_closed', + 'interval_finite_im', + 'interval_inf_im', + 'interval_closed_im', + } + ) + def symbol(self) -> sim_symbols.SimSymbol: + return sim_symbols.SimSymbol( + sym_name=self.sym_name, + mathtype=self.mathtype, + physical_type=self.physical_type, + unit=self.unit, + rows=self.size.rows, + cols=self.size.cols, + interval_finite_z=self.interval_finite_z, + interval_finite_q=self.interval_finite_q, + interval_finite_re=self.interval_finite_re, + interval_inf=self.interval_inf, + interval_closed=self.interval_closed, + interval_finite_im=self.interval_finite_im, + interval_inf_im=self.interval_inf_im, + interval_closed_im=self.interval_closed_im, + ) + + #################### + # - UI + #################### + def draw_label(self): + return self.symbol.def_label + + def draw_props(self, _: bpy.types.Context, col: bpy.types.UILayout) -> None: + col.prop(self, self.blfields['sym_name'], text='') + + row = col.row(align=True) + row.alignment = 'CENTER' + row.label(text='Assumptions') + + row = col.row(align=True) + row.prop(self, self.blfields['mathtype'], text='') + + row = col.row(align=True) + row.prop(self, self.blfields['size'], text='') + row.prop(self, self.blfields['active_unit'], text='') + + col.prop(self, self.blfields['physical_type'], text='') + + row = col.row(align=True) + row.alignment = 'CENTER' + row.label(text='Domain') + + match self.mathtype: + case spux.MathType.Integer: + col.prop(self, self.blfields['interval_finite_z'], text='') + col.prop(self, self.blfields['interval_inf'], text='Infinite') + col.prop(self, self.blfields['interval_closed'], text='Closed') + + case spux.MathType.Rational: + col.prop(self, self.blfields['interval_finite_q'], text='') + col.prop(self, self.blfields['interval_inf'], text='Infinite') + col.prop(self, self.blfields['interval_closed'], text='Closed') + + case spux.MathType.Real: + col.prop(self, self.blfields['interval_finite_re'], text='') + col.prop(self, self.blfields['interval_inf'], text='Infinite') + col.prop(self, self.blfields['interval_closed'], text='Closed') + + case spux.MathType.Complex: + col.prop(self, self.blfields['interval_finite_re'], text='ā„') + col.prop(self, self.blfields['interval_inf'], text='ā„ Infinite') + col.prop(self, self.blfields['interval_closed'], text='ā„ Closed') + + col.separator() + + col.prop(self, self.blfields['interval_finite_im'], text='š•€') + col.prop(self, self.blfields['interval_inf'], text='š•€ Infinite') + col.prop(self, self.blfields['interval_closed'], text='š•€ Closed') + + #################### + # - FlowKinds + #################### + @events.computes_output_socket( + # Trigger + 'Expr', + kind=ct.FlowKind.Value, + # Loaded + props={'symbol'}, + ) + def compute_value(self, props) -> typ.Any: + return props['symbol'].sp_symbol + + @events.computes_output_socket( + # Trigger + 'Expr', + kind=ct.FlowKind.Func, + # Loaded + props={'symbol'}, + ) + def compute_lazy_func(self, props) -> typ.Any: + sp_sym = props['symbol'].sp_symbol + return ct.FuncFlow( + func=sp.lambdify(sp_sym, sp_sym, 'jax'), + func_args=[sp_sym], + supports_jax=True, + ) + + #################### + # - FlowKinds: Auxiliary + #################### + @events.computes_output_socket( + # Trigger + 'Expr', + kind=ct.FlowKind.Info, + # Loaded + props={'symbol'}, + ) + def compute_info(self, props) -> typ.Any: + return ct.InfoFlow( + output=props['symbol'], + ) + + @events.computes_output_socket( + # Trigger + 'Expr', + kind=ct.FlowKind.Params, + # Loaded + props={'symbol'}, + ) + def compute_params(self, props) -> typ.Any: + sym = props['symbol'] + return ct.ParamsFlow( + arg_targets=[sym], + func_args=[sym.sp_symbol], + symbols={sym}, + is_differentiable=( + sym.mathtype in [spux.MathType.Real, spux.MathType.Complex] + ), + ) + + +#################### +# - Blender Registration +#################### +BL_REGISTER = [ + SymbolConstantNode, +] +BL_NODES = {ct.NodeType.SymbolConstant: (ct.NodeCategory.MAXWELLSIM_INPUTS_CONSTANTS)} diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/file_importers/data_file_importer.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/file_importers/data_file_importer.py index 4bbe43e..3d5af11 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/file_importers/data_file_importer.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/file_importers/data_file_importer.py @@ -50,10 +50,15 @@ class DataFileImporterNode(base.MaxwellSimNode): # - Properties #################### @events.on_value_changed( + # Trigger socket_name={'File Path'}, + # Loaded input_sockets={'File Path'}, input_socket_kinds={'File Path': ct.FlowKind.Value}, input_sockets_optional={'File Path': True}, + # Flow + ## -> See docs in TransformMathNode + stop_propagation=True, ) def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 has_file_path = not ct.FlowSignal.check(input_sockets['File Path']) @@ -83,7 +88,15 @@ class DataFileImporterNode(base.MaxwellSimNode): #################### # - Output Info #################### - @bl_cache.cached_bl_property(depends_on={'file_path'}) + @bl_cache.cached_bl_property( + depends_on={ + 'output_name', + 'output_mathtype', + 'output_physical_type', + 'output_unit', + } + | {f'dim_{i}_name' for i in range(6)} + ) def expr_info(self) -> ct.InfoFlow | None: """Retrieve the output expression's `InfoFlow`.""" info = self.compute_output('Expr', kind=ct.FlowKind.Info) @@ -184,19 +197,19 @@ class DataFileImporterNode(base.MaxwellSimNode): @events.computes_output_socket( 'Expr', kind=ct.FlowKind.Func, + # Loaded input_sockets={'File Path'}, ) - def compute_func(self, input_sockets: dict) -> td.Simulation: + def compute_func(self, input_sockets) -> td.Simulation: """Declare a lazy, composable function that returns the loaded data. Returns: A completely empty `ParamsFlow`, ready to be composed. """ file_path = input_sockets['File Path'] + has_file_path = not ct.FlowSignal.check(file_path) - has_file_path = not ct.FlowSignal.check(input_sockets['File Path']) - - if has_file_path: + if has_file_path and file_path is not None: data_file_format = ct.DataFileFormat.from_path(file_path) if data_file_format is not None: # Jax Compatibility: Lazy Data Loading 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 b2aed00..7528717 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 @@ -195,18 +195,23 @@ class EHFieldMonitorNode(base.MaxwellSimNode): #################### # - Preview #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Time Monitor', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews_time(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) + + @events.computes_output_socket( + 'Freq Monitor', + kind=ct.FlowKind.Previews, + # Loaded + props={'sim_node_name'}, + ) + def compute_previews_freq(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) @events.on_value_changed( # Trigger 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 2ed61f0..319ce0b 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 @@ -170,18 +170,23 @@ class PowerFluxMonitorNode(base.MaxwellSimNode): #################### # - Preview - Changes to Input Sockets #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Time Monitor', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews_time(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) + + @events.computes_output_socket( + 'Freq Monitor', + kind=ct.FlowKind.Previews, + # Loaded + props={'sim_node_name'}, + ) + def compute_previews_freq(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) @events.on_value_changed( # Trigger diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/permittivity_monitor.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/permittivity_monitor.py index 0246f97..b5a287c 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/permittivity_monitor.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/monitors/permittivity_monitor.py @@ -119,18 +119,14 @@ class PermittivityMonitorNode(base.MaxwellSimNode): #################### # - Preview #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Permittivity Monitor', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews_freq(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) @events.on_value_changed( # Trigger diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/file_exporters/data_file_exporter.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/file_exporters/data_file_exporter.py index 99b7257..4cf1503 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/file_exporters/data_file_exporter.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/file_exporters/data_file_exporter.py @@ -232,8 +232,17 @@ class DataFileExporterNode(base.MaxwellSimNode): dim.name for dim in params.symbols if dim in info.dims }: self.loose_input_sockets = { - dim_name: sockets.ExprSocketDef(**expr_info) - for dim_name, expr_info in params.sym_expr_infos(info).items() + sym.name: sockets.ExprSocketDef( + **( + expr_info + | { + 'active_kind': ct.FlowKind.Range + if sym in info.dims + else ct.FlowKind.Value + } + ) + ) + for sym, expr_info in params.sym_expr_infos.items() } elif self.loose_input_sockets: 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 7af1295..9198f6a 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 @@ -18,6 +18,7 @@ import typing as typ import bpy import sympy as sp +import tidy3d as td from blender_maxwell.utils import bl_cache, logger from blender_maxwell.utils import extra_sympy_units as spux @@ -88,32 +89,79 @@ class ViewerNode(base.MaxwellSimNode): socket_name='Any', ) def on_input_changed(self) -> None: - self.input_flow = bl_cache.Signal.InvalidateCache - - @bl_cache.cached_bl_property() - def input_flow(self) -> dict[ct.FlowKind, typ.Any | None]: - input_flow = {} + """Lightweight invalidator, which invalidates the more specific `cached_bl_property` used to determine when something ex. plot-related has changed. + Calls `get_flow`, which will be called again when regenerating the `cached_bl_property`s. + This **does not** call the flow twice, as `self._compute_input()` will be cached the first time. + """ for flow_kind in list(ct.FlowKind): + flow = self.get_flow( + flow_kind, always_load=flow_kind is ct.FlowKind.Previews + ) + if flow is not None: + setattr( + self, + 'input_' + flow_kind.property_name, + bl_cache.Signal.InvalidateCache, + ) + + @bl_cache.cached_bl_property(depends_on={'auto_expr'}) + def input_capabilities(self) -> ct.CapabilitiesFlow | None: + return self.get_flow(ct.FlowKind.Capabilities) + + @bl_cache.cached_bl_property(depends_on={'auto_expr'}) + def input_previews(self) -> ct.PreviewsFlow | None: + return self.get_flow(ct.FlowKind.Previews, always_load=True) + + @bl_cache.cached_bl_property(depends_on={'auto_expr'}) + def input_value(self) -> ct.ValueFlow | None: + return self.get_flow(ct.FlowKind.Value) + + @bl_cache.cached_bl_property(depends_on={'auto_expr'}) + def input_array(self) -> ct.ArrayFlow | None: + return self.get_flow(ct.FlowKind.Array) + + @bl_cache.cached_bl_property(depends_on={'auto_expr'}) + def input_lazy_range(self) -> ct.RangeFlow | None: + return self.get_flow(ct.FlowKind.Range) + + @bl_cache.cached_bl_property(depends_on={'auto_expr'}) + def input_lazy_func(self) -> ct.FuncFlow | None: + return self.get_flow(ct.FlowKind.Func) + + @bl_cache.cached_bl_property(depends_on={'auto_expr'}) + def input_params(self) -> ct.ParamsFlow | None: + return self.get_flow(ct.FlowKind.Params) + + @bl_cache.cached_bl_property(depends_on={'auto_expr'}) + def input_info(self) -> ct.InfoFlow | None: + return self.get_flow(ct.FlowKind.Info) + + def get_flow( + self, flow_kind: ct.FlowKind, always_load: bool = False + ) -> typ.Any | None: + """Generic interface to simplify getting `FlowKind` properties on the viewer node.""" + if self.auto_expr or always_load: flow = self._compute_input('Any', kind=flow_kind) has_flow = not ct.FlowSignal.check(flow) if has_flow: - input_flow |= {flow_kind: flow} - else: - input_flow |= {flow_kind: None} - - return input_flow + return flow + return None + return None #################### # - Property: Input Expression String Lines #################### - @bl_cache.cached_bl_property(depends_on={'input_flow'}) + @bl_cache.cached_bl_property(depends_on={'input_value'}) def input_expr_str_entries(self) -> list[list[str]] | None: - value = self.input_flow.get(ct.FlowKind.Value) + value = self.input_value + if value is None: + return None + # Parse SympyType def sp_pretty(v: spux.SympyExpr) -> spux.SympyExpr: - ## sp.pretty makes new lines and wreaks havoc. + ## -> The real sp.pretty makes new lines and wreaks havoc. return spux.sp_to_str(v.n(4)) if isinstance(value, spux.SympyType): @@ -124,6 +172,25 @@ class ViewerNode(base.MaxwellSimNode): ] return [[sp_pretty(value)]] + + # Parse Tidy3D Types + if isinstance(value, td.Structure): + return [ + [str(key), str(value)] + for key, value in dict(value).items() + if key not in ['type', 'geometry', 'medium'] + ] + [ + [str(key), str(value)] + for key, value in dict(value.geometry).items() + if key != 'type' + ] + if isinstance(value, td.components.base.Tidy3dBaseModel): + return [ + [str(key), str(value)] + for key, value in dict(value).items() + if key != 'type' + ] + return None #################### @@ -132,12 +199,12 @@ class ViewerNode(base.MaxwellSimNode): def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout): row = layout.row(align=True) + # Automatic Expression Printing + row.prop(self, self.blfields['auto_expr'], text='Live', toggle=True) + # Debug Mode On/Off row.prop(self, self.blfields['debug_mode'], text='Debug', toggle=True) - # Automatic Expression Printing - row.prop(self, self.blfields['auto_expr'], text='Expr', toggle=True) - # Debug Mode Operators if self.debug_mode: layout.prop(self, self.blfields['console_print_kind'], text='') @@ -210,47 +277,47 @@ class ViewerNode(base.MaxwellSimNode): # - Methods #################### def print_data_to_console(self): - if not self.inputs['Any'].is_linked: - return + flow = self._compute_input('Any', kind=self.console_print_kind) log.info('Printing to Console') - data = self._compute_input('Any', kind=self.console_print_kind, optional=True) - - if isinstance(data, spux.SympyType): - console.print(sp.pretty(data, use_unicode=True)) + if isinstance(flow, spux.SympyType): + console.print(sp.pretty(flow, use_unicode=True)) else: - console.print(data) + console.print(flow) #################### # - Event Methods #################### @events.on_value_changed( - socket_name='Any', - prop_name='auto_plot', - props={'auto_plot'}, + # Trigger + prop_name={'input_previews', 'auto_plot'}, + # Loaded + props={'input_previews', 'auto_plot'}, ) def on_changed_plot_preview(self, props): - node_tree = self.id_data + previews = props['input_previews'] + if previews is not None: + if props['auto_plot']: + bl_socket = self.inputs['Any'] + if bl_socket.is_linked: + bl_socket.links[0].from_node.compute_plot() - # Unset Plot if Nothing Plotted - with node_tree.replot(): - if props['auto_plot'] and self.inputs['Any'].is_linked: - self.inputs['Any'].links[0].from_socket.node.trigger_event( - ct.FlowEvent.ShowPlot - ) + previews.update_image_preview() + else: + ct.PreviewsFlow.hide_image_preview() @events.on_value_changed( - socket_name='Any', - prop_name='auto_3d_preview', - props={'auto_3d_preview'}, + # Trigger + prop_name={'input_previews', 'auto_3d_preview'}, + # Loaded + props={'input_previews', 'auto_3d_preview'}, ) def on_changed_3d_preview(self, props): - node_tree = self.id_data - - # Remove Non-Repreviewed Previews on Close - with node_tree.repreview_all(): - if props['auto_3d_preview']: - self.trigger_event(ct.FlowEvent.ShowPreview) + previews = props['input_previews'] + if previews is not None and props['auto_3d_preview']: + previews.update_bl_object_previews() + else: + ct.PreviewsFlow.hide_bl_object_previews() #################### diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/fdtd_sim.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/fdtd_sim.py index 2ef45a0..5a1901c 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/fdtd_sim.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/simulations/fdtd_sim.py @@ -64,20 +64,22 @@ class FDTDSimNode(base.MaxwellSimNode): }, ) def compute_fdtd_sim(self, input_sockets: dict) -> sp.Expr: - ## TODO: Visualize the boundary conditions on top of the sim domain + if any(ct.FlowSignal.check(inp) for inp in input_sockets): + return ct.FlowSignal.FlowPending + sim_domain = input_sockets['Domain'] sources = input_sockets['Sources'] structures = input_sockets['Structures'] bounds = input_sockets['BCs'] monitors = input_sockets['Monitors'] - return td.Simulation( - **sim_domain, ## run_time=, size=, grid=, medium= + **sim_domain, structures=structures, sources=sources, monitors=monitors, boundary_spec=bounds, ) + ## TODO: Visualize the boundary conditions on top of the sim domain #################### 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 d8aa1a2..66a0696 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 @@ -93,18 +93,14 @@ class SimDomainNode(base.MaxwellSimNode): #################### # - Preview #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Domain', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) @events.on_value_changed( ## Trigger 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 d3c246a..85f95dc 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 @@ -165,18 +165,14 @@ class GaussianBeamSourceNode(base.MaxwellSimNode): #################### # - Preview - Changes to Input Sockets #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Angled Source', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) @events.on_value_changed( # Trigger 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 496881c..5d3f26a 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 @@ -129,18 +129,14 @@ class PlaneWaveSourceNode(base.MaxwellSimNode): #################### # - Preview - Changes to Input Sockets #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Angled Source', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) @events.on_value_changed( # Trigger 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 6dfe144..ad438bc 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 @@ -104,18 +104,14 @@ class PointDipoleSourceNode(base.MaxwellSimNode): #################### # - Preview #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Source', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) @events.on_value_changed( socket_name={'Center'}, 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 367156e..27369d5 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 @@ -132,18 +132,14 @@ class GeoNodesStructureNode(base.MaxwellSimNode): #################### # - Events: Preview #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Structure', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) @events.on_value_changed( # Trigger 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 6911682..d4d54b2 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 @@ -16,13 +16,15 @@ import typing as typ +import bpy import sympy as sp import sympy.physics.units as spu import tidy3d as td +import tidy3d.plugins.adjoint as tdadj from blender_maxwell.assets.geonodes import GeoNodes, import_geonodes +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 managed_objs, sockets @@ -62,41 +64,172 @@ class BoxStructureNode(base.MaxwellSimNode): } #################### - # - Outputs + # - Properties + #################### + differentiable: bool = bl_cache.BLField(False) + + #################### + # - UI + #################### + def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout): + layout.prop( + self, + self.blfields['differentiable'], + text='Differentiable', + toggle=True, + ) + + #################### + # - FlowKind.Value #################### @events.computes_output_socket( 'Structure', + kind=ct.FlowKind.Value, + # Loaded + props={'differentiable'}, input_sockets={'Medium', 'Center', 'Size'}, - unit_systems={'Tidy3DUnits': ct.UNITS_TIDY3D}, - scale_input_sockets={ - 'Center': 'Tidy3DUnits', - 'Size': 'Tidy3DUnits', + output_sockets={'Structure'}, + output_socket_kinds={'Structure': ct.FlowKind.Params}, + ) + def compute_value(self, props, input_sockets, output_sockets) -> td.Box: + output_params = output_sockets['Structure'] + center = input_sockets['Center'] + size = input_sockets['Size'] + medium = input_sockets['Medium'] + + has_output_params = not ct.FlowSignal.check(output_params) + has_center = not ct.FlowSignal.check(center) + has_size = not ct.FlowSignal.check(size) + has_medium = not ct.FlowSignal.check(medium) + + if ( + has_center + and has_size + and has_medium + and has_output_params + and not props['differentiable'] + and not output_params.symbols + ): + return td.Structure( + geometry=td.Box( + center=spux.scale_to_unit_system(center, ct.UNITS_TIDY3D), + size=spux.scale_to_unit_system(size, ct.UNITS_TIDY3D), + ), + medium=medium, + ) + return ct.FlowSignal.FlowPending + + #################### + # - FlowKind.Func + #################### + @events.computes_output_socket( + 'Structure', + kind=ct.FlowKind.Func, + # Loaded + props={'differentiable'}, + input_sockets={'Medium', 'Center', 'Size'}, + input_socket_kinds={ + 'Medium': ct.FlowKind.Func, + 'Center': ct.FlowKind.Func, + 'Size': ct.FlowKind.Func, + }, + output_sockets={'Structure'}, + output_socket_kinds={'Structure': ct.FlowKind.Params}, + ) + def compute_lazy_structure(self, props, input_sockets, output_sockets) -> td.Box: + output_params = output_sockets['Structure'] + center = input_sockets['Center'] + size = input_sockets['Size'] + medium = input_sockets['Medium'] + + has_output_params = not ct.FlowSignal.check(output_params) + has_center = not ct.FlowSignal.check(center) + has_size = not ct.FlowSignal.check(size) + has_medium = not ct.FlowSignal.check(medium) + + differentiable = props['differentiable'] + if ( + has_output_params + and has_center + and has_size + and has_medium + and differentiable == output_params.is_differentiable + ): + if differentiable: + return (center | size | medium).compose_within( + enclosing_func=lambda els: tdadj.JaxStructure( + geometry=tdadj.JaxBox( + center=tuple(els[0][0].flatten()), + size=tuple(els[0][1].flatten()), + ), + medium=els[1], + ), + supports_jax=True, + ) + return (center | size | medium).compose_within( + enclosing_func=lambda els: td.Structure( + geometry=td.Box( + center=tuple(els[0][0].flatten()), + size=tuple(els[0][1].flatten()), + ), + medium=els[1], + ), + supports_jax=False, + ) + return ct.FlowSignal.FlowPending + + #################### + # - FlowKind.Params + #################### + @events.computes_output_socket( + 'Structure', + kind=ct.FlowKind.Params, + # Loaded + props={'differentiable'}, + input_sockets={'Medium', 'Center', 'Size'}, + input_socket_kinds={ + 'Medium': ct.FlowKind.Params, + 'Center': ct.FlowKind.Params, + 'Size': ct.FlowKind.Params, }, ) - def compute_structure(self, input_sockets, unit_systems) -> td.Box: - return td.Structure( - geometry=td.Box( - center=input_sockets['Center'], - size=input_sockets['Size'], - ), - medium=input_sockets['Medium'], - ) + def compute_params(self, props, input_sockets) -> td.Box: + center = input_sockets['Center'] + size = input_sockets['Size'] + medium = input_sockets['Medium'] + + has_center = not ct.FlowSignal.check(center) + has_size = not ct.FlowSignal.check(size) + has_medium = not ct.FlowSignal.check(medium) + + if has_center and has_size and has_medium: + if props['differentiable'] == ( + center.is_differentiable + & size.is_differentiable + & medium.is_differentiable + ): + return center | size | medium + return ct.FlowSignal.FlowPending + return ct.FlowSignal.FlowPending #################### # - Events: Preview #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Structure', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, + output_sockets={'Structure'}, + output_socket_kinds={'Structure': ct.FlowKind.Params}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews(self, props, output_sockets): + output_params = output_sockets['Structure'] + has_output_params = not ct.FlowSignal.check(output_params) + + if has_output_params and not output_params.symbols: + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) + return ct.PreviewsFlow() @events.on_value_changed( # Trigger @@ -105,29 +238,26 @@ class BoxStructureNode(base.MaxwellSimNode): # Loaded input_sockets={'Center', 'Size'}, managed_objs={'modifier'}, - unit_systems={'BlenderUnits': ct.UNITS_BLENDER}, - scale_input_sockets={ - 'Center': 'BlenderUnits', - }, + output_sockets={'Structure'}, + output_socket_kinds={'Structure': ct.FlowKind.Params}, ) - def on_inputs_changed( - self, - managed_objs, - input_sockets, - unit_systems, - ): - # Push Loose Input Values to GeoNodes Modifier - managed_objs['modifier'].bl_modifier( - 'NODES', - { - 'node_group': import_geonodes(GeoNodes.StructurePrimitiveBox), - 'unit_system': unit_systems['BlenderUnits'], - 'inputs': { - 'Size': input_sockets['Size'], + def on_inputs_changed(self, managed_objs, input_sockets, output_sockets): + output_params = output_sockets['Structure'] + has_output_params = not ct.FlowSignal.check(output_params) + if has_output_params and not output_params.symbols: + # Push Loose Input Values to GeoNodes Modifier + center = input_sockets['Center'] + managed_objs['modifier'].bl_modifier( + 'NODES', + { + 'node_group': import_geonodes(GeoNodes.StructurePrimitiveBox), + 'unit_system': ct.UNITS_BLENDER, + 'inputs': { + 'Size': input_sockets['Size'], + }, }, - }, - location=input_sockets['Center'], - ) + location=spux.scale_to_unit_system(center, ct.UNITS_BLENDER), + ) #################### diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/cylinder_structure.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/cylinder_structure.py index a89ef06..c1f7440 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/cylinder_structure.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/structures/primitives/cylinder_structure.py @@ -89,18 +89,14 @@ class CylinderStructureNode(base.MaxwellSimNode): #################### # - Preview #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Structure', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) @events.on_value_changed( # Trigger 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 1b4bca3..d5a83b4 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 @@ -83,18 +83,14 @@ class SphereStructureNode(base.MaxwellSimNode): #################### # - Preview #################### - @events.on_value_changed( - # Trigger - prop_name='preview_active', + @events.computes_output_socket( + 'Structure', + kind=ct.FlowKind.Previews, # Loaded - managed_objs={'modifier'}, - props={'preview_active'}, + props={'sim_node_name'}, ) - def on_preview_changed(self, managed_objs, props): - if props['preview_active']: - managed_objs['modifier'].show_preview() - else: - managed_objs['modifier'].hide_preview() + def compute_previews(self, props): + return ct.PreviewsFlow(bl_object_names={props['sim_node_name']}) @events.on_value_changed( # Trigger 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 e75bb21..2cb3138 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 @@ -50,8 +50,10 @@ class SocketDef(pyd.BaseModel, abc.ABC): Parameters: bl_socket: The Blender node socket to alter using data from this SocketDef. """ + log.debug('%s: Start Socket Preinit', bl_socket.bl_label) bl_socket.reset_instance_id() bl_socket.regenerate_dynamic_field_persistance() + log.debug('%s: End Socket Preinit', bl_socket.bl_label) def postinit(self, bl_socket: bpy.types.NodeSocket) -> None: """Pre-initialize a real Blender node socket from this socket definition. @@ -59,8 +61,12 @@ class SocketDef(pyd.BaseModel, abc.ABC): Parameters: bl_socket: The Blender node socket to alter using data from this SocketDef. """ + log.debug('%s: Start Socket Postinit', bl_socket.bl_label) bl_socket.is_initializing = False bl_socket.on_active_kind_changed() + bl_socket.on_socket_props_changed(set(bl_socket.blfields)) + bl_socket.on_data_changed(set(ct.FlowKind)) + log.debug('%s: End Socket Postinit', bl_socket.bl_label) @abc.abstractmethod def init(self, bl_socket: bpy.types.NodeSocket) -> None: @@ -135,6 +141,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): socket_type: ct.SocketType bl_label: str + use_linked_capabilities: bool = bl_cache.BLField(False, use_prop_update=False) + ## Computed by Subclass bl_idname: str @@ -181,17 +189,17 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): """ 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. + def on_socket_props_changed(self, prop_names: set[str]) -> None: + """Called when a set of properties has been updated. Notes: - Can be overridden if a socket needs to respond to a property change. + Can be overridden if a socket needs to respond to property changes. **Always prefer using node events instead of overriding this in a socket**. Think **very carefully** before using this, and use it with the greatest of care. Attributes: - prop_name: The name of the property that was changed. + prop_names: The set of property names that were changed. """ def on_prop_changed(self, prop_name: str) -> None: @@ -207,30 +215,49 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): Attributes: prop_name: The name of the property that was changed. """ - # All Attributes: Trigger Local Event - ## -> While initializing, only `DataChanged` won't trigger. - if hasattr(self, prop_name): - # Property Callbacks: Active Kind - ## -> WARNING: May NOT rely on flow. - if prop_name == 'active_kind': + # BLField Attributes: Invalidate BLField Dependents + ## -> All invalidated blfields will have their caches cleared. + ## -> The (topologically) ordered list of cleared blfields is returned. + ## -> WARNING: The chain is not checked for ex. cycles. + if not self.is_initializing and prop_name in self.blfields: + cleared_blfields = self.clear_blfields_after(prop_name) + set_of_cleared_blfields = set(cleared_blfields) + + # Property Callbacks: Internal + ## -> NOTE: May NOT recurse on_prop_changed. + if ('active_kind', 'invalidate') in set_of_cleared_blfields: + # log.debug( + # '%s (NodeSocket): Changed Active Kind', + # self.bl_label, + # ) self.on_active_kind_changed() # Property Callbacks: Per-Socket - ## -> WARNING: May NOT rely on flow. - self.on_socket_prop_changed(prop_name) + ## -> NOTE: User-defined handlers might recurse on_prop_changed. + self.is_initializing = True + self.on_socket_props_changed(set_of_cleared_blfields) + self.is_initializing = False - # Not Initializing: Trigger Event - ## -> This declares that the socket has changed. - ## -> This should happen first, in case dependents need a cache. - if not self.is_initializing: - self.trigger_event(ct.FlowEvent.DataChanged) - - # BLField Attributes: Invalidate BLField Dependents - ## -> Dependent props will generally also trigger on_prop_changed. - ## -> The recursion ends with the depschain. - ## -> WARNING: The chain is not checked for ex. cycles. - if prop_name in self.blfields: - self.invalidate_blfield_deps(prop_name) + # Trigger Event + ## -> Before SocketDef.postinit(), never emit DataChanged. + ## -> ONLY emit DataChanged if a FlowKind-bound prop was cleared. + ## -> ONLY emit a single DataChanged w/set of altered FlowKinds. + ## w/node's trigger_event, we've guaranteed a minimal action. + socket_kinds = { + ct.FlowKind.from_property_name(prop_name) + for prop_name in { + prop_name + for prop_name, clear_method in set_of_cleared_blfields + if clear_method == 'invalidate' + }.intersection(ct.FlowKind.property_names) + } + # log.debug( + # '%s (NodeSocket): Computed SocketKind Frontier: %s', + # self.bl_label, + # str(socket_kinds), + # ) + if socket_kinds: + self.trigger_event(ct.FlowEvent.DataChanged, socket_kinds=socket_kinds) #################### # - Link Event: Consent / On Change @@ -273,11 +300,29 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): return False # Capability Check - if not link.from_socket.capabilities.is_compatible_with(self.capabilities): + ## -> "Use Linked Capabilities" allow sockets flow-dependent caps. + ## -> The tradeoff: No link if there is no InfoFlow. + if self.use_linked_capabilities: + info = self.compute_data(kind=ct.FlowKind.Info) + has_info = not ct.FlowSignal.check(info) + if has_info: + incoming_capabilities = link.from_socket.linked_capabilities(info) + else: + log.error( + 'Attempted to link output socket "%s" to input socket "%s" (%s), but linked capabilities of the output socket could not be determined', + link.from_socket.bl_label, + self.bl_label, + self.capabilities, + ) + return False + else: + incoming_capabilities = link.from_socket.capabilities + + if not incoming_capabilities.is_compatible_with(self.capabilities): log.error( 'Attempted to link output socket "%s" (%s) to input socket "%s" (%s), but capabilities are incompatible', link.from_socket.bl_label, - link.from_socket.capabilities, + incoming_capabilities, self.bl_label, self.capabilities, ) @@ -288,6 +333,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): def on_link_added(self, link: bpy.types.NodeLink) -> None: # noqa: ARG002 """Triggers a `ct.FlowEvent.LinkChanged` event when a link is added. + Calls `self.trigger_event()` with `FlowKind`s, since an added link requires recomputing **all** data that depends on flow. + Notes: Called by the node tree, generally (but not guaranteed) after `self.allow_add_link()` has given consent to add the link. @@ -295,7 +342,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): link: The node link that was added. Currently unused. """ - self.trigger_event(ct.FlowEvent.LinkChanged) + self.trigger_event(ct.FlowEvent.LinkChanged, socket_kinds=set(ct.FlowKind)) def allow_remove_link(self, from_socket: bpy.types.NodeSocket) -> bool: # noqa: ARG002 """Called to ask whether a link may be removed from this `to_socket`. @@ -333,6 +380,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): def on_link_removed(self, from_socket: bpy.types.NodeSocket) -> None: # noqa: ARG002 """Triggers a `ct.FlowEvent.LinkChanged` event when a link is removed. + Calls `self.trigger_event()` with `FlowKind`s, since a removed link requires recomputing **all** data that depends on flow. + Notes: Called by the node tree, generally (but not guaranteed) after `self.allow_remove_link()` has given consent to remove the link. @@ -340,7 +389,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): from_socket: The node socket that was attached to before link removal. Currently unused. """ - self.trigger_event(ct.FlowEvent.LinkChanged) + self.trigger_event(ct.FlowEvent.LinkChanged, socket_kinds=set(ct.FlowKind)) def remove_invalidated_links(self) -> None: """Reevaluates the capabilities of all socket links, and removes any that no longer match. @@ -371,6 +420,41 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): #################### # - Event Chain #################### + def on_data_changed(self, socket_kinds: set[ct.FlowKind]) -> None: + """Called when `ct.FlowEvent.DataChanged` flows through this socket. + + Parameters: + socket_kinds: The altered `ct.FlowKind`s flowing through. + """ + self.on_socket_data_changed(socket_kinds) + + def on_socket_data_changed(self, socket_kinds: set[ct.FlowKind]) -> None: + """Called when `ct.FlowEvent.DataChanged` flows through this socket. + + Notes: + Can be overridden if a socket needs to respond to `DataChanged` in a custom way. + + **Always prefer using node events instead of overriding this in a socket**. + Think **very carefully** before using this, and use it with the greatest of care. + + Parameters: + socket_kinds: The altered `ct.FlowKind`s flowing through. + """ + + def on_link_changed(self) -> None: + """Called when `ct.FlowEvent.LinkChanged` flows through this socket.""" + self.on_socket_link_changed() + + def on_socket_link_changed(self) -> None: + """Called when `ct.FlowEvent.LinkChanged` flows through this socket. + + Notes: + Can be overridden if a socket needs to respond to `LinkChanged` in a custom way. + + **Always prefer using node events instead of overriding this in a socket**. + Think **very carefully** before using this, and use it with the greatest of care. + """ + def trigger_event( self, event: ct.FlowEvent, @@ -384,7 +468,6 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): - **Output Socket -> Input**: Trigger event on node (w/`socket_name`). - **Output Socket -> Output**: Trigger event on `to_socket`s along output links. - Notes: This can be an unpredictably heavy function, depending on the node graph topology. @@ -395,11 +478,41 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): event: The event to report along the node tree. The value of `ct.FlowEvent.flow_direction[event]` (`input` or `output`) determines the direction that an event flows. """ + # log.debug( + # '[%s] [%s] Triggered (socket_kinds=%s)', + # self.name, + # event, + # str(socket_kinds), + # ) + # Local DataChanged Callbacks + ## -> socket_kinds MUST NOT be None + if event is ct.FlowEvent.DataChanged: + # WORKAROUND + ## -> Altering value/lazy_range like this causes MANY DataChanged + ## -> If we pretend we're initializing, we can block on_prop_changed + ## -> This works because _unit conversion doesn't change the value_ + ## -> Only the displayed values change - which are inv. on __set__. + ## -> For this reason alone, we can get away with it :) + ## -> TODO: This is not clean :) + self.is_initializing = True + self.on_data_changed(socket_kinds) + self.is_initializing = False + + # Local LinkChanged Callbacks + ## -> socket_kinds MUST NOT be None + if event is ct.FlowEvent.LinkChanged: + self.is_initializing = True + self.on_link_changed() + self.on_data_changed(socket_kinds) + self.is_initializing = False + flow_direction = ct.FlowEvent.flow_direction[event] # Locking - if event in [ct.FlowEvent.EnableLock, ct.FlowEvent.DisableLock]: - self.locked = event == ct.FlowEvent.EnableLock + if event is ct.FlowEvent.EnableLock: + self.locked = True + elif event is ct.FlowEvent.DisableLock: + self.locked = False # Event by Socket Orientation | Flow Direction match (self.is_output, flow_direction): @@ -408,7 +521,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): link.from_socket.trigger_event(event, socket_kinds=socket_kinds) case (False, 'output'): - if event == ct.FlowEvent.LinkChanged: + if event is ct.FlowEvent.LinkChanged: self.node.trigger_event( ct.FlowEvent.DataChanged, socket_name=self.name, @@ -432,6 +545,10 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): # - FlowKind: Auxiliary #################### # Capabilities + def linked_capabilities(self, info: ct.InfoFlow) -> ct.CapabilitiesFlow: + """Try this first when `is_linked and use_linked_capabilities`.""" + raise NotImplementedError + @property def capabilities(self) -> None: """By default, the socket is linkeable with any other socket of the same type and active kind. @@ -592,21 +709,16 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): Raises: ValueError: When referencing a socket that's meant to be directly referenced. """ - kind_data_map = { + return { ct.FlowKind.Capabilities: lambda: self.capabilities, + ct.FlowKind.Previews: lambda: ct.PreviewsFlow(), ct.FlowKind.Value: lambda: self.value, ct.FlowKind.Array: lambda: self.array, ct.FlowKind.Func: lambda: self.lazy_func, ct.FlowKind.Range: lambda: self.lazy_range, ct.FlowKind.Params: lambda: self.params, ct.FlowKind.Info: lambda: self.info, - } - if kind in kind_data_map: - return kind_data_map[kind]() - - ## TODO: Reflect this constraint in the type - msg = f'Socket {self.bl_label} ({self.socket_type}): Kind {kind} cannot be computed within a socket "compute_data", as it is meant to be referenced directly' - raise ValueError(msg) + }[kind]() def compute_data( self, @@ -635,7 +747,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): return self.node.compute_output(self.name, kind=kind) # Compute Input Socket - ## Unlinked: Retrieve Socket Value + ## -> Unlinked: Retrieve Socket Value if not self.is_linked: return self._compute_data(kind) @@ -645,7 +757,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): linked_values = [link.from_socket.compute_data(kind) for link in self.links] # Return Single Value / List of Values - if len(linked_values) == 1: + ## -> Multi-input sockets are not yet supported. + if linked_values: return linked_values[0] # Edge Case: While Dragging Link (but not yet removed) @@ -653,11 +766,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance): ## - self.is_linked = True, since the user hasn't confirmed anything. ## - self.links will be empty, since the link object was freed. ## When this particular condition is met, pretend that we're not linked. - if len(linked_values) == 0: - return self._compute_data(kind) - - msg = f'Socket {self.bl_label} ({self.socket_type}): Multi-input sockets are not yet supported' - raise NotImplementedError(msg) + return self._compute_data(kind) #################### # - UI - Color diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/any.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/any.py index 45051e0..72eb220 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/any.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/any.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from blender_maxwell.utils import bl_cache + from ... import contracts as ct from .. import base @@ -25,8 +27,8 @@ class AnyBLSocket(base.MaxwellSimSocket): socket_type = ct.SocketType.Any bl_label = 'Any' - @property - def capabilities(self): + @bl_cache.cached_bl_property(depends_on={'active_kind'}) + def capabilities(self) -> ct.CapabilitiesFlow: return ct.CapabilitiesFlow( socket_type=self.socket_type, active_kind=self.active_kind, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/bool.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/bool.py index b4b9913..d2c4f4b 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/bool.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/bool.py @@ -16,7 +16,7 @@ import bpy -from blender_maxwell.utils import bl_cache, logger +from blender_maxwell.utils import bl_cache from ... import contracts as ct from .. import base @@ -43,7 +43,7 @@ class BoolBLSocket(base.MaxwellSimSocket): #################### # - Computation of Default Value #################### - @property + @bl_cache.cached_bl_property(depends_on={'raw_value'}) def value(self) -> bool: return self.raw_value 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 a9738b0..e6a2e5f 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 @@ -48,7 +48,7 @@ class FilePathBLSocket(base.MaxwellSimSocket): #################### # - FlowKind: Value #################### - @property + @bl_cache.cached_bl_property(depends_on={'raw_value'}) def value(self) -> Path: return self.raw_value diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/string.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/string.py index 9aba1bb..b198935 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/string.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/string.py @@ -16,6 +16,8 @@ import bpy +from blender_maxwell.utils import bl_cache + from ... import contracts as ct from .. import base @@ -30,12 +32,7 @@ class StringBLSocket(base.MaxwellSimSocket): #################### # - Properties #################### - raw_value: bpy.props.StringProperty( - name='String', - description='Represents a string', - default='', - update=(lambda self, context: self.on_prop_changed('raw_value', context)), - ) + raw_value: str = bl_cache.BLField('') #################### # - Socket UI @@ -46,7 +43,7 @@ class StringBLSocket(base.MaxwellSimSocket): #################### # - Computation of Default Value #################### - @property + @bl_cache.cached_bl_property(depends_on={'raw_value'}) def value(self) -> str: return self.raw_value diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/geonodes.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/geonodes.py index 78fb328..b812e99 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/geonodes.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/geonodes.py @@ -71,7 +71,7 @@ class BlenderGeoNodesBLSocket(base.MaxwellSimSocket): #################### # - Default Value #################### - @property + @bl_cache.cached_bl_property(depends_on={'raw_value'}) def value(self) -> bpy.types.NodeTree | ct.FlowSignal: return self.raw_value if self.raw_value is not None else ct.FlowSignal.NoFlow diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/image.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/image.py index c832152..1d6b100 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/image.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/image.py @@ -16,6 +16,8 @@ import bpy +from blender_maxwell.utils import bl_cache, logger + from ... import contracts as ct from .. import base @@ -30,12 +32,7 @@ class BlenderImageBLSocket(base.MaxwellSimSocket): #################### # - Properties #################### - raw_value: bpy.props.PointerProperty( - name='Blender Image', - description='Represents a Blender Image', - type=bpy.types.Image, - update=(lambda self, context: self.on_prop_changed('raw_value', context)), - ) + raw_value: bpy.types.Image = bl_cache.BLField() #################### # - UI @@ -46,7 +43,7 @@ class BlenderImageBLSocket(base.MaxwellSimSocket): #################### # - Default Value #################### - @property + @bl_cache.cached_bl_property(depends_on={'raw_value'}) def value(self) -> bpy.types.Image | None: return self.raw_value diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/material.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/material.py index 52f4a16..b31e716 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/material.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/material.py @@ -16,6 +16,8 @@ import bpy +from blender_maxwell.utils import bl_cache, logger + from ... import contracts as ct from .. import base @@ -27,12 +29,7 @@ class BlenderMaterialBLSocket(base.MaxwellSimSocket): #################### # - Properties #################### - raw_value: bpy.props.PointerProperty( - name='Blender Material', - description='Represents a Blender material', - type=bpy.types.Material, - update=(lambda self, context: self.on_prop_changed('raw_value', context)), - ) + raw_value: bpy.types.Material = bl_cache.BLField() #################### # - UI @@ -43,7 +40,7 @@ class BlenderMaterialBLSocket(base.MaxwellSimSocket): #################### # - Default Value #################### - @property + @bl_cache.cached_bl_property(depends_on={'raw_value'}) def value(self) -> bpy.types.Material | None: return self.raw_value diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/object.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/object.py index e1251ee..c2f53fe 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/object.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/object.py @@ -16,29 +16,12 @@ import bpy +from blender_maxwell.utils import bl_cache, logger + from ... import contracts as ct from .. import base - -#################### -# - Create and Assign BL Object -#################### -class BlenderMaxwellCreateAndAssignBLObject(bpy.types.Operator): - bl_idname = 'blender_maxwell.create_and_assign_bl_object' - bl_label = 'Create and Assign BL Object' - - node_tree_name = bpy.props.StringProperty(name='Node Tree Name') - node_name = bpy.props.StringProperty(name='Node Name') - socket_name = bpy.props.StringProperty(name='Socket Name') - - def execute(self, context): - node_tree = bpy.data.node_groups[self.node_tree_name] - node = node_tree.nodes[self.node_name] - socket = node.inputs[self.socket_name] - - socket.create_and_assign_bl_object() - - return {'FINISHED'} +log = logger.get(__name__) #################### @@ -51,47 +34,18 @@ class BlenderObjectBLSocket(base.MaxwellSimSocket): #################### # - Properties #################### - raw_value: bpy.props.PointerProperty( - name='Blender Object', - description='Represents a Blender object', - type=bpy.types.Object, - update=(lambda self, context: self.on_prop_changed('raw_value', context)), - ) + raw_value: bpy.types.Object = bl_cache.BLField() #################### # - UI #################### - def draw_label_row(self, label_col_row, text): - label_col_row.label(text=text) - - op = label_col_row.operator( - 'blender_maxwell.create_and_assign_bl_object', - text='', - icon='ADD', - ) - op.socket_name = self.name - op.node_name = self.node.name - op.node_tree_name = self.node.id_data.name - def draw_value(self, col: bpy.types.UILayout) -> None: col.prop(self, 'raw_value', text='') - #################### - # - Methods - #################### - def create_and_assign_bl_object(self): - node_tree = self.node.id_data - mesh = bpy.data.meshes.new('MaxwellMesh') - new_bl_object = bpy.data.objects.new('MaxwellObject', mesh) - - bpy.context.collection.objects.link(new_bl_object) - - self.value = new_bl_object - #################### # - Default Value #################### - @property + @bl_cache.cached_bl_property(depends_on={'raw_value'}) def value(self) -> bpy.types.Object | None: return self.raw_value @@ -114,6 +68,5 @@ class BlenderObjectSocketDef(base.SocketDef): # - Blender Registration #################### BL_REGISTER = [ - BlenderMaxwellCreateAndAssignBLObject, BlenderObjectBLSocket, ] diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/text.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/text.py index cf01d75..7ea4264 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/text.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/blender/text.py @@ -16,9 +16,13 @@ import bpy +from blender_maxwell.utils import bl_cache, logger + from ... import contracts as ct from .. import base +log = logger.get(__name__) + #################### # - Blender Socket @@ -30,12 +34,7 @@ class BlenderTextBLSocket(base.MaxwellSimSocket): #################### # - Properties #################### - raw_value: bpy.props.PointerProperty( - name='Blender Text', - description='Represents a Blender text datablock', - type=bpy.types.Text, - update=(lambda self, context: self.on_prop_changed('raw_value', context)), - ) + raw_value: bpy.types.Text = bl_cache.BLField() #################### # - UI @@ -46,7 +45,7 @@ class BlenderTextBLSocket(base.MaxwellSimSocket): #################### # - Default Value #################### - @property + @bl_cache.cached_bl_property(depends_on={'raw_value'}) def value(self) -> bpy.types.Text: return self.raw_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 0212d90..cf7d47e 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 @@ -64,21 +64,21 @@ class InfoDisplayCol(enum.StrEnum): @staticmethod def to_name(value: typ.Self) -> str: + """Friendly, single-letter, human-readable column names. + + Must be concise, as there is not a lot of header space to contain these. + """ IDC = InfoDisplayCol return { IDC.Length: 'L', - IDC.MathType: 'āˆˆ', + IDC.MathType: 'M', IDC.Unit: 'U', }[value] @staticmethod - def to_icon(value: typ.Self) -> str: - IDC = InfoDisplayCol - return { - IDC.Length: '', - IDC.MathType: '', - IDC.Unit: '', - }[value] + def to_icon(_: typ.Self) -> str: + """No icons.""" + return '' #################### @@ -109,6 +109,7 @@ class ExprBLSocket(base.MaxwellSimSocket): socket_type = ct.SocketType.Expr bl_label = 'Expr' + use_socket_color = True #################### # - Socket Interface @@ -117,6 +118,58 @@ class ExprBLSocket(base.MaxwellSimSocket): mathtype: spux.MathType = bl_cache.BLField(spux.MathType.Real) physical_type: spux.PhysicalType = bl_cache.BLField(spux.PhysicalType.NonPhysical) + @bl_cache.cached_bl_property( + depends_on={ + 'active_kind', + 'symbols', + 'raw_value_spstr', + 'raw_min_spstr', + 'raw_max_spstr', + 'output_name', + 'mathtype', + 'physical_type', + 'unit', + 'size', + } + ) + def output_sym(self) -> sim_symbols.SimSymbol | None: + """Compute an appropriate `SimSymbol` to represent the mathematical and physical properties of the socket's own output. + + For the parsed string expression, functionality is derived heavily from the internal method `self._parse_expr_symbol()`. + + Raises: + NotImplementedError: When `active_kind` is neither `Value`, `Func`, or `Range`. + """ + if self.symbols: + if self.active_kind in [ct.FlowKind.Value, ct.FlowKind.Func]: + return self._parse_expr_symbol( + self._parse_expr_str(self.raw_value_spstr) + ) + + if self.active_kind is ct.FlowKind.Range: + ## TODO: Support RangeFlow + ## -- It's hard; we need a min-span set over bound domains. + ## -- We... Don't use this anywhere. Yet? + # sym_start = self._parse_expr_symbol( + # self._parse_expr_str(self.raw_min_spstr) + # ) + # sym_stop = self._parse_expr_symbol( + # self._parse_expr_str(self.raw_max_spstr) + # ) + msg = 'RangeFlow support not yet implemented for when self.symbols is not empty' + raise NotImplementedError(msg) + + raise NotImplementedError + + return sim_symbols.SimSymbol( + sym_name=self.output_name, + mathtype=self.mathtype, + physical_type=self.physical_type, + unit=self.unit, + rows=self.size.rows, + cols=self.size.cols, + ) + #################### # - Symbols #################### @@ -140,6 +193,11 @@ class ExprBLSocket(base.MaxwellSimSocket): """Computes `sympy` symbols from `self.sorted_symbols`.""" return [sym.sp_symbol_matsym for sym in self.sorted_symbols] + @bl_cache.cached_bl_property(depends_on={'symbols'}) + def sorted_symbol_names(self) -> list[sp.Symbol | sp.MatrixSymbol]: + """Computes the name of symbols in `self.sorted_symbols`.""" + return [sym.name for sym in self.sorted_symbols] + #################### # - Units #################### @@ -171,8 +229,13 @@ class ExprBLSocket(base.MaxwellSimSocket): return None - @property + @bl_cache.cached_bl_property(depends_on={'unit'}) def unit_factor(self) -> spux.Unit | None: + """Gets the current active unit as a factor, where unitless is `1`. + + Returns: + Same as `self.unit`, except `1` instead of `None` when there is no units. + """ return sp.Integer(1) if self.unit is None else self.unit prev_unit: str | None = bl_cache.BLField(None) @@ -228,26 +291,92 @@ class ExprBLSocket(base.MaxwellSimSocket): #################### # - Computed String Expressions #################### - @bl_cache.cached_bl_property(depends_on={'raw_value_spstr'}) + @bl_cache.cached_bl_property( + depends_on={'raw_value_spstr', 'sorted_symbol_names', 'symbols'} + ) def raw_value_sp(self) -> spux.SympyExpr: + """Parse the given symbolic `FlowKind.Value` string into a `sympy` expression. + + Notes: + The `self.*` properties used by `_parse_expr_str` must be included in the `depends_on` of any `cached_bl_property`s that use it. + + Directly derived from the internal method `self._parse_expr_str()`, which acts on `raw_value_spstr`. + """ return self._parse_expr_str(self.raw_value_spstr) - @bl_cache.cached_bl_property(depends_on={'raw_min_spstr'}) + @bl_cache.cached_bl_property( + depends_on={'raw_min_spstr', 'sorted_symbol_names', 'symbols'} + ) def raw_min_sp(self) -> spux.SympyExpr: + """Parse the given symbolic `FlowKind.Range` string (for the lower bound) into a `sympy` expression. + + Notes: + The `self.*` properties used by `_parse_expr_str` must be included in the `depends_on` of any `cached_bl_property`s that use it. + + Directly derived from the internal method `self._parse_expr_str()`, which acts on `raw_min_spstr`. + """ return self._parse_expr_str(self.raw_min_spstr) - @bl_cache.cached_bl_property(depends_on={'raw_max_spstr'}) + @bl_cache.cached_bl_property( + depends_on={'raw_max_spstr', 'sorted_symbol_names', 'symbols'} + ) def raw_max_sp(self) -> spux.SympyExpr: + """Parse the given symbolic `FlowKind.Range` string (for the upper bound) into a `sympy` expression. + + Notes: + The `self.*` properties used by `_parse_expr_str` must be included in the `depends_on` of any `cached_bl_property`s that use it. + + Directly derived from the internal method `self._parse_expr_str()`, which acts on `raw_max_spstr`. + """ return self._parse_expr_str(self.raw_max_spstr) #################### - # - Prop-Change Callback + # - Event Callbacks #################### - def on_socket_prop_changed(self, prop_name: str) -> None: + def on_socket_data_changed(self, socket_kinds: set[ct.FlowKind]) -> None: + """Alter the socket's color in response to flow. + + - `FlowKind.Info`: Any change causes the socket color to be updated with the physical type of the output symbol. + + Notes: + Overridden method called whenever `FlowEvent.LinkChanged` is generated on this socket, in response to link add/link remove. + + See `MaxwellSimTree` for more detail on the link callbacks. + """ + ## NODE: Depends on suppressed on_prop_changed + + if ct.FlowKind.Info in socket_kinds: + info = self.compute_data(kind=ct.FlowKind.Info) + has_info = not ct.FlowSignal.check(info) + + # Alter Color + pt_color = ( + info.output.physical_type.color + if has_info + else self.physical_type.color + ) + if self.socket_color != pt_color: + self.socket_color = pt_color + + def on_socket_props_changed( + self, + cleared_blfields: set[ + tuple[str, typ.Literal['invalidate', 'reset_enum', 'reset_strsearch']] + ], + ) -> None: + """Alter the socket in response to local property changes. + + Notes: + Overridden method called whenever `FlowEvent.LinkChanged` is generated on this socket, in response to link add/link remove. + + See `MaxwellSimTree` for more detail on the link callbacks. + """ + ## NODE: Depends on suppressed on_prop_changed + # 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': + if ('unit', 'invalidate') in cleared_blfields: # Check Unit Change ## -> self.prev_unit only updates here; "lags" behind self.unit. ## -> 1. "Laggy" unit must be different than new unit. @@ -272,37 +401,6 @@ class ExprBLSocket(base.MaxwellSimSocket): #################### # - 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): - msg = f'MathType is {self.mathtype}, but tried to set expr {expr} with mathtype {mathtype}' - raise ValueError(msg) - - # Parse Symbols - if expr.free_symbols and not expr.free_symbols.issubset(self.sp_symbols): - msg = f'Tried to set expr {expr} with free symbols {expr.free_symbols}, which is incompatible with socket symbols {self.symbols}' - raise ValueError(msg) - - # Parse Dimensions - shape = spux.parse_shape(expr) - 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) - - 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: @@ -324,38 +422,117 @@ class ExprBLSocket(base.MaxwellSimSocket): return pyvalue + def _parse_expr_symbol( + self, expr: spux.SympyExpr | None + ) -> sim_symbols.SimSymbol | None: + """Deduce the `SimSymbol` corresponding to the given `expr`, else None.""" + if expr is not None and ( + not expr.free_symbols or expr.free_symbols.issubset(self.sp_symbols) + ): + # Compute Units of Expression + ## -> The output units may not be physically meaningful. + ## -> However, "weird units" may be a good indicator of problems. + ## -> So, we let the user shoot their foot off. + unit_expr = expr.subs( + {sym.sp_symbol: sym.unit_factor for sym in self.symbols} + ) + + return sim_symbols.SimSymbol.from_expr( + self.output_name, expr, unit_expr, optional=True + ) + + return 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. + Uses `self._parse_expr_symbol()` to validate the parsed result. Returns: The parsed expression, if it manages to validate; else None. """ - expr = sp.sympify( + expr = sp.parsing.sympy_parser.parse_expr( expr_spstr, - locals={sym.name: sym.sp_symbol_matsym for sym in self.symbols}, - strict=False, - convert_xor=True, - ).subs(spux.UNIT_BY_SYMBOL) + local_dict=( + {sym.name: sym.sp_symbol_matsym for sym in self.symbols} + | {sym.name: unit for sym, unit in spux.UNIT_BY_SYMBOL.items()} + ), + transformations=[ + # Lambda Notation: Symbolic Anonymous Functions + ## -> Interpret 'lambda: x/8' to sp.Lambda((), x/0) + sp.parsing.sympy_parser.lambda_notation, + # Automatic Symbols + ## -> Interpret known functions as their sympy equivs. + ## -> Interpret unknown 'x' as sp.Symbol('x') + ## -> NOTE: Must check for extraneous/unwelcome unknowns. + sp.parsing.sympy_parser.auto_symbol, + # Repeated Decimals + ## -> Interpret '0.2[1]' as 0.211111... + sp.parsing.sympy_parser.repeated_decimals, + # Number Literals + ## -> Interpret ints/float literals. + ## -> Interpret 'I' as the imaginary number literal. + ## -> TODO: Maybe special-case the variable name 'I'? + sp.parsing.sympy_parser.auto_number, + # Factorial Notation + ## -> Allow 'x!' to be the factorial of x. + sp.parsing.sympy_parser.factorial_notation, + # Rationalize Float -> Rational + ## -> Helps numerical stability for pure-symbolic math. + ## -> AFTER auto_number + sp.parsing.sympy_parser.rationalize, + # Carrot Exponentiation + ## -> Interpret '^' as power, instead of as XOR. + sp.parsing.sympy_parser.convert_xor, + # Symbol Splitting + ## -> Interpret 'xyz' as 'x*y*z' for convenience. + ## -> NEVER split greek character names (ex. theta). + ## -> NEVER split symbol names in 'self.symbols'. + sp.parsing.sympy_parser.split_symbols_custom( + predicate=lambda sym_name: ( + sp.parsing.sympy_parser._token_splittable(sym_name) # noqa: SLF001 + if sym_name not in self.sorted_symbol_names + else False + ) + ), + # Implicit Mult/Call + ## -> Most times, allow '2x' as '2*x' / '2 x y' as '2*x*y'. + ## -> Sometimes, allow 'sin 2x' as 'sin(2*x)' + ## -> Allow functions to be exponentiated ex. 'sin^2 x' + sp.parsing.sympy_parser.implicit_multiplication, + sp.parsing.sympy_parser.implicit_application, + sp.parsing.sympy_parser.function_exponentiation, + ], + ) - # Try Parsing and Returning the Expression - try: - self._parse_expr_info(expr) - except ValueError: - log.exception( - 'Couldn\'t parse expression "%s" in Expr socket.', - expr_spstr, - ) - else: + if self._parse_expr_symbol(expr) is not None: return expr - return None #################### # - FlowKind: Value #################### - @property + @bl_cache.cached_bl_property( + depends_on={ + 'symbols', + 'unit', + 'mathtype', + 'size', + 'raw_value_sp', + 'raw_value_int', + 'raw_value_rat', + 'raw_value_float', + 'raw_value_complex', + 'raw_value_int2', + 'raw_value_rat2', + 'raw_value_float2', + 'raw_value_complex2', + 'raw_value_int3', + 'raw_value_rat3', + 'raw_value_float3', + 'raw_value_complex3', + } + ) def value(self) -> spux.SympyExpr: """Return the expression defined by the socket as `FlowKind.Value`. @@ -382,8 +559,8 @@ class ExprBLSocket(base.MaxwellSimSocket): ## -> 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 + if self.size is NS.Vec4: + return ct.FlowSignal.NoFlow MT_Z = spux.MathType.Integer MT_Q = spux.MathType.Rational @@ -430,7 +607,6 @@ class ExprBLSocket(base.MaxwellSimSocket): Notes: Called to set the internal `FlowKind.Value` of this socket. """ - _mathtype, _size = self._parse_expr_info(expr) if self.symbols: self.raw_value_spstr = sp.sstr(expr) else: @@ -473,7 +649,22 @@ class ExprBLSocket(base.MaxwellSimSocket): #################### # - FlowKind: Range #################### - @property + @bl_cache.cached_bl_property( + depends_on={ + 'symbols', + 'unit', + 'mathtype', + 'size', + 'steps', + 'scaling', + 'raw_min_sp', + 'raw_max_sp', + 'raw_range_int', + 'raw_range_rat', + 'raw_range_float', + 'raw_range_complex', + } + ) def lazy_range(self) -> ct.RangeFlow: """Return the not-yet-computed uniform array defined by the socket. @@ -519,18 +710,18 @@ class ExprBLSocket(base.MaxwellSimSocket): ) @lazy_range.setter - def lazy_range(self, value: ct.RangeFlow) -> None: + def lazy_range(self, lazy_range: ct.RangeFlow) -> None: """Set the not-yet-computed uniform array defined by the socket. Notes: Called to compute the internal `FlowKind.Range` of this socket. """ - self.steps = value.steps - self.scaling = value.scaling + self.steps = lazy_range.steps + self.scaling = lazy_range.scaling if self.symbols: - self.raw_min_spstr = sp.sstr(value.start) - self.raw_max_spstr = sp.sstr(value.stop) + self.raw_min_spstr = sp.sstr(lazy_range.start) + self.raw_max_spstr = sp.sstr(lazy_range.stop) else: MT_Z = spux.MathType.Integer @@ -538,32 +729,40 @@ class ExprBLSocket(base.MaxwellSimSocket): MT_R = spux.MathType.Real MT_C = spux.MathType.Complex - unit = value.unit if value.unit is not None else 1 + unit = lazy_range.unit if lazy_range.unit is not None else 1 if self.mathtype == MT_Z: self.raw_range_int = [ self._to_raw_value(bound * unit) - for bound in [value.start, value.stop] + for bound in [lazy_range.start, lazy_range.stop] ] elif self.mathtype == MT_Q: self.raw_range_rat = [ self._to_raw_value(bound * unit) - for bound in [value.start, value.stop] + for bound in [lazy_range.start, lazy_range.stop] ] elif self.mathtype == MT_R: self.raw_range_float = [ self._to_raw_value(bound * unit) - for bound in [value.start, value.stop] + for bound in [lazy_range.start, lazy_range.stop] ] 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] + for bound in [lazy_range.start, lazy_range.stop] ] #################### # - FlowKind: Func (w/Params if Constant) #################### - @property + @bl_cache.cached_bl_property( + depends_on={ + 'value', + 'symbols', + 'sorted_sp_symbols', + 'sorted_symbols', + 'output_sym', + } + ) def lazy_func(self) -> ct.FuncFlow: """Returns a lazy value that computes the expression returned by `self.value`. @@ -574,15 +773,21 @@ class ExprBLSocket(base.MaxwellSimSocket): ## -> `self.value` is guaranteed to be an expression with unknowns. ## -> The function computes `self.value` with unknowns as arguments. if self.symbols: - return ct.FuncFlow( - func=sp.lambdify( - self.sorted_sp_symbols, - spux.strip_unit_system(self.value), - 'jax', - ), - func_args=list(self.sorted_symbols), - supports_jax=True, - ) + value = self.value + has_value = not ct.FlowSignal.check(value) + + output_sym = self.output_sym + if output_sym is not None and has_value: + return ct.FuncFlow( + func=sp.lambdify( + self.sorted_sp_symbols, + output_sym.conform(value, strip_unit=True), + 'jax', + ), + func_args=list(self.sorted_symbols), + supports_jax=True, + ) + return ct.FlowSignal.FlowPending # Constant ## -> When a `self.value` has no unknowns, use a dummy function. @@ -591,15 +796,25 @@ class ExprBLSocket(base.MaxwellSimSocket): ## -> Generally only useful for operations with other expressions. return ct.FuncFlow( func=lambda v: v, - func_args=[ - sim_symbols.SimSymbol.from_expr( - sim_symbols.SimSymbolName.Constant, self.value, self.unit_factor - ) - ], + func_args=[self.output_sym], supports_jax=True, ) - @property + @bl_cache.cached_bl_property(depends_on={'sorted_symbols'}) + def is_differentiable(self) -> bool: + """Whether all symbols are differentiable. + + If there are no symbols, then there is nothing to differentiate, and thus the expression is differentiable. + """ + if not self.sorted_symbols: + return True + + return all( + sym.mathtype in [spux.MathType.Real, spux.MathType.Complex] + for sym in self.sorted_symbols + ) + + @bl_cache.cached_bl_property(depends_on={'sorted_symbols', 'output_sym', 'value'}) def params(self) -> ct.ParamsFlow: """Returns parameter symbols/values to accompany `self.lazy_func`. @@ -611,19 +826,28 @@ class ExprBLSocket(base.MaxwellSimSocket): ## -> 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=[sym.sp_symbol_phy for sym in self.sorted_symbols], - symbols=self.sorted_symbols, - ) + if self.sorted_symbols: + output_sym = self.output_sym + if output_sym is not None: + return ct.ParamsFlow( + arg_targets=list(self.sorted_symbols), + func_args=[sym.sp_symbol for sym in self.sorted_symbols], + symbols=self.sorted_symbols, + is_differentiable=self.is_differentiable, + ) + return ct.FlowSignal.FlowPending # Constant ## -> Simply pass self.value verbatim as a function argument. ## -> Easy dice, easy life! - return ct.ParamsFlow(func_args=[self.value]) + return ct.ParamsFlow( + arg_targets=[self.output_sym], + func_args=[self.value], + is_differentiable=self.is_differentiable, + ) - @property - def info(self) -> ct.ArrayFlow: + @bl_cache.cached_bl_property(depends_on={'sorted_symbols', 'output_sym'}) + def info(self) -> ct.InfoFlow: r"""Returns parameter symbols/values to accompany `self.lazy_func`. The output name/size/mathtype/unit corresponds directly the `ExprSocket`. @@ -634,37 +858,78 @@ class ExprBLSocket(base.MaxwellSimSocket): Otherwise, only the output name/size/mathtype/unit corresponding to the socket is passed along. """ - output_sym = sim_symbols.SimSymbol( - sym_name=self.output_name, - mathtype=self.mathtype, - physical_type=self.physical_type, - unit=self.unit, - rows=self.size.rows, - cols=self.size.cols, - ) - # Constant ## -> The input SimSymbols become continuous dimensional indices. ## -> All domain validity information is defined on the SimSymbol keys. - if self.symbols: - return ct.InfoFlow( - dims={sym: None for sym in self.sorted_symbols}, - output=output_sym, - ) + if self.sorted_symbols: + output_sym = self.output_sym + if output_sym is not None: + return ct.InfoFlow( + dims={sym: None for sym in self.sorted_symbols}, + output=self.output_sym, + ) + return ct.FlowSignal.FlowPending # Constant ## -> We only need the output symbol to describe the raw data. - return ct.InfoFlow(output=output_sym) + return ct.InfoFlow(output=self.output_sym) #################### # - FlowKind: Capabilities #################### - @property - def capabilities(self) -> None: + def linked_capabilities(self, info: ct.InfoFlow) -> ct.CapabilitiesFlow: + """When this socket is linked as an output socket, expose these capabilities instead of querying `self.capabilities`. + + Only used when `use_linked_capabilities == True`. + """ return ct.CapabilitiesFlow( socket_type=self.socket_type, active_kind=self.active_kind, - allow_out_to_in={ct.FlowKind.Func: ct.FlowKind.Value}, + allow_out_to_in={ + ct.FlowKind.Func: ct.FlowKind.Value, + }, + allow_out_to_in_if_matches={ + ct.FlowKind.Value: ( + ct.FlowKind.Func, + ( + info.output.physical_type, + info.output.mathtype, + info.output.rows, + info.output.cols, + ), + ), + }, + ) + + @bl_cache.cached_bl_property(depends_on={'active_kind', 'output_sym'}) + def capabilities(self) -> ct.CapabilitiesFlow: + """Expose capabilities for use when checking socket link compatibility. + + Only used when `use_linked_capabilities == True`. + """ + output_sym = self.output_sym + if output_sym is not None: + allow_out_to_in_if_matches = { + ct.FlowKind.Value: ( + ct.FlowKind.Func, + ( + output_sym.physical_type, + output_sym.mathtype, + output_sym.rows, + output_sym.cols, + ), + ), + } + else: + allow_out_to_in_if_matches = {} + + return ct.CapabilitiesFlow( + socket_type=self.socket_type, + active_kind=self.active_kind, + allow_out_to_in={ + ct.FlowKind.Func: ct.FlowKind.Value, + }, + allow_out_to_in_if_matches=allow_out_to_in_if_matches, ) #################### @@ -692,29 +957,32 @@ class ExprBLSocket(base.MaxwellSimSocket): 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) + if self.active_kind is ct.FlowKind.Func: + info = self.compute_data(kind=ct.FlowKind.Info) + has_info = not ct.FlowSignal.check(info) - if has_info: - split = row.split(factor=0.85, align=True) - _row = split.row(align=False) + if has_info: + split = row.split(factor=0.85, align=True) + _row = split.row(align=False) + else: + _row = row + + _row.label(text=text) + if has_info: + if self.show_info_columns: + _row.prop(self, self.blfields['info_columns']) + + _row = split.row(align=True) + _row.alignment = 'RIGHT' + _row.prop( + self, + self.blfields['show_info_columns'], + toggle=True, + text='', + icon=ct.Icon.ToggleSocketInfo, + ) else: - _row = row - - _row.label(text=text) - if has_info: - if self.show_info_columns: - _row.prop(self, self.blfields['info_columns']) - - _row = split.row(align=True) - _row.alignment = 'RIGHT' - _row.prop( - self, - self.blfields['show_info_columns'], - toggle=True, - text='', - icon=ct.Icon.ToggleSocketInfo, - ) + row.label(text=text) 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. @@ -724,29 +992,32 @@ class ExprBLSocket(base.MaxwellSimSocket): 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) + if self.active_kind is ct.FlowKind.Func: + info = self.compute_data(kind=ct.FlowKind.Info) + has_info = not ct.FlowSignal.check(info) - if has_info: - split = row.split(factor=0.15, align=True) + if has_info: + split = row.split(factor=0.15, align=True) - _row = split.row(align=True) - _row.prop( - self, - self.blfields['show_info_columns'], - toggle=True, - text='', - icon=ct.Icon.ToggleSocketInfo, - ) + _row = split.row(align=True) + _row.prop( + self, + self.blfields['show_info_columns'], + toggle=True, + text='', + icon=ct.Icon.ToggleSocketInfo, + ) - _row = split.row(align=False) - _row.alignment = 'RIGHT' - if self.show_info_columns: - _row.prop(self, self.blfields['info_columns']) + _row = split.row(align=False) + _row.alignment = 'RIGHT' + if self.show_info_columns: + _row.prop(self, self.blfields['info_columns']) + else: + _col = _row.column() + _col.alignment = 'EXPAND' + _col.label(text='') else: - _col = _row.column() - _col.alignment = 'EXPAND' - _col.label(text='') + _row = row else: _row = row @@ -860,42 +1131,38 @@ class ExprBLSocket(base.MaxwellSimSocket): Uses `draw_value` to draw the base UI """ if self.show_func_ui: - # Non-Symbolic: Size/Mathtype Selector - ## -> Symbols imply str expr input. - ## -> For arbitrary str exprs, size/mathtype are derived from the expr. - ## -> Otherwise, size/mathtype must be pre-specified for a nice UI. - if not self.symbols: - row = col.row(align=True) - row.prop(self, self.blfields['size'], text='') - row.prop(self, self.blfields['mathtype'], text='') - - # Base UI - ## -> Draws the UI appropriate for the above choice of constraints. - self.draw_value(col) - - # Physical Type Selector - ## -> Determines whether/which unit-dropdown will be shown. - col.prop(self, self.blfields['physical_type'], text='') - - # Symbol UI - ## -> Draws the UI appropriate for the above choice of constraints. - ## -> TODO - # Output Name Selector ## -> The name of the output if self.show_name_selector: row = col.row() + row.alignment = 'CENTER' row.prop(self, self.blfields['output_name'], text='Name') + # Non-Symbolic: Size/Mathtype Selector + ## -> Symbols imply str expr input. + ## -> For arbitrary str exprs, size/mathtype are derived from the expr. + ## -> Otherwise, size/mathtype must be pre-specified for a nice UI. + if self.symbols: + self.draw_value(col) + + # TODO: Symbol UI + else: + row = col.row(align=True) + row.prop(self, self.blfields['size'], text='') + row.prop(self, self.blfields['mathtype'], text='') + + self.draw_value(col) + col.prop(self, self.blfields['physical_type'], text='') + #################### # - UI: InfoFlow #################### def draw_info(self, info: ct.InfoFlow, col: bpy.types.UILayout) -> None: """Visualize the `InfoFlow` information passing through the socket.""" if ( - self.active_kind == ct.FlowKind.Func + self.active_kind is ct.FlowKind.Func and self.show_info_columns - and self.is_linked + and (self.is_linked or self.is_output) ): row = col.row() box = row.box() @@ -922,7 +1189,7 @@ class ExprBLSocket(base.MaxwellSimSocket): if InfoDisplayCol.Length in self.info_columns: grid.label(text='', icon=ct.Icon.DataSocketOutput) if InfoDisplayCol.MathType in self.info_columns: - grid.label(text=info.output.def_label) + grid.label(text=info.output.mathtype_size_label) if InfoDisplayCol.Unit in self.info_columns: grid.label(text=info.output.unit_label) @@ -935,7 +1202,6 @@ class ExprSocketDef(base.SocketDef): active_kind: typ.Literal[ ct.FlowKind.Value, ct.FlowKind.Range, - ct.FlowKind.Array, ct.FlowKind.Func, ] = ct.FlowKind.Value output_name: sim_symbols.SimSymbolName = sim_symbols.SimSymbolName.Expr @@ -1240,6 +1506,7 @@ class ExprSocketDef(base.SocketDef): def init(self, bl_socket: ExprBLSocket) -> None: bl_socket.active_kind = self.active_kind bl_socket.output_name = self.output_name + bl_socket.use_linked_capabilities = True # Socket Interface ## -> Recall that auto-updates are turned off during init() diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/bound_cond.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/bound_cond.py index 6b1da12..0c3ae97 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/bound_cond.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/bound_cond.py @@ -15,6 +15,7 @@ # along with this program. If not, see . import bpy +import pydantic as pyd import tidy3d as td from blender_maxwell.utils import bl_cache, logger @@ -59,7 +60,9 @@ class MaxwellBoundCondBLSocket(base.MaxwellSimSocket): #################### # - FlowKind #################### - @property + @bl_cache.cached_bl_property( + depends_on={'active_kind', 'allow_axes', 'present_axes'} + ) def capabilities(self) -> ct.CapabilitiesFlow: return ct.CapabilitiesFlow( socket_type=self.socket_type, @@ -68,7 +71,7 @@ class MaxwellBoundCondBLSocket(base.MaxwellSimSocket): present_any=self.present_axes, ) - @property + @bl_cache.cached_bl_property(depends_on={'default'}) def value(self) -> td.BoundaryEdge: return self.default.tidy3d_boundary_edge @@ -84,16 +87,20 @@ class MaxwellBoundCondSocketDef(base.SocketDef): socket_type: ct.SocketType = ct.SocketType.MaxwellBoundCond default: ct.BoundCondType = ct.BoundCondType.Pml - allow_axes: set[ct.SimSpaceAxis] = { - ct.SimSpaceAxis.X, - ct.SimSpaceAxis.Y, - ct.SimSpaceAxis.Z, - } - present_axes: set[ct.SimSpaceAxis] = { - ct.SimSpaceAxis.X, - ct.SimSpaceAxis.Y, - ct.SimSpaceAxis.Z, - } + allow_axes: set[ct.SimSpaceAxis] = pyd.Field( + default={ + ct.SimSpaceAxis.X, + ct.SimSpaceAxis.Y, + ct.SimSpaceAxis.Z, + } + ) + present_axes: set[ct.SimSpaceAxis] = pyd.Field( + default={ + ct.SimSpaceAxis.X, + ct.SimSpaceAxis.Y, + ct.SimSpaceAxis.Z, + } + ) def init(self, bl_socket: MaxwellBoundCondBLSocket) -> None: bl_socket.default = self.default diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/bound_conds.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/bound_conds.py index 48b1dc9..ea69736 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/bound_conds.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/bound_conds.py @@ -86,7 +86,9 @@ class MaxwellBoundCondsBLSocket(base.MaxwellSimSocket): #################### # - Computation of Default Value #################### - @property + @bl_cache.cached_bl_property( + depends_on={'x_pos', 'x_neg', 'y_pos', 'y_neg', 'z_pos', 'z_neg'} + ) def value(self) -> td.BoundarySpec: """Compute a user-defined default value for simulation boundary conditions, from certain common/sensible options. diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/medium.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/medium.py index 55f707e..4b92a24 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/medium.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/medium.py @@ -18,6 +18,7 @@ import bpy import scipy as sc import sympy.physics.units as spu import tidy3d as td +import tidy3d.plugins.adjoint as tdadj from blender_maxwell.utils import bl_cache, logger from blender_maxwell.utils import extra_sympy_units as spux @@ -39,12 +40,14 @@ class MaxwellMediumBLSocket(base.MaxwellSimSocket): #################### # - Properties #################### - rel_permittivity: tuple[float, float] = bl_cache.BLField((1.0, 0.0), float_prec=2) + eps_rel: tuple[float, float] = bl_cache.BLField((1.0, 0.0), float_prec=2) + + differentiable: bool = bl_cache.BLField(False) #################### # - FlowKinds #################### - @property + @bl_cache.cached_bl_property(depends_on={'eps_rel', 'differentiable'}) def value(self) -> td.Medium: freq = ( spu.convert_to( @@ -53,31 +56,49 @@ class MaxwellMediumBLSocket(base.MaxwellSimSocket): ) / spu.hertz ) + + if self.differentiable: + return tdadj.JaxMedium.from_nk( + n=self.eps_rel[0], + k=self.eps_rel[1], + freq=freq, + ) return td.Medium.from_nk( - n=self.rel_permittivity[0], - k=self.rel_permittivity[1], + n=self.eps_rel[0], + k=self.eps_rel[1], freq=freq, ) @value.setter - def value( - self, value: tuple[spux.ConstrSympyExpr(allow_variables=False), complex] - ) -> None: - rel_permittivity = value + def value(self, eps_rel: tuple[float, float]) -> None: + self.eps_rel = eps_rel - self.rel_permittivity = (rel_permittivity.real, rel_permittivity.imag) + @bl_cache.cached_bl_property(depends_on={'value', 'differentiable'}) + def lazy_func(self) -> ct.FuncFlow: + return ct.FuncFlow( + func=lambda: self.value, + supports_jax=self.differentiable, + ) + + @bl_cache.cached_bl_property(depends_on={'differentiable'}) + def params(self) -> ct.FuncFlow: + return ct.ParamsFlow(is_differentiable=self.differentiable) #################### # - UI #################### def draw_value(self, col: bpy.types.UILayout) -> None: - split = col.split(factor=0.35, align=False) + col.prop( + self, self.blfields['differentiable'], text='Differentiable', toggle=True + ) + col.separator() + split = col.split(factor=0.25, align=False) - col = split.column(align=True) - col.label(text='Ļµ_r (ā„‚)') + _col = split.column(align=True) + _col.label(text='Īµįµ£') - col = split.column(align=True) - col.prop(self, self.blfields['rel_permittivity'], text='') + _col = split.column(align=True) + _col.prop(self, self.blfields['eps_rel'], text='') #################### @@ -90,7 +111,7 @@ class MaxwellMediumSocketDef(base.SocketDef): default_permittivity_imag: float = 0.0 def init(self, bl_socket: MaxwellMediumBLSocket) -> None: - bl_socket.rel_permittivity = ( + bl_socket.eps_rel = ( self.default_permittivity_real, self.default_permittivity_imag, ) diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/sim_grid.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/sim_grid.py index 3267fa6..80e4635 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/sim_grid.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/sim_grid.py @@ -49,7 +49,7 @@ class MaxwellSimGridBLSocket(base.MaxwellSimSocket): #################### # - Computation of Default Value #################### - @property + @bl_cache.cached_bl_property(depends_on={'min_steps_per_wl'}) def value(self) -> td.GridSpec: return td.GridSpec.auto( min_steps_per_wvl=self.min_steps_per_wl, 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 ad6d08b..28fe9e8 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 @@ -50,7 +50,9 @@ class ReloadFolderList(bpy.types.Operator): tdcloud.TidyCloudTasks.update_tasks(bl_socket.existing_folder_id) bl_socket.existing_folder_id = bl_cache.Signal.ResetEnumItems + bl_socket.existing_folder_id = bl_cache.Signal.InvalidateCache bl_socket.existing_task_id = bl_cache.Signal.ResetEnumItems + bl_socket.existing_task_id = bl_cache.Signal.InvalidateCache return {'FINISHED'} @@ -77,7 +79,9 @@ class Authenticate(bpy.types.Operator): bl_socket.api_key = '' bl_socket.existing_folder_id = bl_cache.Signal.ResetEnumItems + bl_socket.existing_folder_id = bl_cache.Signal.InvalidateCache bl_socket.existing_task_id = bl_cache.Signal.ResetEnumItems + bl_socket.existing_task_id = bl_cache.Signal.InvalidateCache return {'FINISHED'} @@ -102,62 +106,18 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket): #################### # - Properties #################### - api_key: str = bl_cache.BLField('', prop_ui=True, str_secret=True) + api_key: str = bl_cache.BLField('', str_secret=True) should_exist: bool = bl_cache.BLField(False) + new_task_name: str = bl_cache.BLField('') + + #################### + # - Properties: Cloud Folders + #################### existing_folder_id: enum.StrEnum = bl_cache.BLField( - prop_ui=True, enum_cb=lambda self, _: self.search_cloud_folders() - ) - existing_task_id: enum.StrEnum = bl_cache.BLField( - prop_ui=True, enum_cb=lambda self, _: self.search_cloud_tasks() + enum_cb=lambda self, _: self.search_cloud_folders() ) - new_task_name: str = bl_cache.BLField('', prop_ui=True) - - #################### - # - FlowKinds - #################### - @property - def capabilities(self) -> ct.CapabilitiesFlow: - return ct.CapabilitiesFlow( - socket_type=self.socket_type, - active_kind=self.active_kind, - must_match={'should_exist': self.should_exist}, - ) - - @property - def value( - self, - ) -> ct.NewSimCloudTask | tdcloud.CloudTask | ct.FlowSignal: - if tdcloud.IS_AUTHENTICATED: - # Retrieve Folder - cloud_folder = tdcloud.TidyCloudFolders.folders().get( - self.existing_folder_id - ) - if cloud_folder is None: - return ct.FlowSignal.NoFlow ## Folder deleted somewhere else - - # Case: New Task - if not self.should_exist: - return ct.NewSimCloudTask( - task_name=self.new_task_name, cloud_folder=cloud_folder - ) - - # Case: Existing Task - if self.existing_task_id is not None: - cloud_task = tdcloud.TidyCloudTasks.tasks(cloud_folder).get( - self.existing_task_id - ) - if cloud_folder is None: - return ct.FlowSignal.NoFlow ## Task deleted somewhere else - - return cloud_task - - return ct.FlowSignal.FlowPending - - #################### - # - Searchers - #################### def search_cloud_folders(self) -> list[ct.BLEnumElement]: if tdcloud.IS_AUTHENTICATED: return [ @@ -175,6 +135,13 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket): return [] + #################### + # - Properties: Cloud Tasks + #################### + existing_task_id: enum.StrEnum = bl_cache.BLField( + enum_cb=lambda self, _: self.search_cloud_tasks() + ) + def search_cloud_tasks(self) -> list[ct.BLEnumElement]: if self.existing_folder_id is None or not tdcloud.IS_AUTHENTICATED: return [] @@ -228,6 +195,54 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket): ) ] + #################### + # - FlowKinds + #################### + @bl_cache.cached_bl_property(depends_on={'active_kind', 'should_exist'}) + def capabilities(self) -> ct.CapabilitiesFlow: + return ct.CapabilitiesFlow( + socket_type=self.socket_type, + active_kind=self.active_kind, + must_match={'should_exist': self.should_exist}, + ) + + @bl_cache.cached_bl_property( + depends_on={ + 'should_exist', + 'new_task_name', + 'existing_folder_id', + 'existing_task_id', + } + ) + def value( + self, + ) -> ct.NewSimCloudTask | tdcloud.CloudTask | ct.FlowSignal: + if tdcloud.IS_AUTHENTICATED: + # Retrieve Folder + cloud_folder = tdcloud.TidyCloudFolders.folders().get( + self.existing_folder_id + ) + if cloud_folder is None: + return ct.FlowSignal.NoFlow ## Folder deleted somewhere else + + # Case: New Task + if not self.should_exist: + return ct.NewSimCloudTask( + task_name=self.new_task_name, cloud_folder=cloud_folder + ) + + # Case: Existing Task + if self.existing_task_id is not None: + cloud_task = tdcloud.TidyCloudTasks.tasks(cloud_folder).get( + self.existing_task_id + ) + if cloud_folder is None: + return ct.FlowSignal.NoFlow ## Task deleted somewhere else + + return cloud_task + + return ct.FlowSignal.FlowPending + #################### # - UI #################### diff --git a/src/blender_maxwell/utils/bl_cache/bl_field.py b/src/blender_maxwell/utils/bl_cache/bl_field.py index 00b8140..110a985 100644 --- a/src/blender_maxwell/utils/bl_cache/bl_field.py +++ b/src/blender_maxwell/utils/bl_cache/bl_field.py @@ -16,6 +16,7 @@ """Implements various key caches on instances of Blender objects, especially nodes and sockets.""" +import contextlib import functools import inspect import typing as typ @@ -166,7 +167,7 @@ class BLField: self.cb_depends_on: set[str] | None = cb_depends_on # Update Suppressing - self.suppress_update: dict[str, bool] = {} + self.suppressed_update: dict[str, bool] = {} #################### # - Descriptor Setup @@ -253,9 +254,38 @@ class BLField: 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 + @contextlib.contextmanager + def suppress_update(self, bl_instance: bl_instance.BLInstance) -> None: + """A context manager that suppresses all calls to `on_prop_changed()` for fields of the given `bl_instance` while active. + + Any change to a `BLProp` managed by this descriptor inevitably trips `bl_instance.on_bl_prop_changed()`. + In response to these changes, `bl_instance.on_bl_prop_changed()` always signals the `Signal.InvalidateCache` via this descriptor. + Unless something interferes, this results in a call to `bl_instance.on_prop_changed()`. + + Usually, this is great. + But sometimes, like when ex. refreshing enum items, we **want** to be able to set the value of the `BLProp` **without** triggering that `bl_instance.on_prop_changed()`. + By default, there is absolutely no way to accomplish this. + + That's where this context manager comes into play. + While active, all calls to `bl_instance.on_prop_changed()` will be ignored for the given `bl_instance`, allowing us to freely set persistent properties without side effects. + + Examples: + A simple illustrative example could look something like: + + ```python + with self.suppress_update(bl_instance): + self.bl_prop.write(bl_instance, 'I won't trigger an update') + + self.bl_prop.write(bl_instance, 'I will trigger an update') + ``` + """ + self.suppressed_update[bl_instance.instance_id] = True + try: + yield + finally: + self.suppressed_update[bl_instance.instance_id] = False + ## -> We could .pop(None). + ## -> But keeping a reused memory location around is GC friendly. def __set__( self, bl_instance: bl_instance.BLInstance | None, value: typ.Any @@ -263,7 +293,7 @@ class BLField: """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. + 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 `FlowEvent.DataChanged` event chain. Notes: Run by Python when the attribute described by the descriptor is set. @@ -273,28 +303,29 @@ class BLField: 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: + if 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: + # Trigger Update Chain + ## -> User can disable w/'use_prop_update=False'. + ## -> Use InvalidateCacheNoUpdate to explicitly disable update. + ## -> If 'suppressed_update' context manager is active, don't update. + if ( + self.prop_info['use_prop_update'] + and value is Signal.InvalidateCache + and not self.suppressed_update.get(bl_instance.instance_id, False) + ): bl_instance.on_prop_changed(self.bl_prop.name) # Reset Enum Items + ## -> If there is no enum items callback, do nothing. + ## -> Re-run the enum items callback and set it active. + ## -> If the old item can be retained, then do so. + ## -> Otherwise, set the first item. elif value is Signal.ResetEnumItems: if self.bl_prop_enum_items is None: return @@ -335,8 +366,8 @@ class BLField: # 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) + with self.suppress_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. @@ -344,9 +375,8 @@ class BLField: ## -> Thus, we set it - Blender sees a change, user doesn't. ## -> DO NOT trigger on_prop_changed (since "nothing changed"). if any(raw_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. + with self.suppress_update(bl_instance): + self.bl_prop.write(bl_instance, old_item) # Old Item Not in Current Items ## -> In this case, fallback to the first current item. @@ -355,28 +385,27 @@ class BLField: raw_first_current_item = current_items[0][0] first_current_item = self.bl_prop.decode(raw_first_current_item) - 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) + with self.suppress_update(bl_instance): + self.bl_prop.write(bl_instance, first_current_item) # Reset Str Search + ## -> If there is no string search method, do nothing. + ## -> Simply invalidate the non-persistent cache elif value is Signal.ResetStrSearch: if self.bl_prop_str_search is None: return self.bl_prop_str_search.invalidate_nonpersist(bl_instance) - # General __set__ + # Default __set__ else: - self.bl_prop.write(bl_instance, value) + with self.suppress_update(bl_instance): + 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']: + if self.prop_info['use_prop_update'] and not self.suppressed_update.get( + bl_instance.instance_id, False + ): bl_instance.on_prop_changed(self.bl_prop.name) #################### diff --git a/src/blender_maxwell/utils/bl_cache/cached_bl_property.py b/src/blender_maxwell/utils/bl_cache/cached_bl_property.py index cbeac2e..aec3596 100644 --- a/src/blender_maxwell/utils/bl_cache/cached_bl_property.py +++ b/src/blender_maxwell/utils/bl_cache/cached_bl_property.py @@ -16,6 +16,7 @@ """Implements various key caches on instances of Blender objects, especially nodes and sockets.""" +import contextlib import inspect import typing as typ @@ -76,7 +77,7 @@ class CachedBLProperty: self.decode_type: type = inspect.signature(getter_method).return_annotation # Write Suppressing - self.suppress_write: dict[str, bool] = {} + self.suppressed_update: dict[str, bool] = {} # Check Non-Empty Type Annotation ## For now, just presume that all types can be encoded/decoded. @@ -125,9 +126,38 @@ class CachedBLProperty: return Signal.CacheNotReady return cached_value - def suppress_next_write(self, bl_instance) -> None: - self.suppress_write[bl_instance.instance_id] = True - ## TODO: Make it a context manager to prevent the worst of surprises + @contextlib.contextmanager + def suppress_update(self, bl_instance: bl_instance.BLInstance) -> None: + """A context manager that suppresses all calls to `on_prop_changed()` for fields of the given `bl_instance` while active. + + Any change to a `BLProp` managed by this descriptor inevitably trips `bl_instance.on_bl_prop_changed()`. + In response to these changes, `bl_instance.on_bl_prop_changed()` always signals the `Signal.InvalidateCache` via this descriptor. + Unless something interferes, this results in a call to `bl_instance.on_prop_changed()`. + + Usually, this is great. + But sometimes, like when ex. refreshing enum items, we **want** to be able to set the value of the `BLProp` **without** triggering that `bl_instance.on_prop_changed()`. + By default, there is absolutely no way to accomplish this. + + That's where this context manager comes into play. + While active, all calls to `bl_instance.on_prop_changed()` will be ignored for the given `bl_instance`, allowing us to freely set persistent properties without side effects. + + Examples: + A simple illustrative example could look something like: + + ```python + with self.suppress_update(bl_instance): + self.bl_prop.write(bl_instance, 'I won't trigger an update') + + self.bl_prop.write(bl_instance, 'I will trigger an update') + ``` + """ + self.suppressed_update[bl_instance.instance_id] = True + try: + yield + finally: + self.suppressed_update[bl_instance.instance_id] = False + ## -> We could .pop(None). + ## -> But keeping a reused memory location around is GC friendly. def __set__( self, bl_instance: bl_instance.BLInstance | None, value: typ.Any @@ -141,44 +171,59 @@ class CachedBLProperty: 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 Cache + ## -> This empties the non-persistent cache. + ## -> If persist=True, this also writes the persistent cache (no update). + ## The 'on_prop_changed' method on the bl_instance might also be called. + if 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 and not self.suppress_write.get( - bl_instance.instance_id - ): - self.bl_prop.write(bl_instance, self.getter_method(bl_instance)) + ## -> persist=True: Fill Persist and Non-Persist Cache + ## -> persist=False: Fill Non-Persist Cache + if self.persist: + with self.suppress_update(bl_instance): + 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: + # Trigger Update + ## -> Use InvalidateCacheNoUpdate to explicitly disable update. + ## -> If 'suppress_update' context manager is active, don't update. + if value is Signal.InvalidateCache and not self.suppressed_update.get( + bl_instance.instance_id + ): bl_instance.on_prop_changed(self.bl_prop.name) + # Call Setter 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) + if bl_instance is not None: + # Run Setter + ## -> The user-provided setter can set values as it sees fit. + ## -> The user-provided setter will not immediately trigger updates. + with self.suppress_update(bl_instance): + self.setter_method(bl_instance, value) - # Fill Non-Persistant (and maybe Persistent) Cache - if self.persist and not self.suppress_write.get(bl_instance.instance_id): - self.bl_prop.write(bl_instance, self.getter_method(bl_instance)) + # Fill Caches + ## -> persist=True: Fill Persist and Non-Persist Cache + ## -> persist=False: Fill Non-Persist Cache + if self.persist: + with self.suppress_update(bl_instance): + 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: + self.bl_prop.write_nonpersist( + bl_instance, self.getter_method(bl_instance) + ) + + # Trigger Update + ## -> If 'suppress_update' context manager is active, don't update. + if not self.suppressed_update.get(bl_instance.instance_id): + 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' diff --git a/src/blender_maxwell/utils/bl_cache/signal.py b/src/blender_maxwell/utils/bl_cache/signal.py index 08c337d..a1ebda3 100644 --- a/src/blender_maxwell/utils/bl_cache/signal.py +++ b/src/blender_maxwell/utils/bl_cache/signal.py @@ -39,8 +39,6 @@ class Signal(enum.StrEnum): 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. @@ -53,7 +51,6 @@ class Signal(enum.StrEnum): # Invalidation InvalidateCache: str = str(uuid.uuid4()) InvalidateCacheNoUpdate: str = str(uuid.uuid4()) - DoUpdate: str = str(uuid.uuid4()) # Reset Signals ## -> Invalidates data adjascent to fields. diff --git a/src/blender_maxwell/utils/bl_instance.py b/src/blender_maxwell/utils/bl_instance.py index cf11c6d..f17c12a 100644 --- a/src/blender_maxwell/utils/bl_instance.py +++ b/src/blender_maxwell/utils/bl_instance.py @@ -220,7 +220,13 @@ class BLInstance: for str_search_prop_name in self.blfields_str_search: setattr(self, str_search_prop_name, bl_cache.Signal.ResetStrSearch) - def invalidate_blfield_deps(self, prop_name: str) -> None: + def trace_blfields_to_clear( + self, + prop_name: str, + prev_blfields_to_clear: list[ + tuple[str, typ.Literal['invalidate', 'reset_enum', 'reset_strsearch']] + ] = (), + ) -> list[str]: """Invalidates all properties that depend on `prop_name`. A property can recursively depend on other properties, including specificity as to whether the cache should be invalidated, the enum items be recomputed, or the string search items be recomputed. @@ -232,35 +238,110 @@ class BLInstance: The dictionaries governing exactly what invalidates what, and how, are encoded as `self.blfield_deps`, `self.blfield_dynamic_enum_deps`, and `self.blfield_str_search_deps`. All of these are filled when creating the `BLInstance` subclass, using `self.declare_blfield_dep()`, generally via the `BLField` descriptor (which internally uses `BLProp`). """ + if prev_blfields_to_clear: + blfields_to_clear = prev_blfields_to_clear.copy() + else: + blfields_to_clear = [] + # Invalidate Dependent Properties (incl. DynEnums and StrSearch) - ## -> NOTE: Dependent props may also trigger `on_prop_changed`. - ## -> Don't abuse dependencies :) - for deps, invalidate_signal in zip( + ## -> InvalidateCacheNoUpdate: Exactly what it sounds like. + ## -> ResetEnumItems: Won't trigger on_prop_changed. + ## -> -- To get on_prop_changed after, do explicit 'InvalidateCache'. + ## -> StrSearch: It's a straight computation, no on_prop_changed. + for deps, clear_method 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, - ], + ['invalidate', 'reset_enum', 'reset_strsearch'], strict=True, ): if prop_name in deps: for dst_prop_name in deps[prop_name]: - log.debug( - '%s: "%s" is invalidating "%s"', - self.bl_label, - prop_name, - dst_prop_name, - ) - setattr( - self, - dst_prop_name, - invalidate_signal, - ) + # Mark Dependency for Clearance + ## -> Duplicates are OK for now, we'll clear them later. + blfields_to_clear.append((dst_prop_name, clear_method)) + + # Compute Recursive Dependencies for Clearance + ## -> As we go deeper, 'previous fields' is set. + if dst_prop_name in self.blfields: + blfields_to_clear += self.trace_blfields_to_clear( + dst_prop_name, + prev_blfields_to_clear=blfields_to_clear, + ) + + match (bool(prev_blfields_to_clear), bool(blfields_to_clear)): + # Nothing to Clear + ## -> This is a recursive base case for no-dependency BLFields. + case (False, False): + return [] + + # Only Old: Return Old + ## -> This is a recursive base case for the deepest field w/o deps. + ## -> When there are previous BLFields, this cannot be recursive root + ## -> Otherwise, we'd need to de-duplicate. + case (True, False): + return prev_blfields_to_clear ## Is never recursive root + + # Only New: Deduplicate (from right) w/Order Preservation + ## -> This is the recursive root. + ## -> The first time there are new BLFields to clear, we dedupe. + ## -> This is the ONLY case where we need to dedupe. + ## -> Deduping deeper would be extraneous (though not damaging). + case (False, True): + return list(reversed(dict.fromkeys(reversed(blfields_to_clear)))) + + # New And Old: Concatenate + ## -> This is merely a "transport" step, sandwiched btwn base/root. + ## -> As such, deduplication would not be wrong, just extraneous. + ## -> Since invalidation is in a hot-loop, don't do such things. + case (True, True): + return blfields_to_clear + + def clear_blfields_after(self, prop_name: str) -> list[str]: + """Clear (invalidate) all `BLField`s that have become invalid as a result of a change to `prop_name`. + + Uses `self.trace_blfields_to_clear()` to deduce the names and unique ordering of `BLField`s to clear. + Then, update-less `bl_cache.Signal`s are written in order to invalidate each `BLField` cache without invoking `self.on_prop_changed()`. + Finally, the list of cleared `BLField`s is returned. + + Notes: + Generally, this should be called from `on_prop_changed()`. + The resulting cleared fields can then be analyzed / used in a domain specific way as needed by the particular `BLInstance`. + + Returns: + The topologically ordered right-de-duplicated list of BLFields that were cleared. + """ + blfields_to_clear = self.trace_blfields_to_clear(prop_name) + + # Invalidate BLFields + ## -> trace_blfields_to_clear only gave us what/how to invalidate. + ## -> It's the responsibility of on_prop_changed to actually do so. + # log.debug( + # '%s (NodeSocket): Clearing BLFields after "%s": "%s"', + # self.bl_label, + # prop_name, + # blfields_to_clear, + # ) + for blfield, clear_method in blfields_to_clear: + # log.debug( + # '%s (NodeSocket): Clearing BLField: %s (%s)', + # self.bl_label, + # blfield, + # clear_method, + # ) + setattr( + self, + blfield, + { + 'invalidate': bl_cache.Signal.InvalidateCacheNoUpdate, + 'reset_enum': bl_cache.Signal.ResetEnumItems, ## No updates + 'reset_strsearch': bl_cache.Signal.ResetStrSearch, + }[clear_method], + ) + + return [(prop_name, 'invalidate'), *blfields_to_clear] 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. diff --git a/src/blender_maxwell/utils/extra_sympy_units.py b/src/blender_maxwell/utils/extra_sympy_units.py index b35f629..5991eea 100644 --- a/src/blender_maxwell/utils/extra_sympy_units.py +++ b/src/blender_maxwell/utils/extra_sympy_units.py @@ -44,6 +44,7 @@ from pydantic_core import core_schema as pyd_core_schema from blender_maxwell import contracts as ct from . import logger +from .staticproperty import staticproperty log = logger.get(__name__) @@ -69,7 +70,7 @@ class MathType(enum.StrEnum): Complex = enum.auto() @staticmethod - def combine(*mathtypes: list[typ.Self]) -> typ.Self: + def combine(*mathtypes: list[typ.Self], optional: bool = False) -> typ.Self | None: if MathType.Complex in mathtypes: return MathType.Complex if MathType.Real in mathtypes: @@ -79,6 +80,9 @@ class MathType(enum.StrEnum): if MathType.Integer in mathtypes: return MathType.Integer + if optional: + return None + msg = f"Can't combine mathtypes {mathtypes}" raise ValueError(msg) @@ -113,7 +117,7 @@ class MathType(enum.StrEnum): return complex(pyobj, 0) @staticmethod - def from_expr(sp_obj: SympyType) -> type: + def from_expr(sp_obj: SympyType, optional: bool = False) -> type | None: if isinstance(sp_obj, sp.MatrixBase): return MathType.combine( *[MathType.from_expr(v) for v in sp.flatten(sp_obj)] @@ -134,6 +138,9 @@ class MathType(enum.StrEnum): if sp_obj in [sp.zoo, -sp.zoo]: return MathType.Complex + if optional: + return None + msg = f"Can't determine MathType from sympy object: {sp_obj}" raise ValueError(msg) @@ -957,6 +964,48 @@ def unit_str_to_unit(unit_str: str) -> Unit | None: #################### # - "Physical" Type #################### +def unit_dim_to_unit_dim_deps( + unit_dims: SympyType, +) -> dict[spu.dimensions.Dimension, int] | None: + dimsys_SI = spu.systems.si.dimsys_SI + + # Retrieve Dimensional Dependencies + try: + return dimsys_SI.get_dimensional_dependencies(unit_dims) + + # Catch TypeError + ## -> Happens if `+` or `-` is in `unit`. + ## -> Generally, it doesn't make sense to add/subtract differing unit dims. + ## -> Thus, when trying to figure out the unit dimension, there isn't one. + except TypeError: + return None + + +def unit_to_unit_dim_deps( + unit: SympyType, +) -> dict[spu.dimensions.Dimension, int] | None: + # Retrieve Dimensional Dependencies + ## -> NOTE: .subs() alone seems to produce sp.Symbol atoms. + ## -> This is extremely problematic; `Dims` arithmetic has key properties. + ## -> So we have to go all the way to the dimensional dependencies. + ## -> This isn't really respecting the args, but it seems to work :) + return unit_dim_to_unit_dim_deps( + unit.subs({arg: arg.dimension for arg in unit.atoms(spu.Quantity)}) + ) + + +def compare_unit_dims(unit_dim_l: SympyType, unit_dim_r: SympyType) -> bool: + return unit_dim_to_unit_dim_deps(unit_dim_l) == unit_dim_to_unit_dim_deps( + unit_dim_r + ) + + +def compare_unit_dim_to_unit_dim_deps( + unit_dim: SympyType, unit_dim_deps: dict[spu.dimensions.Dimension, int] +) -> bool: + return unit_dim_to_unit_dim_deps(unit_dim) == unit_dim_deps + + class PhysicalType(enum.StrEnum): """Type identifiers for expressions with both `MathType` and a unit, aka a "physical" type.""" @@ -1005,7 +1054,7 @@ class PhysicalType(enum.StrEnum): Illuminance = enum.auto() @functools.cached_property - def unit_dim(self): + def unit_dim(self) -> SympyType: PT = PhysicalType return { PT.NonPhysical: None, @@ -1050,6 +1099,95 @@ class PhysicalType(enum.StrEnum): PT.Illuminance: Dims.luminous_intensity / Dims.length**2, }[self] + @staticproperty + def unit_dims() -> dict[typ.Self, SympyType]: + return { + physical_type: physical_type.unit_dim + for physical_type in list(PhysicalType) + } + + @functools.cached_property + def color(self): + """A color corresponding to the physical type. + + The color selections were initially generated using AI, as this is a rote task that's better adjusted than invented. + The LLM provided the following rationale for its choices: + + > Non-Physical: Grey signifies neutrality and non-physical nature. + > Global: + > Time: Blue is often associated with calmness and the passage of time. + > Angle and Solid Angle: Different shades of blue and cyan suggest angular dimensions and spatial aspects. + > Frequency and Angular Frequency: Darker shades of blue to maintain the link to time. + > Cartesian: + > Length, Area, Volume: Shades of green to represent spatial dimensions, with intensity increasing with dimension. + > Mechanical: + > Velocity and Acceleration: Red signifies motion and dynamics, with lighter reds for related quantities. + > Mass: Dark red for the fundamental property. + > Force and Pressure: Shades of red indicating intensity. + > Energy: + > Work and Power: Orange signifies energy transformation, with lighter oranges for related quantities. + > Temperature: Yellow for heat. + > Electrodynamics: + > Current and related quantities: Cyan shades indicating flow. + > Voltage, Capacitance: Greenish and blueish cyan for electrical potential. + > Impedance, Conductance, Conductivity: Purples and magentas to signify resistance and conductance. + > Magnetic properties: Magenta shades for magnetism. + > Electric Field: Light blue. + > Magnetic Field: Grey, as it can be considered neutral in terms of direction. + > Luminal: + > Luminous properties: Yellows to signify light and illumination. + > + > This color mapping helps maintain intuitive connections for users interacting with these physical types. + """ + PT = PhysicalType + return { + PT.NonPhysical: (0.75, 0.75, 0.75, 1.0), # Light Grey: Non-physical + # Global + PT.Time: (0.5, 0.5, 1.0, 1.0), # Light Blue: Time + PT.Angle: (0.5, 0.75, 1.0, 1.0), # Light Blue: Angle + PT.SolidAngle: (0.5, 0.75, 0.75, 1.0), # Light Cyan: Solid Angle + PT.Freq: (0.5, 0.5, 0.9, 1.0), # Light Blue: Frequency + PT.AngFreq: (0.5, 0.5, 0.8, 1.0), # Light Blue: Angular Frequency + # Cartesian + PT.Length: (0.5, 1.0, 0.5, 1.0), # Light Green: Length + PT.Area: (0.6, 1.0, 0.6, 1.0), # Light Green: Area + PT.Volume: (0.7, 1.0, 0.7, 1.0), # Light Green: Volume + # Mechanical + PT.Vel: (1.0, 0.5, 0.5, 1.0), # Light Red: Velocity + PT.Accel: (1.0, 0.6, 0.6, 1.0), # Light Red: Acceleration + PT.Mass: (0.75, 0.5, 0.5, 1.0), # Light Red: Mass + PT.Force: (0.9, 0.5, 0.5, 1.0), # Light Red: Force + PT.Pressure: (1.0, 0.7, 0.7, 1.0), # Light Red: Pressure + # Energy + PT.Work: (1.0, 0.75, 0.5, 1.0), # Light Orange: Work + PT.Power: (1.0, 0.85, 0.5, 1.0), # Light Orange: Power + PT.PowerFlux: (1.0, 0.8, 0.6, 1.0), # Light Orange: Power Flux + PT.Temp: (1.0, 1.0, 0.5, 1.0), # Light Yellow: Temperature + # Electrodynamics + PT.Current: (0.5, 1.0, 1.0, 1.0), # Light Cyan: Current + PT.CurrentDensity: (0.5, 0.9, 0.9, 1.0), # Light Cyan: Current Density + PT.Charge: (0.5, 0.85, 0.85, 1.0), # Light Cyan: Charge + PT.Voltage: (0.5, 1.0, 0.75, 1.0), # Light Greenish Cyan: Voltage + PT.Capacitance: (0.5, 0.75, 1.0, 1.0), # Light Blueish Cyan: Capacitance + PT.Impedance: (0.6, 0.5, 0.75, 1.0), # Light Purple: Impedance + PT.Conductance: (0.7, 0.5, 0.8, 1.0), # Light Purple: Conductance + PT.Conductivity: (0.8, 0.5, 0.9, 1.0), # Light Purple: Conductivity + PT.MFlux: (0.75, 0.5, 0.75, 1.0), # Light Magenta: Magnetic Flux + PT.MFluxDensity: ( + 0.85, + 0.5, + 0.85, + 1.0, + ), # Light Magenta: Magnetic Flux Density + PT.Inductance: (0.8, 0.5, 0.8, 1.0), # Light Magenta: Inductance + PT.EField: (0.75, 0.75, 1.0, 1.0), # Light Blue: Electric Field + PT.HField: (0.75, 0.75, 0.75, 1.0), # Light Grey: Magnetic Field + # Luminal + PT.LumIntensity: (1.0, 0.95, 0.5, 1.0), # Light Yellow: Luminous Intensity + PT.LumFlux: (1.0, 0.95, 0.6, 1.0), # Light Yellow: Luminous Flux + PT.Illuminance: (1.0, 1.0, 0.75, 1.0), # Pale Yellow: Illuminance + }[self] + @functools.cached_property def default_unit(self) -> list[Unit]: PT = PhysicalType @@ -1256,17 +1394,59 @@ class PhysicalType(enum.StrEnum): }[self] @staticmethod - def from_unit(unit: Unit, optional: bool = False) -> list[Unit] | None: - for physical_type in list(PhysicalType): - if unit in physical_type.valid_units: - return physical_type - ## TODO: Optimize + def from_unit(unit: Unit | None, optional: bool = False) -> typ.Self | None: + """Attempt to determine a matching `PhysicalType` from a unit. + + NOTE: It is not guaranteed that `unit` is within `valid_units`, only that it can be converted to any unit in `valid_units`. + + Returns: + The matched `PhysicalType`. + + If none could be matched, then either return `None` (if `optional` is set) or error. + + Raises: + ValueError: If no `PhysicalType` could be matched, and `optional` is `False`. + """ + if unit is None: + return ct.PhysicalType.NonPhysical + + unit_dim_deps = unit_to_unit_dim_deps(unit) + if unit_dim_deps is not None: + for physical_type, candidate_unit_dim in PhysicalType.unit_dims.items(): + if compare_unit_dim_to_unit_dim_deps(candidate_unit_dim, unit_dim_deps): + return physical_type if optional: return None msg = f'Could not determine PhysicalType for {unit}' raise ValueError(msg) + @staticmethod + def from_unit_dim( + unit_dim: SympyType | None, optional: bool = False + ) -> typ.Self | None: + """Attempts to match an arbitrary unit dimension expression to a corresponding `PhysicalType`. + + For comparing arbitrary unit dimensions (via expressions of `spu.dimensions.Dimension`), it is critical that equivalent dimensions are also compared as equal (ex. `mass*length/time^2 == force`). + To do so, we employ the `SI` unit conventions, for extracting the fundamental dimensional dependencies of unit dimension expressions. + + Returns: + The matched `PhysicalType`. + + If none could be matched, then either return `None` (if `optional` is set) or error. + + Raises: + ValueError: If no `PhysicalType` could be matched, and `optional` is `False`. + """ + for physical_type, candidate_unit_dim in PhysicalType.unit_dims.items(): + if compare_unit_dims(unit_dim, candidate_unit_dim): + return physical_type + + if optional: + return None + msg = f'Could not determine PhysicalType for {unit_dim}' + raise ValueError(msg) + @functools.cached_property def valid_shapes(self) -> list[typ.Literal[(3,), (2,)] | None]: PT = PhysicalType diff --git a/src/blender_maxwell/utils/image_ops.py b/src/blender_maxwell/utils/image_ops.py index 93baf22..205cb71 100644 --- a/src/blender_maxwell/utils/image_ops.py +++ b/src/blender_maxwell/utils/image_ops.py @@ -17,7 +17,6 @@ """Useful image processing operations for use in the addon.""" import enum -import functools import typing as typ import jax @@ -27,13 +26,13 @@ import matplotlib import matplotlib.axis as mpl_ax import matplotlib.backends.backend_agg import matplotlib.figure -import matplotlib.style as mplstyle +import numpy as np import seaborn as sns from blender_maxwell import contracts as ct +from blender_maxwell.utils import extra_sympy_units as spux from blender_maxwell.utils import logger -# mplstyle.use('fast') ## TODO: Does this do anything? sns.set_theme() log = logger.get(__name__) @@ -139,7 +138,7 @@ def rgba_image_from_2d_map( #################### # - MPL Helpers #################### -@functools.lru_cache(maxsize=16) +# @functools.lru_cache(maxsize=16) def mpl_fig_canvas_ax(width_inches: float, height_inches: float, dpi: int): fig = matplotlib.figure.Figure( figsize=[width_inches, height_inches], dpi=dpi, layout='tight' @@ -160,9 +159,9 @@ def plot_box_plot_1d(data, ax: mpl_ax.Axis) -> None: x_sym, y_sym = list(data.keys()) ax.boxplot([data[y_sym]]) - ax.set_title(f'{x_sym.name_pretty} -> {y_sym.name_pretty}') + ax.set_title(f'{x_sym.name_pretty} ā†’ {y_sym.name_pretty}') ax.set_xlabel(x_sym.plot_label) - ax.set_xlabel(y_sym.plot_label) + ax.set_ylabel(y_sym.plot_label) def plot_bar(data, ax: mpl_ax.Axis) -> None: @@ -173,26 +172,31 @@ def plot_bar(data, ax: mpl_ax.Axis) -> None: ax.set_title(f'{x_sym.name_pretty} -> {heights_sym.name_pretty}') ax.set_xlabel(x_sym.plot_label) - ax.set_xlabel(heights_sym.plot_label) + ax.set_ylabel(heights_sym.plot_label) -# (ā„) -> ā„ +# (ā„) -> ā„ (| sometimes complex) def plot_curve_2d(data, ax: mpl_ax.Axis) -> None: x_sym, y_sym = list(data.keys()) + if y_sym.mathtype is spux.MathType.Complex: + ax.plot(data[x_sym], data[y_sym].real, label='ā„') + ax.plot(data[x_sym], data[y_sym].imag, label='š•€') + ax.legend() + ax.plot(data[x_sym], data[y_sym]) - ax.set_title(f'{x_sym.name_pretty} -> {y_sym.name_pretty}') + ax.set_title(f'{x_sym.name_pretty} ā†’ {y_sym.name_pretty}') ax.set_xlabel(x_sym.plot_label) - ax.set_xlabel(y_sym.plot_label) + ax.set_ylabel(y_sym.plot_label) def plot_points_2d(data, ax: mpl_ax.Axis) -> None: x_sym, y_sym = list(data.keys()) ax.scatter(data[x_sym], data[y_sym]) - ax.set_title(f'{x_sym.name_pretty} -> {y_sym.name_pretty}') + ax.set_title(f'{x_sym.name_pretty} ā†’ {y_sym.name_pretty}') ax.set_xlabel(x_sym.plot_label) - ax.set_xlabel(y_sym.plot_label) + ax.set_ylabel(y_sym.plot_label) # (ā„, ā„¤) -> ā„ @@ -202,34 +206,30 @@ def plot_curves_2d(data, ax: mpl_ax.Axis) -> None: for i, label in enumerate(data[label_sym]): ax.plot(data[x_sym], data[y_sym][:, i], label=label) - ax.set_title(f'{x_sym.name_pretty} -> {y_sym.name_pretty}') + ax.set_title(f'{x_sym.name_pretty} ā†’ {y_sym.name_pretty}') ax.set_xlabel(x_sym.plot_label) - ax.set_xlabel(y_sym.plot_label) + ax.set_ylabel(y_sym.plot_label) ax.legend() -def plot_filled_curves_2d( - data: jtyp.Float32[jtyp.Array, 'x_size 2'], info, ax: mpl_ax.Axis -) -> None: - x_sym, _, y_sym = list(data.keys()) +def plot_filled_curves_2d(data, ax: mpl_ax.Axis) -> None: + x_sym, _, y_sym = list(data.keys(data)) ax.fill_between(data[x_sym], data[y_sym][:, 0], data[x_sym], data[y_sym][:, 1]) - ax.set_title(f'{x_sym.name_pretty} -> {y_sym.name_pretty}') + ax.set_title(f'{x_sym.name_pretty} ā†’ {y_sym.name_pretty}') ax.set_xlabel(x_sym.plot_label) - ax.set_xlabel(y_sym.plot_label) + ax.set_ylabel(y_sym.plot_label) ax.legend() # (ā„, ā„) -> ā„ -def plot_heatmap_2d( - data: jtyp.Float32[jtyp.Array, 'x_size y_size'], info, ax: mpl_ax.Axis -) -> None: +def plot_heatmap_2d(data, ax: mpl_ax.Axis) -> None: x_sym, y_sym, c_sym = list(data.keys()) heatmap = ax.imshow(data[c_sym], aspect='equal', interpolation='none') ax.figure.colorbar(heatmap, cax=ax) - ax.set_title(f'({x_sym.name_pretty}, {y_sym.name_pretty}) -> {c_sym.plot_label}') + ax.set_title(f'({x_sym.name_pretty}, {y_sym.name_pretty}) ā†’ {c_sym.plot_label}') ax.set_xlabel(x_sym.plot_label) ax.set_xlabel(y_sym.plot_label) ax.legend() diff --git a/src/blender_maxwell/utils/sim_symbols.py b/src/blender_maxwell/utils/sim_symbols.py index c8370f4..3be4750 100644 --- a/src/blender_maxwell/utils/sim_symbols.py +++ b/src/blender_maxwell/utils/sim_symbols.py @@ -24,7 +24,6 @@ from fractions import Fraction import jaxtyping as jtyp import pydantic as pyd import sympy as sp -import sympy.physics.units as spu from . import extra_sympy_units as spux from . import logger, serialize @@ -88,6 +87,7 @@ class SimSymbolName(enum.StrEnum): Wavelength = enum.auto() Frequency = enum.auto() + Perm = enum.auto() PermXX = enum.auto() PermYY = enum.auto() PermZZ = enum.auto() @@ -161,6 +161,7 @@ class SimSymbolName(enum.StrEnum): # Optics SSN.Wavelength: 'wl', SSN.Frequency: 'freq', + SSN.Perm: 'eps_r', SSN.PermXX: 'eps_xx', SSN.PermYY: 'eps_yy', SSN.PermZZ: 'eps_zz', @@ -179,6 +180,7 @@ class SimSymbolName(enum.StrEnum): SSN.LowerTheta: 'Īø', SSN.LowerPhi: 'Ļ†', # Fields + SSN.Er: 'Er', SSN.Etheta: 'EĪø', SSN.Ephi: 'EĻ†', SSN.Hr: 'Hr', @@ -186,10 +188,11 @@ class SimSymbolName(enum.StrEnum): SSN.Hphi: 'HĻ†', # Optics SSN.Wavelength: 'Ī»', - SSN.Frequency: 'š‘“', - SSN.PermXX: 'Īµ_xx', - SSN.PermYY: 'Īµ_yy', - SSN.PermZZ: 'Īµ_zz', + SSN.Frequency: 'fįµ£', + SSN.Perm: 'Īµįµ£', + SSN.PermXX: 'Īµįµ£[xx]', + SSN.PermYY: 'Īµįµ£[yy]', + SSN.PermZZ: 'Īµįµ£[zz]', }.get(self, self.name) @@ -248,6 +251,8 @@ class SimSymbol(pyd.BaseModel): ## -> See self.domain. ## -> We have to deconstruct symbolic interval semantics a bit for UI. is_constant: bool = False + exclude_zero: bool = False + interval_finite_z: tuple[int, int] = (0, 1) interval_finite_q: tuple[tuple[int, int], tuple[int, int]] = ((0, 1), (1, 1)) interval_finite_re: tuple[float, float] = (0.0, 1.0) @@ -284,20 +289,25 @@ class SimSymbol(pyd.BaseModel): @functools.cached_property def unit_label(self) -> str: """Pretty unit label, which is an empty string when there is no unit.""" - return spux.sp_to_str(self.unit) if self.unit is not None else '' + return spux.sp_to_str(self.unit.n(4)) if self.unit is not None else '' + + @functools.cached_property + def name_unit_label(self) -> str: + """Pretty name | unit label, which is just the name when there is no unit.""" + if self.unit is None: + return self.name_pretty + return f'{self.name_pretty} | {self.unit_label}' @functools.cached_property def def_label(self) -> str: """Pretty definition label, exposing the symbol definition.""" - return f'{self.name_pretty} | {self.unit_label} āˆˆ {self.mathtype_size_label}' + return f'{self.name_unit_label} āˆˆ {self.mathtype_size_label}' ## TODO: Domain of validity from self.domain? @functools.cached_property def plot_label(self) -> str: """Pretty plot-oriented label.""" - return f'{self.name_pretty}' + ( - f'({self.unit})' if self.unit is not None else '' - ) + return f'{self.name_pretty} ({self.unit_label})' #################### # - Computed Properties @@ -307,6 +317,11 @@ class SimSymbol(pyd.BaseModel): """Factor corresponding to the tracked unit, which can be multiplied onto exported values without `None`-checking.""" return self.unit if self.unit is not None else sp.S(1) + @functools.cached_property + def unit_dim(self) -> spux.SympyExpr: + """Unit dimension factor corresponding to the tracked unit, which can be multiplied onto exported values without `None`-checking.""" + return self.unit if self.unit is not None else sp.S(1) + @functools.cached_property def size(self) -> tuple[int, ...] | None: return { @@ -403,6 +418,29 @@ class SimSymbol(pyd.BaseModel): case (False, False): return sp.S(self.mathtype.coerce_compatible_pyobj(-1)) + @functools.cached_property + def is_nonzero(self) -> bool: + if self.exclude_zero: + return True + + def check_real_domain(real_domain): + return ( + ( + real_domain.left == 0 + and real_domain.left_open + or real_domain.right == 0 + and real_domain.right_open + ) + or real_domain.left > 0 + or real_domain.right < 0 + ) + + if self.mathtype is spux.MathType.Complex: + return check_real_domain(self.domain[0]) and check_real_domain( + self.domain[1] + ) + return check_real_domain(self.domain) + #################### # - Properties #################### @@ -434,23 +472,15 @@ class SimSymbol(pyd.BaseModel): mathtype_kwargs |= {'complex': True} # Non-Zero Assumption - if ( - ( - self.domain.left == 0 - and self.domain.left_open - or self.domain.right == 0 - and self.domain.right_open - ) - or self.domain.left > 0 - or self.domain.right < 0 - ): + if self.is_nonzero: mathtype_kwargs |= {'nonzero': True} # Positive/Negative Assumption - if self.domain.left >= 0: - mathtype_kwargs |= {'positive': True} - elif self.domain.right <= 0: - mathtype_kwargs |= {'negative': True} + if self.mathtype is not spux.MathType.Complex: + if self.domain.left >= 0: + mathtype_kwargs |= {'positive': True} + elif self.domain.right <= 0: + mathtype_kwargs |= {'negative': True} # Scalar: Return Symbol if self.rows == 1 and self.cols == 1: @@ -521,8 +551,8 @@ class SimSymbol(pyd.BaseModel): self.valid_domain_value, strip_unit=True ), # Defaults: FlowKind.Range - 'default_min': self.domain.start, - 'default_max': self.domain.end, + 'default_min': self.conform(self.domain.start, strip_unit=True), + 'default_max': self.conform(self.domain.end, strip_unit=True), } msg = f'Tried to generate an ExprSocket from a SymSymbol "{self.name}", but its unit ({self.unit}) is not a valid unit of its physical type ({self.physical_type}) (SimSymbol={self})' raise NotImplementedError(msg) @@ -671,7 +701,9 @@ class SimSymbol(pyd.BaseModel): sym_name: SimSymbolName, expr: spux.SympyExpr, unit_expr: spux.SympyExpr, - ) -> typ.Self: + is_constant: bool = False, + optional: bool = False, + ) -> typ.Self | None: """Deduce a `SimSymbol` that matches the output of a given expression (and unit expression). This is an essential method, allowing for the ded @@ -697,12 +729,28 @@ class SimSymbol(pyd.BaseModel): # MathType from Expr Assumptions ## -> All input symbols have assumptions, because we are very pedantic. ## -> Therefore, we should be able to reconstruct the MathType. - mathtype = spux.MathType.from_expr(expr) + mathtype = spux.MathType.from_expr(expr, optional=optional) + if mathtype is None: + return None # PhysicalType as "NonPhysical" ## -> 'unit' still applies - but we can't guarantee a PhysicalType will. ## -> Therefore, this is what we gotta do. - physical_type = spux.PhysicalType.NonPhysical + if spux.uses_units(unit_expr): + simplified_unit_expr = sp.simplify(unit_expr) + expr_physical_type = spux.PhysicalType.from_unit( + simplified_unit_expr, optional=True + ) + + physical_type = ( + spux.PhysicalType.NonPhysical + if expr_physical_type is None + else expr_physical_type + ) + unit = simplified_unit_expr + else: + physical_type = spux.PhysicalType.NonPhysical + unit = None # Rows/Cols from Expr (if Matrix) rows, cols = expr.shape if isinstance(expr, sp.MatrixBase) else (1, 1) @@ -711,9 +759,11 @@ class SimSymbol(pyd.BaseModel): sym_name=sym_name, mathtype=mathtype, physical_type=physical_type, - unit=unit_expr if unit_expr != 1 else None, + unit=unit, rows=rows, cols=cols, + is_constant=is_constant, + exclude_zero=expr.is_zero is not None and not expr.is_zero, ) ####################