fix: Revalidated cache logic w/KeyedCache.
This especially involved fixing the invalidation logic in `trigger_action`. It should now be far more accurate, concise, and performant. The invalidation check ought still be optimized. The reason this isn't trivial is because of the loose sockets: To use our new `@keyed_cache` on a function like `_should_recompute_output_socket`, the loose socket would also need to do an appropriate invalidation. Such caching without accounting for invalidation on loose-socket change would be incorrect. For now, it seems as though performance is quite good, although it is unknown whether this will scale to large graphs. We've also left `kind`-specific invalidation alone for now (maybe forever).main
parent
7f2bd2e752
commit
e1f11f6d68
72
TODO.md
72
TODO.md
|
@ -2,6 +2,7 @@
|
||||||
- [x] Implement Material Import for Maxim Data
|
- [x] Implement Material Import for Maxim Data
|
||||||
- [x] Implement Robust DataFlowKind for list-like / spectral-like composite types
|
- [x] Implement Robust DataFlowKind for list-like / spectral-like composite types
|
||||||
- [x] Unify random node/socket caches.
|
- [x] Unify random node/socket caches.
|
||||||
|
- [x] Revalidate cache logic
|
||||||
- [ ] Finish the "Low-Hanging Fruit" Nodes
|
- [ ] Finish the "Low-Hanging Fruit" Nodes
|
||||||
- [ ] Move preview GN trees to the asset library.
|
- [ ] Move preview GN trees to the asset library.
|
||||||
|
|
||||||
|
@ -349,11 +350,11 @@
|
||||||
- [ ] Prevents some uses of loose sockets (we want less loose sockets!)
|
- [ ] Prevents some uses of loose sockets (we want less loose sockets!)
|
||||||
|
|
||||||
## CRITICAL
|
## CRITICAL
|
||||||
|
- [ ] `log.error` should invoke `self.report` in some Blender operator - used for errors that are due to usage error (which can't simply be prevented with UX design, like text file formatting of import), not due to error in the program.
|
||||||
- [ ] License header UI for MaxwellSimTrees, to clarify the AGPL-compatible potentially user-selected license that trees must be distributed under.
|
- [ ] License header UI for MaxwellSimTrees, to clarify the AGPL-compatible potentially user-selected license that trees must be distributed under.
|
||||||
- [ ] Document the node tree cache semantics thoroughly; it's a VERY nuanced piece of logic, and its invariants may not survive Blender versions / the author's working memory
|
- [x] Document the node tree cache semantics thoroughly; it's a VERY nuanced piece of logic, and its invariants may not survive Blender versions / the author's working memory
|
||||||
- [ ] Start standardizing nodes/sockets w/individualized SemVer
|
- [ ] Start standardizing nodes/sockets w/individualized SemVer
|
||||||
- Perhaps keep node / socket versions in a property, so that trying to load an incompatible major version hop can error w/indicator of where to find a compatible `blender_maxwell` version.
|
- Perhaps keep node / socket versions in a property, so that trying to load an incompatible major version hop can error w/indicator of where to find a compatible `blender_maxwell` version.
|
||||||
- [ ] `log.error` should invoke `self.report` in some Blender operator - used for errors that are due to usage error (which can't simply be prevented with UX design, like text file formatting of import), not due to error in the program.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
- [ ] Make all modules available
|
- [ ] Make all modules available
|
||||||
|
@ -491,3 +492,70 @@ Unreported:
|
||||||
## Tidy3D bugs
|
## Tidy3D bugs
|
||||||
Unreported:
|
Unreported:
|
||||||
- Directly running `SimulationTask.get()` is missing fields - it doesn't return some fields, including `created_at`. Listing tasks by folder is not broken.
|
- Directly running `SimulationTask.get()` is missing fields - it doesn't return some fields, including `created_at`. Listing tasks by folder is not broken.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Designs / Proposals
|
||||||
|
|
||||||
|
## Coolness Things
|
||||||
|
- Let's have operator `poll_message_set`: https://projects.blender.org/blender/blender/commit/ebe04bd3cafaa1f88bd51eee5b3e7bef38ae69bc
|
||||||
|
- Careful, Python uses user site packages: <https://projects.blender.org/blender/blender/commit/72c012ab4a3d2a7f7f59334f4912402338c82e3c>
|
||||||
|
- Our modifier obj can see execution time: <https://projects.blender.org/blender/blender/commit/8adebaeb7c3c663ec775fda239fdfe5ddb654b06>
|
||||||
|
- We found the translation callback! https://projects.blender.org/blender/blender/commit/8564e03cdf59fb2a71d545e81871411b82f561d9
|
||||||
|
- This can update the node center!!
|
||||||
|
|
||||||
|
- [ ] Optimize the `DataChanged` invalidator.
|
||||||
|
- [ ] Optimize unit stripping.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Keyed Cache
|
||||||
|
- [ ] Implement `bl_cache.KeyedCache` for, especially, abstracting the caches underlying the input and output sockets.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## BLField as Property Abstraction
|
||||||
|
We need Python properties to work together with Blender properties.
|
||||||
|
- Blender Pros: Altered via UI. Options control UI usage. `update()` is a perfect inflection point for callback logic.
|
||||||
|
- Blender Cons: Extremely limited supported types. A lot of manual labor that duplicates work done elsewhere in a Python program
|
||||||
|
|
||||||
|
`BLField` seeks to bridge the two worlds in an elegant way.
|
||||||
|
|
||||||
|
### Type Support
|
||||||
|
We need support for arbitrary objects, but still backed by the persistance semantics of native Blender properties.
|
||||||
|
- [ ] Add logic that matches appropriate types to native IntProperty, FloatProperty, IntVectorProperty, FloatVectorProperty.
|
||||||
|
- We want absolute minimal overhead for types that actually already do work in Blender.
|
||||||
|
- **REMEMBER8* they can do matrices too! https://developer.blender.org/docs/release_notes/3.0/python_api/#other-additions
|
||||||
|
- [ ] Add logic that matches any bpy.types.ID subclass to a PointerProperty.
|
||||||
|
- This is important for certain kinds of properties ex. "select a Blender object".
|
||||||
|
- [ ] Implement Enum property, (also see <https://developer.blender.org/docs/release_notes/4.1/python_api/#enum-id-properties>)
|
||||||
|
- Use this to bridge the enum UI to actual StrEnum objects.
|
||||||
|
- This also maybe enables some very interesting use cases when it comes to ex. static verifiability of data provided to event callbacks.
|
||||||
|
- [ ] Ensure certain options, namely `name` (as `ui_name`), `default`, `subtype`, (numeric) `min`, `max`, `step`, `precision`, (string) `maxlen`, `search`, and `search_options`, can be passed down via the `BLField()` constructor.
|
||||||
|
- [ ] Make a class method that parses the docstring.
|
||||||
|
- [ ] `description`: Use the docstring parser to extract the first description sentence of the attribute name from the subclass docstring, so we are both encouraged to document our nodes/sockets, and so we're not documenting twice.
|
||||||
|
|
||||||
|
### Niceness
|
||||||
|
- [ ] Rename the internal property to 'blfield__'.
|
||||||
|
- [ ] Add a method that extracts the internal property name, for places where we need the Blender property name.
|
||||||
|
- **Key use case**: `draw.prop(self, self.field_name._bl_prop_name)`, which is also nice b/c no implicit string-based reference.
|
||||||
|
- The work done above with types makes this as fast and useful as internal props. Just make sure we validate that the type can be usefully accessed like this.
|
||||||
|
- [ ] Add a field method (called w/instance) that updates min/max/etc. on the 'blfield__' prop, in a native property type compatible manner: https://developer.blender.org/docs/release_notes/3.0/python_api/#idproperty-ui-data-api
|
||||||
|
- Should also throw appropriate errors for invalid access from Python, while Blender handles access from the inside.
|
||||||
|
- This allows us
|
||||||
|
- [ ] Similarly, a field method that gets the 'blfield__' prop data as a dictionary.
|
||||||
|
|
||||||
|
### Parallel Features
|
||||||
|
- [ ] Move serialization work to a `utils`.
|
||||||
|
- [ ] Also make ENCODER a function that can shortcut the easy cases.
|
||||||
|
- [ ] For serializeability, let the encoder/decoder be able to make use of an optional `.msgspec_encodable()` and similar decoder respectively, and add support for these in the ENCODER/DECODER functions.
|
||||||
|
- [ ] Define a superclass for `SocketDef` and make everyone inherit from it
|
||||||
|
- [ ] Collect with a `BL_SOCKET_DEFS` object, instead of manually from `__init__.py`s
|
||||||
|
- [ ] Add support for `.msgspec_*()` methods, so that we remove the dependency on sockets from the serialization module.
|
||||||
|
|
||||||
|
### Sweeping Features
|
||||||
|
- [ ] Replace all raw Blender properties with `BLField`.
|
||||||
|
- Benefit: update= is taken care of automatically, preventing an entire class of nasty bug.
|
||||||
|
- Benefit: Any serializable object can be "simply used", at almost native speed (due to the aggressive read-cache).
|
||||||
|
- Benefit: Better error properties for updating, access, setting, etc. .
|
||||||
|
- Benefit: Validate usage in a vastly greater amount of contexts.
|
||||||
|
|
|
@ -50,6 +50,9 @@ quartodoc:
|
||||||
signature_name: "short"
|
signature_name: "short"
|
||||||
|
|
||||||
sections:
|
sections:
|
||||||
|
####################
|
||||||
|
# - scripts
|
||||||
|
####################
|
||||||
- title: "`scripts`"
|
- title: "`scripts`"
|
||||||
desc: Build/packaging scripts for developing and publishing the addon.
|
desc: Build/packaging scripts for developing and publishing the addon.
|
||||||
package: scripts
|
package: scripts
|
||||||
|
@ -65,6 +68,9 @@ quartodoc:
|
||||||
- name: bl_install_addon
|
- name: bl_install_addon
|
||||||
children: embedded
|
children: embedded
|
||||||
|
|
||||||
|
####################
|
||||||
|
# - bl_maxwell
|
||||||
|
####################
|
||||||
- title: "`bl_maxwell`"
|
- title: "`bl_maxwell`"
|
||||||
desc: Root package for the addon.
|
desc: Root package for the addon.
|
||||||
contents:
|
contents:
|
||||||
|
@ -117,7 +123,9 @@ quartodoc:
|
||||||
- name: operators.connect_viewer
|
- name: operators.connect_viewer
|
||||||
children: embedded
|
children: embedded
|
||||||
|
|
||||||
# Node Tree
|
####################
|
||||||
|
# - ..maxwell_sim_nodes
|
||||||
|
####################
|
||||||
- title: "`..maxwell_sim_nodes`"
|
- title: "`..maxwell_sim_nodes`"
|
||||||
desc: Maxwell Simulation Design/Viz Node Tree.
|
desc: Maxwell Simulation Design/Viz Node Tree.
|
||||||
package: blender_maxwell.node_trees.maxwell_sim_nodes
|
package: blender_maxwell.node_trees.maxwell_sim_nodes
|
||||||
|
@ -126,6 +134,8 @@ quartodoc:
|
||||||
children: embedded
|
children: embedded
|
||||||
- name: categories
|
- name: categories
|
||||||
children: embedded
|
children: embedded
|
||||||
|
- name: bl_cache
|
||||||
|
children: embedded
|
||||||
- name: node_tree
|
- name: node_tree
|
||||||
children: embedded
|
children: embedded
|
||||||
|
|
||||||
|
@ -187,3 +197,27 @@ quartodoc:
|
||||||
children: embedded
|
children: embedded
|
||||||
- name: managed_bl_modifier
|
- name: managed_bl_modifier
|
||||||
children: embedded
|
children: embedded
|
||||||
|
|
||||||
|
####################
|
||||||
|
# - ..maxwell_sim_nodes.nodes
|
||||||
|
####################
|
||||||
|
- title: "`...sockets`"
|
||||||
|
desc: Maxwell Simulation Node Sockets
|
||||||
|
package: blender_maxwell.node_trees.maxwell_sim_nodes.sockets
|
||||||
|
contents:
|
||||||
|
- name: base
|
||||||
|
children: embedded
|
||||||
|
- name: scan_socket_defs
|
||||||
|
children: embedded
|
||||||
|
|
||||||
|
####################
|
||||||
|
# - ..maxwell_sim_nodes.nodes
|
||||||
|
####################
|
||||||
|
- title: "`...nodes`"
|
||||||
|
desc: Maxwell Simulation Nodes
|
||||||
|
package: blender_maxwell.node_trees.maxwell_sim_nodes.nodes
|
||||||
|
contents:
|
||||||
|
- name: base
|
||||||
|
children: embedded
|
||||||
|
- name: events
|
||||||
|
children: embedded
|
||||||
|
|
|
@ -28,6 +28,16 @@ class BLInstance(typ.Protocol):
|
||||||
|
|
||||||
instance_id: InstanceID
|
instance_id: InstanceID
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_prop(
|
||||||
|
cls,
|
||||||
|
prop_name: str,
|
||||||
|
prop: bpy.types.Property,
|
||||||
|
no_update: bool = False,
|
||||||
|
update_with_name: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
EncodableValue: typ.TypeAlias = typ.Any ## msgspec-compatible
|
EncodableValue: typ.TypeAlias = typ.Any ## msgspec-compatible
|
||||||
PropGetMethod: typ.TypeAlias = typ.Callable[[BLInstance], EncodableValue]
|
PropGetMethod: typ.TypeAlias = typ.Callable[[BLInstance], EncodableValue]
|
||||||
|
@ -183,7 +193,7 @@ CACHE_NOPERSIST: dict[InstanceID, dict[typ.Any, typ.Any]] = {}
|
||||||
def invalidate_nonpersist_instance_id(instance_id: InstanceID) -> None:
|
def invalidate_nonpersist_instance_id(instance_id: InstanceID) -> None:
|
||||||
"""Invalidate any `instance_id` that might be utilizing cache space in `CACHE_NOPERSIST`.
|
"""Invalidate any `instance_id` that might be utilizing cache space in `CACHE_NOPERSIST`.
|
||||||
|
|
||||||
Note:
|
Notes:
|
||||||
This should be run by the `instance_id` owner in its `free()` method.
|
This should be run by the `instance_id` owner in its `free()` method.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
@ -192,13 +202,135 @@ def invalidate_nonpersist_instance_id(instance_id: InstanceID) -> None:
|
||||||
CACHE_NOPERSIST.pop(instance_id, None)
|
CACHE_NOPERSIST.pop(instance_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# - Property Descriptor
|
||||||
|
####################
|
||||||
|
class KeyedCache:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
func: typ.Callable,
|
||||||
|
exclude: set[str],
|
||||||
|
serialize: set[str],
|
||||||
|
):
|
||||||
|
# Function Information
|
||||||
|
self.func: typ.Callable = func
|
||||||
|
self.func_sig: inspect.Signature = inspect.signature(self.func)
|
||||||
|
|
||||||
|
# Arg -> Key Information
|
||||||
|
self.exclude: set[str] = exclude
|
||||||
|
self.include: set[str] = set(self.func_sig.parameters.keys()) - exclude
|
||||||
|
self.serialize: set[str] = serialize
|
||||||
|
|
||||||
|
# Cache Information
|
||||||
|
self.key_schema: tuple[str, ...] = tuple(
|
||||||
|
[
|
||||||
|
arg_name
|
||||||
|
for arg_name in self.func_sig.parameters
|
||||||
|
if arg_name not in exclude
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.caches: dict[str | None, dict[tuple[typ.Any, ...], typ.Any]] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_method(self):
|
||||||
|
return 'self' in self.exclude
|
||||||
|
|
||||||
|
def cache(self, instance_id: str | None) -> dict[tuple[typ.Any, ...], typ.Any]:
|
||||||
|
if self.caches.get(instance_id) is None:
|
||||||
|
self.caches[instance_id] = {}
|
||||||
|
|
||||||
|
return self.caches[instance_id]
|
||||||
|
|
||||||
|
def _encode_key(self, arguments: dict[str, typ.Any]):
|
||||||
|
## WARNING: Order of arguments matters. Arguments may contain 'exclude'd elements.
|
||||||
|
return tuple(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
arg_value
|
||||||
|
if arg_name not in self.serialize
|
||||||
|
else ENCODER.encode(arg_value)
|
||||||
|
)
|
||||||
|
for arg_name, arg_value in arguments.items()
|
||||||
|
if arg_name in self.include
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def __get__(
|
||||||
|
self, bl_instance: BLInstance | None, owner: type[BLInstance]
|
||||||
|
) -> typ.Callable:
|
||||||
|
_func = functools.partial(self, bl_instance)
|
||||||
|
_func.invalidate = functools.partial(
|
||||||
|
self.__class__.invalidate, self, bl_instance
|
||||||
|
)
|
||||||
|
return _func
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
# Test Argument Bindability to Decorated Function
|
||||||
|
try:
|
||||||
|
bound_args = self.func_sig.bind(*args, **kwargs)
|
||||||
|
except TypeError as ex:
|
||||||
|
msg = f'Can\'t bind arguments (args={args}, kwargs={kwargs}) to @keyed_cache-decorated function "{self.func.__name__}" (signature: {self.func_sig})"'
|
||||||
|
raise ValueError(msg) from ex
|
||||||
|
|
||||||
|
# Check that Parameters for Keying the Cache are Available
|
||||||
|
bound_args.apply_defaults()
|
||||||
|
all_arg_keys = set(bound_args.arguments.keys())
|
||||||
|
if not self.include <= (all_arg_keys - self.exclude):
|
||||||
|
msg = f'Arguments spanning the keyed cached ({self.include}) are not available in the non-excluded arguments passed to "{self.func.__name__}": {all_arg_keys - self.exclude}'
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
# Create Keyed Cache Entry
|
||||||
|
key = self._encode_key(bound_args.arguments)
|
||||||
|
cache = self.cache(args[0].instance_id if self.is_method else None)
|
||||||
|
if (value := cache.get(key)) is None:
|
||||||
|
value = self.func(*args, **kwargs)
|
||||||
|
cache[key] = value
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def invalidate(
|
||||||
|
self, bl_instance: BLInstance | None, **arguments: dict[str, typ.Any]
|
||||||
|
) -> dict[str, typ.Any]:
|
||||||
|
# Determine Wildcard Arguments
|
||||||
|
wildcard_arguments = {
|
||||||
|
arg_name for arg_name, arg_value in arguments.items() if arg_value is ...
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compute Keys to Invalidate
|
||||||
|
arguments_hashable = {
|
||||||
|
arg_name: ENCODER.encode(arg_value)
|
||||||
|
if arg_name in self.serialize and arg_name not in wildcard_arguments
|
||||||
|
else arg_value
|
||||||
|
for arg_name, arg_value in arguments.items()
|
||||||
|
}
|
||||||
|
cache = self.cache(bl_instance.instance_id if self.is_method else None)
|
||||||
|
for key in list(cache.keys()):
|
||||||
|
if all(
|
||||||
|
arguments_hashable.get(arg_name) == arg_value
|
||||||
|
for arg_name, arg_value in zip(self.key_schema, key, strict=True)
|
||||||
|
if arg_name not in wildcard_arguments
|
||||||
|
):
|
||||||
|
cache.pop(key)
|
||||||
|
|
||||||
|
|
||||||
|
def keyed_cache(exclude: set[str], serialize: set[str] = frozenset()) -> typ.Callable:
|
||||||
|
def decorator(func: typ.Callable) -> typ.Callable:
|
||||||
|
return KeyedCache(
|
||||||
|
func,
|
||||||
|
exclude=exclude,
|
||||||
|
serialize=serialize,
|
||||||
|
)
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# - Property Descriptor
|
# - Property Descriptor
|
||||||
####################
|
####################
|
||||||
class CachedBLProperty:
|
class CachedBLProperty:
|
||||||
"""A descriptor that caches a computed attribute of a Blender node/socket/... instance (`bl_instance`), with optional cache persistence.
|
"""A descriptor that caches a computed attribute of a Blender node/socket/... instance (`bl_instance`), with optional cache persistence.
|
||||||
|
|
||||||
Note:
|
Notes:
|
||||||
**Accessing the internal `_*` attributes is likely an anti-pattern**.
|
**Accessing the internal `_*` attributes is likely an anti-pattern**.
|
||||||
|
|
||||||
`CachedBLProperty` does not own the data; it only provides a convenient interface of running user-provided getter/setters.
|
`CachedBLProperty` does not own the data; it only provides a convenient interface of running user-provided getter/setters.
|
||||||
|
@ -279,6 +411,7 @@ class CachedBLProperty:
|
||||||
"""
|
"""
|
||||||
if bl_instance is None:
|
if bl_instance is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Create Non-Persistent Cache Entry
|
# Create Non-Persistent Cache Entry
|
||||||
## Prefer explicit cache management to 'defaultdict'
|
## Prefer explicit cache management to 'defaultdict'
|
||||||
if CACHE_NOPERSIST.get(bl_instance.instance_id) is None:
|
if CACHE_NOPERSIST.get(bl_instance.instance_id) is None:
|
||||||
|
@ -371,7 +504,7 @@ class CachedBLProperty:
|
||||||
|
|
||||||
This is invoked by `__set__`.
|
This is invoked by `__set__`.
|
||||||
|
|
||||||
Note:
|
Notes:
|
||||||
Will not delete the `bpy.props.StringProperty`; instead, it will be set to ''.
|
Will not delete the `bpy.props.StringProperty`; instead, it will be set to ''.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
@ -416,14 +549,12 @@ def cached_bl_property(persist: bool = ...):
|
||||||
Examples:
|
Examples:
|
||||||
```python
|
```python
|
||||||
class CustomNode(bpy.types.Node):
|
class CustomNode(bpy.types.Node):
|
||||||
@bl_cache.cached(persist=True|False)
|
@bl_cache.cached(persist=True)
|
||||||
def computed_prop(self) -> ...: return ...
|
def computed_prop(self) -> ...: return ...
|
||||||
|
|
||||||
print(bl_instance.prop) ## Computes first time
|
print(bl_instance.prop) ## Computes first time
|
||||||
print(bl_instance.prop) ## Cached (maybe persistently in a property, maybe not)
|
print(bl_instance.prop) ## Cached (after restart, will read from persistent cache)
|
||||||
```
|
```
|
||||||
|
|
||||||
When
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(getter_method: typ.Callable[[BLInstance], None]) -> type:
|
def decorator(getter_method: typ.Callable[[BLInstance], None]) -> type:
|
||||||
|
@ -438,12 +569,14 @@ def cached_bl_property(persist: bool = ...):
|
||||||
class BLField:
|
class BLField:
|
||||||
"""A descriptor that allows persisting arbitrary types in Blender objects, with cached reads."""
|
"""A descriptor that allows persisting arbitrary types in Blender objects, with cached reads."""
|
||||||
|
|
||||||
def __init__(self, default_value: typ.Any, triggers_prop_update: bool = True):
|
def __init__(
|
||||||
|
self, default_value: typ.Any, triggers_prop_update: bool = True
|
||||||
|
) -> typ.Self:
|
||||||
"""Initializes and sets the attribute to a given default value.
|
"""Initializes and sets the attribute to a given default value.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
default_value: The default value to use if the value is read before it's set.
|
default_value: The default value to use if the value is read before it's set.
|
||||||
trigger_prop_update: Whether to run `bl_instance.sync_prop(attr_name)` whenever value is set.
|
triggers_prop_update: Whether to run `bl_instance.sync_prop(attr_name)` whenever value is set.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
log.debug(
|
log.debug(
|
||||||
|
@ -462,7 +595,7 @@ class BLField:
|
||||||
and use them as user-provided getter/setter to internally define a normal non-persistent `CachedBLProperty`.
|
and use them as user-provided getter/setter to internally define a normal non-persistent `CachedBLProperty`.
|
||||||
As a result, we can reuse almost all of the logic in `CachedBLProperty`
|
As a result, we can reuse almost all of the logic in `CachedBLProperty`
|
||||||
|
|
||||||
Note:
|
Notes:
|
||||||
Run by Python when setting an instance of this class to an attribute.
|
Run by Python when setting an instance of this class to an attribute.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
|
|
@ -73,7 +73,7 @@ class NodeLinkCache:
|
||||||
- Failure to do so may result in a segmentation fault at arbitrary future time.
|
- Failure to do so may result in a segmentation fault at arbitrary future time.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
link_ptrs: Pointers to remove from the cache.
|
link_ptr: Pointer to remove from the cache.
|
||||||
"""
|
"""
|
||||||
self.link_ptrs.remove(link_ptr)
|
self.link_ptrs.remove(link_ptr)
|
||||||
self.link_ptrs_as_links.pop(link_ptr)
|
self.link_ptrs_as_links.pop(link_ptr)
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
"""Defines a special base class, `MaxwellSimNode`, from which all nodes inherit.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
MANDATORY_PROPS: Properties that must be defined on the `MaxwellSimNode`.
|
||||||
|
"""
|
||||||
|
|
||||||
import typing as typ
|
import typing as typ
|
||||||
import uuid
|
import uuid
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
|
@ -6,7 +12,6 @@ import bpy
|
||||||
import sympy as sp
|
import sympy as sp
|
||||||
import typing_extensions as typx
|
import typing_extensions as typx
|
||||||
|
|
||||||
from ....utils import extra_sympy_units as spux
|
|
||||||
from ....utils import logger
|
from ....utils import logger
|
||||||
from .. import bl_cache
|
from .. import bl_cache
|
||||||
from .. import contracts as ct
|
from .. import contracts as ct
|
||||||
|
@ -15,7 +20,7 @@ from . import events
|
||||||
|
|
||||||
log = logger.get(__name__)
|
log = logger.get(__name__)
|
||||||
|
|
||||||
MANDATORY_PROPS = {'node_type', 'bl_label'}
|
MANDATORY_PROPS: set[str] = {'node_type', 'bl_label'}
|
||||||
|
|
||||||
|
|
||||||
class MaxwellSimNode(bpy.types.Node):
|
class MaxwellSimNode(bpy.types.Node):
|
||||||
|
@ -147,6 +152,9 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
if output_socket_set_name not in _input_socket_set_names
|
if output_socket_set_name not in _input_socket_set_names
|
||||||
]
|
]
|
||||||
|
|
||||||
|
####################
|
||||||
|
# - Subclass Initialization
|
||||||
|
####################
|
||||||
@classmethod
|
@classmethod
|
||||||
def __init_subclass__(cls, **kwargs) -> None:
|
def __init_subclass__(cls, **kwargs) -> None:
|
||||||
"""Initializes node properties and attributes for use.
|
"""Initializes node properties and attributes for use.
|
||||||
|
@ -210,16 +218,8 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
cls.active_preset = None
|
cls.active_preset = None
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# - Events: Class Properties
|
# - Events: Default
|
||||||
####################
|
####################
|
||||||
@events.on_value_changed(prop_name='active_socket_set')
|
|
||||||
def _on_socket_set_changed(self):
|
|
||||||
log.info(
|
|
||||||
'Changed Sim Node Socket Set to "%s"',
|
|
||||||
self.active_socket_set,
|
|
||||||
)
|
|
||||||
self._sync_sockets()
|
|
||||||
|
|
||||||
@events.on_value_changed(
|
@events.on_value_changed(
|
||||||
prop_name='sim_node_name',
|
prop_name='sim_node_name',
|
||||||
props={'sim_node_name', 'managed_objs', 'managed_obj_defs'},
|
props={'sim_node_name', 'managed_objs', 'managed_obj_defs'},
|
||||||
|
@ -237,6 +237,14 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
mobj_def = props['managed_obj_defs'][mobj_id]
|
mobj_def = props['managed_obj_defs'][mobj_id]
|
||||||
mobj.name = mobj_def.name_prefix + props['sim_node_name']
|
mobj.name = mobj_def.name_prefix + props['sim_node_name']
|
||||||
|
|
||||||
|
@events.on_value_changed(prop_name='active_socket_set')
|
||||||
|
def _on_socket_set_changed(self):
|
||||||
|
log.info(
|
||||||
|
'Changed Sim Node Socket Set to "%s"',
|
||||||
|
self.active_socket_set,
|
||||||
|
)
|
||||||
|
self._sync_sockets()
|
||||||
|
|
||||||
@events.on_value_changed(
|
@events.on_value_changed(
|
||||||
prop_name='active_preset', props=['presets', 'active_preset']
|
prop_name='active_preset', props=['presets', 'active_preset']
|
||||||
)
|
)
|
||||||
|
@ -293,7 +301,7 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
self.locked = False
|
self.locked = False
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# - Loose Sockets
|
# - Loose Sockets w/Events
|
||||||
####################
|
####################
|
||||||
loose_input_sockets: dict[str, ct.schemas.SocketDef] = bl_cache.BLField({})
|
loose_input_sockets: dict[str, ct.schemas.SocketDef] = bl_cache.BLField({})
|
||||||
loose_output_sockets: dict[str, ct.schemas.SocketDef] = bl_cache.BLField({})
|
loose_output_sockets: dict[str, ct.schemas.SocketDef] = bl_cache.BLField({})
|
||||||
|
@ -312,7 +320,7 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
|
|
||||||
Only use internally, when `node.inputs`/`node.outputs` is too much of a mouthful to use directly.
|
Only use internally, when `node.inputs`/`node.outputs` is too much of a mouthful to use directly.
|
||||||
|
|
||||||
Note:
|
Notes:
|
||||||
You should probably use `node.inputs` or `node.outputs` directly.
|
You should probably use `node.inputs` or `node.outputs` directly.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
@ -329,7 +337,7 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
) -> dict[ct.SocketName, ct.schemas.SocketDef]:
|
) -> dict[ct.SocketName, ct.schemas.SocketDef]:
|
||||||
"""Retrieve all socket definitions for sockets that should be defined, according to the `self.active_socket_set`.
|
"""Retrieve all socket definitions for sockets that should be defined, according to the `self.active_socket_set`.
|
||||||
|
|
||||||
Note:
|
Notes:
|
||||||
You should probably use `self.active_socket_defs()`
|
You should probably use `self.active_socket_defs()`
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
@ -442,7 +450,7 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
- Any existing active socket will not be changed.
|
- Any existing active socket will not be changed.
|
||||||
- Any existing inactive socket will be removed.
|
- Any existing inactive socket will be removed.
|
||||||
|
|
||||||
Note:
|
Notes:
|
||||||
Must be called after any change to socket definitions, including loose
|
Must be called after any change to socket definitions, including loose
|
||||||
sockets.
|
sockets.
|
||||||
"""
|
"""
|
||||||
|
@ -482,6 +490,271 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
####################
|
||||||
|
# - Event Methods
|
||||||
|
####################
|
||||||
|
@property
|
||||||
|
def _event_method_filter_by_action(self) -> dict[ct.DataFlowAction, typ.Callable]:
|
||||||
|
"""Compute a map of DataFlowActions, to a function that filters its event methods.
|
||||||
|
|
||||||
|
The returned filter functions are hard-coded, and must always return a `bool`.
|
||||||
|
They may use attributes of `self`, always return `True` or `False`, or something different.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
This is an internal method; you probably want `self.filtered_event_methods_by_action`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The map of `ct.DataFlowAction` to a function that can determine whether any `event_method` should be run.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
ct.DataFlowAction.EnableLock: lambda *_: True,
|
||||||
|
ct.DataFlowAction.DisableLock: lambda *_: True,
|
||||||
|
ct.DataFlowAction.DataChanged: lambda event_method,
|
||||||
|
socket_name,
|
||||||
|
prop_name,
|
||||||
|
_: (
|
||||||
|
(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
socket_name
|
||||||
|
and event_method.callback_info.on_any_changed_loose_input
|
||||||
|
and socket_name in self.loose_input_sockets
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ct.DataFlowAction.OutputRequested: lambda output_socket_method,
|
||||||
|
output_socket_name,
|
||||||
|
_,
|
||||||
|
kind: (
|
||||||
|
kind == output_socket_method.callback_info.kind
|
||||||
|
and (
|
||||||
|
output_socket_name
|
||||||
|
== output_socket_method.callback_info.output_socket_name
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ct.DataFlowAction.ShowPreview: lambda *_: True,
|
||||||
|
ct.DataFlowAction.ShowPlot: lambda *_: True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def filtered_event_methods_by_action(
|
||||||
|
self,
|
||||||
|
action: ct.DataFlowAction,
|
||||||
|
_filter: tuple[ct.SocketName, str],
|
||||||
|
) -> list[typ.Callable]:
|
||||||
|
"""Return all event methods that should run, given the context provided by `_filter`.
|
||||||
|
|
||||||
|
The inclusion decision is made by the internal property `self._event_method_filter_by_action`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
All `event_method`s that should run, as callable objects (they can be run using `event_method(self)`).
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
event_method
|
||||||
|
for event_method in self.event_methods_by_action[action]
|
||||||
|
if self._event_method_filter_by_action[action](event_method, *_filter)
|
||||||
|
]
|
||||||
|
|
||||||
|
####################
|
||||||
|
# - Compute: Input Socket
|
||||||
|
####################
|
||||||
|
@bl_cache.keyed_cache(
|
||||||
|
exclude={'self', 'optional'},
|
||||||
|
serialize={'unit_system'},
|
||||||
|
)
|
||||||
|
def _compute_input(
|
||||||
|
self,
|
||||||
|
input_socket_name: ct.SocketName,
|
||||||
|
kind: ct.DataFlowKind = ct.DataFlowKind.Value,
|
||||||
|
unit_system: dict[ct.SocketType, sp.Expr] | None = None,
|
||||||
|
optional: bool = False,
|
||||||
|
) -> typ.Any:
|
||||||
|
"""Computes the data of an input socket, following links if needed.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
The semantics derive entirely from `sockets.MaxwellSimSocket.compute_data()`.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
input_socket_name: The name of the input socket to compute the value of.
|
||||||
|
It must be currently active.
|
||||||
|
kind: The data flow kind to compute.
|
||||||
|
"""
|
||||||
|
if (bl_socket := self.inputs.get(input_socket_name)) is not None:
|
||||||
|
return (
|
||||||
|
ct.DataFlowKind.scale_to_unit_system(
|
||||||
|
kind,
|
||||||
|
bl_socket.compute_data(kind=kind),
|
||||||
|
bl_socket.socket_type,
|
||||||
|
unit_system,
|
||||||
|
)
|
||||||
|
if unit_system is not None
|
||||||
|
else bl_socket.compute_data(kind=kind)
|
||||||
|
)
|
||||||
|
|
||||||
|
if optional:
|
||||||
|
return None
|
||||||
|
|
||||||
|
msg = f'Input socket "{input_socket_name}" on "{self.bl_idname}" is not an active input socket'
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
####################
|
||||||
|
# - Compute Action: Output Socket
|
||||||
|
####################
|
||||||
|
@bl_cache.keyed_cache(
|
||||||
|
exclude={'self', 'optional'},
|
||||||
|
)
|
||||||
|
def compute_output(
|
||||||
|
self,
|
||||||
|
output_socket_name: ct.SocketName,
|
||||||
|
kind: ct.DataFlowKind = ct.DataFlowKind.Value,
|
||||||
|
optional: bool = False,
|
||||||
|
) -> typ.Any:
|
||||||
|
"""Computes the value of an output socket.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
output_socket_name: The name declaring the output socket, for which this method computes the output.
|
||||||
|
kind: The DataFlowKind to use when computing the output socket value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
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
|
||||||
|
|
||||||
|
msg = f"Can't compute nonexistent output socket name {output_socket_name}, as it's not currently active"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
output_socket_methods = self.filtered_event_methods_by_action(
|
||||||
|
ct.DataFlowAction.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)
|
||||||
|
|
||||||
|
return output_socket_methods[0](self)
|
||||||
|
|
||||||
|
msg = f'No output method for ({output_socket_name}, {kind.value!s}'
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
####################
|
||||||
|
# - Action Trigger
|
||||||
|
####################
|
||||||
|
def _should_recompute_output_socket(
|
||||||
|
self,
|
||||||
|
method_info: events.InfoOutputRequested,
|
||||||
|
input_socket_name: ct.SocketName,
|
||||||
|
prop_name: str,
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
prop_name is not None
|
||||||
|
and prop_name in method_info.depon_props
|
||||||
|
or input_socket_name is not None
|
||||||
|
and (
|
||||||
|
input_socket_name in method_info.depon_input_sockets
|
||||||
|
or (
|
||||||
|
method_info.depon_all_loose_input_sockets
|
||||||
|
and input_socket_name in self.loose_input_sockets
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def trigger_action(
|
||||||
|
self,
|
||||||
|
action: ct.DataFlowAction,
|
||||||
|
socket_name: ct.SocketName | None = None,
|
||||||
|
prop_name: ct.SocketName | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Recursively triggers actions/events forwards or backwards along the node tree, allowing nodes in the update path to react.
|
||||||
|
|
||||||
|
Use `events` decorators to define methods that react to particular `ct.DataFlowAction`s.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
This can be an unpredictably heavy function, depending on the node graph topology.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
action: The action/event to report forwards/backwards along the node tree.
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
if action == ct.DataFlowAction.DataChanged:
|
||||||
|
input_socket_name = socket_name ## Trigger direction is forwards
|
||||||
|
|
||||||
|
# Invalidate Input Socket Cache
|
||||||
|
if input_socket_name is not None:
|
||||||
|
self._compute_input.invalidate(
|
||||||
|
input_socket_name=input_socket_name,
|
||||||
|
kind=...,
|
||||||
|
unit_system=...,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invalidate Output Socket Cache
|
||||||
|
for output_socket_method in self.event_methods_by_action[
|
||||||
|
ct.DataFlowAction.OutputRequested
|
||||||
|
]:
|
||||||
|
method_info = output_socket_method.callback_info
|
||||||
|
if self._should_recompute_output_socket(
|
||||||
|
method_info, socket_name, prop_name
|
||||||
|
):
|
||||||
|
self.compute_output.invalidate(
|
||||||
|
output_socket_name=method_info.output_socket_name,
|
||||||
|
kind=method_info.kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run Triggered Event Methods
|
||||||
|
stop_propagation = False
|
||||||
|
triggered_event_methods = self.filtered_event_methods_by_action(
|
||||||
|
action, (socket_name, prop_name, None)
|
||||||
|
)
|
||||||
|
for event_method in triggered_event_methods:
|
||||||
|
stop_propagation |= event_method.stop_propagation
|
||||||
|
event_method(self)
|
||||||
|
|
||||||
|
# Stop Propagation (maybe)
|
||||||
|
if (
|
||||||
|
ct.DataFlowAction.stop_if_no_event_methods(action)
|
||||||
|
and len(triggered_event_methods) == 0
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Propagate Action to All Sockets in "Trigger Direction"
|
||||||
|
## The trigger chain goes node/socket/node/socket/...
|
||||||
|
if not stop_propagation:
|
||||||
|
triggered_sockets = self._bl_sockets(
|
||||||
|
direc=ct.DataFlowAction.trigger_direction(action)
|
||||||
|
)
|
||||||
|
for bl_socket in triggered_sockets:
|
||||||
|
bl_socket.trigger_action(action)
|
||||||
|
|
||||||
|
####################
|
||||||
|
# - Property Action: On Update
|
||||||
|
####################
|
||||||
|
def sync_prop(self, prop_name: str, _: bpy.types.Context) -> None:
|
||||||
|
"""Report that a particular property has changed, which may cause certain caches to regenerate.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
Called by **all** valid `bpy.prop.Property` definitions in the addon, via their update methods.
|
||||||
|
|
||||||
|
May be called in a threaded context - careful!
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
prop_name: The name of the property that changed.
|
||||||
|
"""
|
||||||
|
if hasattr(self, prop_name):
|
||||||
|
self.trigger_action(ct.DataFlowAction.DataChanged, prop_name=prop_name)
|
||||||
|
else:
|
||||||
|
msg = f'Property {prop_name} not defined on node {self}'
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# - UI Methods
|
# - UI Methods
|
||||||
####################
|
####################
|
||||||
|
@ -514,9 +787,7 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
layout.prop(self, 'active_preset', text='')
|
layout.prop(self, 'active_preset', text='')
|
||||||
|
|
||||||
# Draw Name
|
# Draw Name
|
||||||
# col = layout.column(align=False)
|
|
||||||
if self.use_sim_node_name:
|
if self.use_sim_node_name:
|
||||||
# row = col.row(align=True)
|
|
||||||
row = layout.row(align=True)
|
row = layout.row(align=True)
|
||||||
row.label(text='', icon='FILE_TEXT')
|
row.label(text='', icon='FILE_TEXT')
|
||||||
row.prop(self, 'sim_node_name', text='')
|
row.prop(self, 'sim_node_name', text='')
|
||||||
|
@ -525,389 +796,83 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
self.draw_props(context, layout)
|
self.draw_props(context, layout)
|
||||||
self.draw_operators(context, layout)
|
self.draw_operators(context, layout)
|
||||||
self.draw_info(context, layout)
|
self.draw_info(context, layout)
|
||||||
# self.draw_props(context, col)
|
|
||||||
# self.draw_operators(context, col)
|
|
||||||
# self.draw_info(context, col)
|
|
||||||
|
|
||||||
def draw_props(self, context, layout):
|
def draw_props(
|
||||||
pass
|
self, context: bpy.types.Context, layout: bpy.types.UILayout
|
||||||
|
|
||||||
def draw_operators(self, context, layout):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def draw_info(self, context, layout):
|
|
||||||
pass
|
|
||||||
|
|
||||||
####################
|
|
||||||
# - Special Compute Input / Output Caches
|
|
||||||
####################
|
|
||||||
|
|
||||||
## Compute Output Cache
|
|
||||||
## -> KEY: output socket name, kind
|
|
||||||
## -> INV: When DataChanged triggers with one of the event_method dependencies:
|
|
||||||
## - event_method.dependencies.input_sockets has DataChanged socket_name
|
|
||||||
## - event_method.dependencies.input_socket_kinds has DataChanged kind
|
|
||||||
## - DataChanged socket_name is loose and event_method wants all-loose
|
|
||||||
## - event_method.dependencies.props has DataChanged prop_name
|
|
||||||
def _hit_cached_output_socket_value(
|
|
||||||
self,
|
|
||||||
compute_output_socket_cb: typ.Callable[[], typ.Any],
|
|
||||||
output_socket_name: ct.SocketName,
|
|
||||||
kind: ct.DataFlowKind,
|
|
||||||
) -> typ.Any | None:
|
|
||||||
"""Retrieve a cached output socket value by `output_socket_name, kind`."""
|
|
||||||
# Create Non-Persistent Cache Entry
|
|
||||||
if bl_cache.CACHE_NOPERSIST.get(self.instance_id) is None:
|
|
||||||
bl_cache.CACHE_NOPERSIST[self.instance_id] = {}
|
|
||||||
cache_nopersist = bl_cache.CACHE_NOPERSIST[self.instance_id]
|
|
||||||
|
|
||||||
# Create Output Socket Cache Entry
|
|
||||||
if cache_nopersist.get('_cached_output_sockets') is None:
|
|
||||||
cache_nopersist['_cached_output_sockets'] = {}
|
|
||||||
cached_output_sockets = cache_nopersist['_cached_output_sockets']
|
|
||||||
|
|
||||||
# Try Hit on Cached Output Sockets
|
|
||||||
cached_value = cached_output_sockets.get((output_socket_name, kind))
|
|
||||||
if cached_value is None:
|
|
||||||
value = compute_output_socket_cb()
|
|
||||||
cached_output_sockets[(output_socket_name, kind)] = value
|
|
||||||
else:
|
|
||||||
value = cached_value
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _invalidate_cached_output_socket_value(
|
|
||||||
self, output_socket_name: ct.SocketName, kind: ct.DataFlowKind
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# Create Non-Persistent Cache Entry
|
"""Draws any properties of the node.
|
||||||
if bl_cache.CACHE_NOPERSIST.get(self.instance_id) is None:
|
|
||||||
return
|
|
||||||
cache_nopersist = bl_cache.CACHE_NOPERSIST[self.instance_id]
|
|
||||||
|
|
||||||
# Create Output Socket Cache Entry
|
Notes:
|
||||||
if cache_nopersist.get('_cached_output_sockets') is None:
|
Should be overriden by individual node classes, if they have properties to expose.
|
||||||
return
|
|
||||||
cached_output_sockets = cache_nopersist['_cached_output_sockets']
|
|
||||||
|
|
||||||
# Try Hit & Delete
|
Parameters:
|
||||||
cached_output_sockets.pop((output_socket_name, kind), None)
|
context: The current Blender context.
|
||||||
|
layout: Target for defining UI elements.
|
||||||
|
"""
|
||||||
|
|
||||||
## Input Cache
|
def draw_operators(
|
||||||
## -> KEY: input socket name, kind, unit system
|
self, context: bpy.types.Context, layout: bpy.types.UILayout
|
||||||
## -> INV: DataChanged w/socket name
|
|
||||||
def _hit_cached_input_socket_value(
|
|
||||||
self,
|
|
||||||
compute_input_socket_cb: typ.Callable[[typ.Self], typ.Any],
|
|
||||||
input_socket_name: ct.SocketName,
|
|
||||||
kind: ct.DataFlowKind,
|
|
||||||
unit_system: dict[ct.SocketType, sp.Expr],
|
|
||||||
) -> typ.Any | None:
|
|
||||||
# Create Non-Persistent Cache Entry
|
|
||||||
if bl_cache.CACHE_NOPERSIST.get(self.instance_id) is None:
|
|
||||||
bl_cache.CACHE_NOPERSIST[self.instance_id] = {}
|
|
||||||
cache_nopersist = bl_cache.CACHE_NOPERSIST[self.instance_id]
|
|
||||||
|
|
||||||
# Create Output Socket Cache Entry
|
|
||||||
if cache_nopersist.get('_cached_input_sockets') is None:
|
|
||||||
cache_nopersist['_cached_input_sockets'] = {}
|
|
||||||
cached_input_sockets = cache_nopersist['_cached_input_sockets']
|
|
||||||
|
|
||||||
# Try Hit on Cached Output Sockets
|
|
||||||
encoded_unit_system = bl_cache.ENCODER.encode(unit_system).decode('utf-8')
|
|
||||||
cached_value = cached_input_sockets.get(
|
|
||||||
(input_socket_name, kind, encoded_unit_system),
|
|
||||||
)
|
|
||||||
if cached_value is None:
|
|
||||||
value = compute_input_socket_cb()
|
|
||||||
cached_input_sockets[(input_socket_name, kind, encoded_unit_system)] = value
|
|
||||||
else:
|
|
||||||
value = cached_value
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _invalidate_cached_input_socket_value(
|
|
||||||
self,
|
|
||||||
input_socket_name: ct.SocketName,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# Create Non-Persistent Cache Entry
|
"""Draws any operators associated with the node.
|
||||||
if bl_cache.CACHE_NOPERSIST.get(self.instance_id) is None:
|
|
||||||
return
|
|
||||||
cache_nopersist = bl_cache.CACHE_NOPERSIST[self.instance_id]
|
|
||||||
|
|
||||||
# Create Output Socket Cache Entry
|
Notes:
|
||||||
if cache_nopersist.get('_cached_input_sockets') is None:
|
Should be overriden by individual node classes, if they have operators to expose.
|
||||||
return
|
|
||||||
cached_input_sockets = cache_nopersist['_cached_input_sockets']
|
|
||||||
|
|
||||||
# Try Hit & Delete
|
|
||||||
for cached_input_socket in list(cached_input_sockets.keys()):
|
|
||||||
if cached_input_socket[0] == input_socket_name:
|
|
||||||
cached_input_sockets.pop(cached_input_socket, None)
|
|
||||||
|
|
||||||
####################
|
|
||||||
# - Data Flow
|
|
||||||
####################
|
|
||||||
## TODO: Lazy input socket list in events.py callbacks, to replace random scattered `_compute_input` calls.
|
|
||||||
def _compute_input(
|
|
||||||
self,
|
|
||||||
input_socket_name: ct.SocketName,
|
|
||||||
kind: ct.DataFlowKind = ct.DataFlowKind.Value,
|
|
||||||
unit_system: dict[ct.SocketType, sp.Expr] | None = None,
|
|
||||||
optional: bool = False,
|
|
||||||
) -> typ.Any:
|
|
||||||
"""Computes the data of an input socket, following links if needed.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
The semantics derive entirely from `sockets.MaxwellSimSocket.compute_data()`.
|
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
input_socket_name: The name of the input socket to compute the value of.
|
context: The current Blender context.
|
||||||
It must be currently active.
|
layout: Target for defining UI elements.
|
||||||
kind: The data flow kind to compute.
|
|
||||||
"""
|
"""
|
||||||
if (bl_socket := self.inputs.get(input_socket_name)) is not None:
|
|
||||||
return self._hit_cached_input_socket_value(
|
|
||||||
lambda: (
|
|
||||||
ct.DataFlowKind.scale_to_unit_system(
|
|
||||||
kind,
|
|
||||||
bl_socket.compute_data(kind=kind),
|
|
||||||
bl_socket.socket_type,
|
|
||||||
unit_system,
|
|
||||||
)
|
|
||||||
if unit_system is not None
|
|
||||||
else bl_socket.compute_data(kind=kind)
|
|
||||||
),
|
|
||||||
input_socket_name,
|
|
||||||
kind,
|
|
||||||
unit_system,
|
|
||||||
)
|
|
||||||
if optional:
|
|
||||||
return None
|
|
||||||
|
|
||||||
msg = f'Input socket "{input_socket_name}" on "{self.bl_idname}" is not an active input socket'
|
def draw_info(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None:
|
||||||
raise ValueError(msg)
|
"""Draws any runtime information associated with the node.
|
||||||
|
|
||||||
def compute_output(
|
Notes:
|
||||||
self,
|
Should be overriden by individual node classes, if they have runtime information to show.
|
||||||
output_socket_name: ct.SocketName,
|
|
||||||
kind: ct.DataFlowKind = ct.DataFlowKind.Value,
|
|
||||||
optional: bool = False,
|
|
||||||
) -> typ.Any:
|
|
||||||
"""Computes the value of an output socket.
|
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
output_socket_name: The name declaring the output socket, for which this method computes the output.
|
context: The current Blender context.
|
||||||
kind: The DataFlowKind to use when computing the output socket value.
|
layout: Target for defining UI elements.
|
||||||
|
|
||||||
Returns:
|
|
||||||
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
|
|
||||||
msg = f"Can't compute nonexistent output socket name {output_socket_name}, as it's not currently active"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
output_socket_methods = self.event_methods_by_action[
|
|
||||||
ct.DataFlowAction.OutputRequested
|
|
||||||
]
|
|
||||||
possible_output_socket_methods = [
|
|
||||||
output_socket_method
|
|
||||||
for output_socket_method in output_socket_methods
|
|
||||||
if kind == output_socket_method.callback_info.kind
|
|
||||||
and (
|
|
||||||
output_socket_name
|
|
||||||
== output_socket_method.callback_info.output_socket_name
|
|
||||||
or (
|
|
||||||
output_socket_method.callback_info.any_loose_output_socket
|
|
||||||
and output_socket_name in self.loose_output_sockets
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
if len(possible_output_socket_methods) == 1:
|
|
||||||
return self._hit_cached_output_socket_value(
|
|
||||||
lambda: possible_output_socket_methods[0](self),
|
|
||||||
output_socket_name,
|
|
||||||
kind,
|
|
||||||
)
|
|
||||||
return possible_output_socket_methods[0](self)
|
|
||||||
|
|
||||||
if len(possible_output_socket_methods) == 0:
|
|
||||||
msg = f'No output method for ({output_socket_name}, {kind.value!s}'
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
if len(possible_output_socket_methods) > 1:
|
|
||||||
msg = (
|
|
||||||
f'More than one method found for ({output_socket_name}, {kind.value!s}.'
|
|
||||||
)
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
msg = 'Somehow, a length is negative. Call NASA.'
|
|
||||||
raise SystemError(msg)
|
|
||||||
|
|
||||||
####################
|
|
||||||
# - Action Chain
|
|
||||||
####################
|
|
||||||
def sync_prop(self, prop_name: str, _: bpy.types.Context) -> None:
|
|
||||||
"""Report that a particular property has changed, which may cause certain caches to regenerate.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Called by **all** valid `bpy.prop.Property` definitions in the addon, via their update methods.
|
|
||||||
|
|
||||||
May be called in a threaded context - careful!
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
prop_name: The name of the property that changed.
|
|
||||||
"""
|
|
||||||
if hasattr(self, prop_name):
|
|
||||||
self.trigger_action(ct.DataFlowAction.DataChanged, prop_name=prop_name)
|
|
||||||
else:
|
|
||||||
msg = f'Property {prop_name} not defined on node {self}'
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
@bl_cache.cached_bl_property(persist=False)
|
|
||||||
def event_method_filter_by_action(self) -> dict[ct.DataFlowAction, typ.Callable]:
|
|
||||||
"""Compute a map of DataFlowActions, to a function that filters its event methods.
|
|
||||||
|
|
||||||
The filter expression may use attributes of `self`, or return `True` if no filtering should occur, or return `False` if methods should never run.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
ct.DataFlowAction.EnableLock: lambda *_: True,
|
|
||||||
ct.DataFlowAction.DisableLock: lambda *_: True,
|
|
||||||
ct.DataFlowAction.DataChanged: lambda event_method,
|
|
||||||
socket_name,
|
|
||||||
prop_name: (
|
|
||||||
(
|
|
||||||
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
|
|
||||||
)
|
|
||||||
or (
|
|
||||||
socket_name
|
|
||||||
and event_method.callback_info.on_any_changed_loose_input
|
|
||||||
and socket_name in self.loose_input_sockets
|
|
||||||
)
|
|
||||||
),
|
|
||||||
ct.DataFlowAction.OutputRequested: lambda *_: False,
|
|
||||||
ct.DataFlowAction.ShowPreview: lambda *_: True,
|
|
||||||
ct.DataFlowAction.ShowPlot: lambda *_: True,
|
|
||||||
}
|
|
||||||
|
|
||||||
def trigger_action(
|
|
||||||
self,
|
|
||||||
action: ct.DataFlowAction,
|
|
||||||
socket_name: ct.SocketName | None = None,
|
|
||||||
prop_name: ct.SocketName | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Recursively triggers actions/events forwards or backwards along the node tree, allowing nodes in the update path to react.
|
|
||||||
|
|
||||||
Use `events` decorators to define methods that react to particular `ct.DataFlowAction`s.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
This can be an unpredictably heavy function, depending on the node graph topology.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
action: The action/event to report forwards/backwards along the node tree.
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
if action == ct.DataFlowAction.DataChanged:
|
|
||||||
# Invalidate Input/Output Socket Caches
|
|
||||||
all_output_method_infos = [
|
|
||||||
event_method.callback_info
|
|
||||||
for event_method in self.event_methods_by_action[
|
|
||||||
ct.DataFlowAction.OutputRequested
|
|
||||||
]
|
|
||||||
]
|
|
||||||
input_sockets_to_invalidate_cached_values_of = set()
|
|
||||||
output_sockets_to_invalidate_cached_values_of = set()
|
|
||||||
|
|
||||||
# Invalidate by Dependent Input Socket
|
|
||||||
if socket_name is not None:
|
|
||||||
input_sockets_to_invalidate_cached_values_of.add(socket_name)
|
|
||||||
|
|
||||||
## Output Socket: Invalidate if an Output Method Depends on Us
|
|
||||||
output_sockets_to_invalidate_cached_values_of |= {
|
|
||||||
(method_info.output_socket_name, method_info.kind)
|
|
||||||
for method_info in all_output_method_infos
|
|
||||||
if socket_name in method_info.depon_input_sockets
|
|
||||||
or (
|
|
||||||
socket_name in self.loose_input_sockets
|
|
||||||
and method_info.depon_all_loose_input_sockets
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Invalidate by Dependent Property
|
|
||||||
if prop_name is not None:
|
|
||||||
output_sockets_to_invalidate_cached_values_of |= {
|
|
||||||
(method_info.output_socket_name, method_info.kind)
|
|
||||||
for method_info in all_output_method_infos
|
|
||||||
if prop_name in method_info.depon_props
|
|
||||||
}
|
|
||||||
|
|
||||||
# Invalidate Output Socket Values
|
|
||||||
for key in input_sockets_to_invalidate_cached_values_of:
|
|
||||||
# log.debug('Invalidating Input Socket Cache: %s', key)
|
|
||||||
self._invalidate_cached_input_socket_value(key)
|
|
||||||
|
|
||||||
for key in output_sockets_to_invalidate_cached_values_of:
|
|
||||||
# log.debug('Invalidating Output Socket Cache: %s', key)
|
|
||||||
self._invalidate_cached_output_socket_value(*key)
|
|
||||||
|
|
||||||
# Run Triggered Event Methods
|
|
||||||
stop_propagation = False ## A method wants us to not continue
|
|
||||||
event_methods_to_run = [
|
|
||||||
event_method
|
|
||||||
for event_method in self.event_methods_by_action[action]
|
|
||||||
if self.event_method_filter_by_action[action](
|
|
||||||
event_method, socket_name, prop_name
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for event_method in event_methods_to_run:
|
|
||||||
stop_propagation |= event_method.stop_propagation
|
|
||||||
event_method(self)
|
|
||||||
|
|
||||||
# Trigger Action on Input/Output Sockets
|
|
||||||
## The trigger chain goes node/socket/node/socket/...
|
|
||||||
if (
|
|
||||||
ct.DataFlowAction.stop_if_no_event_methods(action)
|
|
||||||
and len(event_methods_to_run) == 0
|
|
||||||
):
|
|
||||||
return
|
|
||||||
if not stop_propagation:
|
|
||||||
triggered_sockets = self._bl_sockets(
|
|
||||||
direc=ct.DataFlowAction.trigger_direction(action)
|
|
||||||
)
|
|
||||||
for bl_socket in triggered_sockets:
|
|
||||||
bl_socket.trigger_action(action)
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# - Blender Node Methods
|
# - Blender Node Methods
|
||||||
####################
|
####################
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, node_tree: bpy.types.NodeTree) -> bool:
|
def poll(cls, node_tree: bpy.types.NodeTree) -> bool:
|
||||||
"""Run (by Blender) to determine instantiability.
|
"""Render the node exlusively instantiable within a Maxwell Sim nodetree.
|
||||||
|
|
||||||
Restricted to the MaxwellSimTreeType.
|
Notes:
|
||||||
|
Run by Blender when determining instantiability of a node.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
node_tree: The node tree within which the instantiability of this node should be determined.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether or not the node can be instantiated within the given node tree.
|
||||||
"""
|
"""
|
||||||
return node_tree.bl_idname == ct.TreeType.MaxwellSim.value
|
return node_tree.bl_idname == ct.TreeType.MaxwellSim.value
|
||||||
|
|
||||||
def init(self, context: bpy.types.Context):
|
def init(self, _: bpy.types.Context) -> None:
|
||||||
"""Run (by Blender) on node creation."""
|
"""Initialize the node instance, including ID, name, socket, presets, and the execution of any `on_value_changed` methods with the `run_on_init` keyword set.
|
||||||
# Initialize Cache and Instance ID
|
|
||||||
|
Notes:
|
||||||
|
Run by Blender when a new instance of a node is added to a tree.
|
||||||
|
"""
|
||||||
|
# Initialize Instance ID
|
||||||
|
## This is used by various caches from 'bl_cache'.
|
||||||
self.instance_id = str(uuid.uuid4())
|
self.instance_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# Initialize Name
|
# Initialize Name
|
||||||
|
## This is used whenever a unique name pointing to this node is needed.
|
||||||
|
## Contrary to self.name, it can be altered by the user as a property.
|
||||||
self.sim_node_name = self.name
|
self.sim_node_name = self.name
|
||||||
## Only shown in draw_buttons if 'self.use_sim_node_name'
|
|
||||||
|
|
||||||
# Initialize Sockets
|
# Initialize Sockets
|
||||||
|
## This initializes any nodes that need initializing
|
||||||
self._sync_sockets()
|
self._sync_sockets()
|
||||||
|
|
||||||
# Apply Default Preset
|
# Apply Preset
|
||||||
|
## This applies the default preset, if any.
|
||||||
if self.active_preset:
|
if self.active_preset:
|
||||||
self._on_active_preset_changed()
|
self._on_active_preset_changed()
|
||||||
|
|
||||||
|
@ -925,26 +890,38 @@ class MaxwellSimNode(bpy.types.Node):
|
||||||
event_method(self)
|
event_method(self)
|
||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
pass
|
"""Explicitly do nothing per-node on tree changes.
|
||||||
|
|
||||||
|
For responding to changes affecting only this node, use decorators from `events`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
Run by Blender on all node instances whenever anything **in the entire node tree** changes.
|
||||||
|
"""
|
||||||
|
|
||||||
def copy(self, _: bpy.types.Node) -> None:
|
def copy(self, _: bpy.types.Node) -> None:
|
||||||
"""Generate a new instance ID and Sim Node Name.
|
"""Generate a new instance ID and Sim Node Name on the duplicated node.
|
||||||
|
|
||||||
Note:
|
Notes:
|
||||||
Blender runs this when instantiating this node from an existing node.
|
Blender runs this when instantiating this node from an existing node.
|
||||||
|
|
||||||
Parameters:
|
|
||||||
node: The existing node from which this node was copied.
|
|
||||||
"""
|
"""
|
||||||
# Generate New Instance ID
|
# Generate New Instance ID
|
||||||
self.instance_id = str(uuid.uuid4())
|
self.instance_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# Generate New Name
|
# Generate New Sim Node Name
|
||||||
## Blender will automatically add .001 so that `self.name` is unique.
|
## Blender will automatically add .001 so that `self.name` is unique.
|
||||||
self.sim_node_name = self.name
|
self.sim_node_name = self.name
|
||||||
|
|
||||||
def free(self) -> None:
|
def free(self) -> None:
|
||||||
"""Run (by Blender) when deleting the node."""
|
"""Cleans various instance-associated data up, so the node can be cleanly deleted.
|
||||||
|
|
||||||
|
- **Locking**: The entire input chain will be unlocked. Since we can't prevent the deletion, this is one way to prevent "dangling locks".
|
||||||
|
- **Managed Objects**: `.free()` will be run on all managed objects.
|
||||||
|
- **`NodeLinkCache`**: `.on_node_removed(self)` will be run on the node tree, so it can correctly adjust the `NodeLinkCache`. **This is essential for avoiding "use-after-free" crashes.**
|
||||||
|
- **Non-Persistent Cache**: `bl_cache.invalidate_nonpersist_instance_id` will be run, so that any caches indexed by the instance ID of the to-be-deleted node will be cleared away.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
Run by Blender **before** executing user-requested deletion of a node.
|
||||||
|
"""
|
||||||
node_tree = self.id_data
|
node_tree = self.id_data
|
||||||
|
|
||||||
# Unlock
|
# Unlock
|
||||||
|
|
|
@ -26,7 +26,6 @@ class InfoDataChanged:
|
||||||
@dataclasses.dataclass(kw_only=True, frozen=True)
|
@dataclasses.dataclass(kw_only=True, frozen=True)
|
||||||
class InfoOutputRequested:
|
class InfoOutputRequested:
|
||||||
output_socket_name: ct.SocketName
|
output_socket_name: ct.SocketName
|
||||||
any_loose_output_socket: bool
|
|
||||||
kind: ct.DataFlowKind
|
kind: ct.DataFlowKind
|
||||||
|
|
||||||
depon_props: set[str]
|
depon_props: set[str]
|
||||||
|
@ -317,7 +316,6 @@ def on_value_changed(
|
||||||
## TODO: Change name to 'on_output_requested'
|
## TODO: Change name to 'on_output_requested'
|
||||||
def computes_output_socket(
|
def computes_output_socket(
|
||||||
output_socket_name: ct.SocketName | None,
|
output_socket_name: ct.SocketName | None,
|
||||||
any_loose_output_socket: bool = False,
|
|
||||||
kind: ct.DataFlowKind = ct.DataFlowKind.Value,
|
kind: ct.DataFlowKind = ct.DataFlowKind.Value,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
|
@ -325,7 +323,6 @@ def computes_output_socket(
|
||||||
action_type=ct.DataFlowAction.OutputRequested,
|
action_type=ct.DataFlowAction.OutputRequested,
|
||||||
callback_info=InfoOutputRequested(
|
callback_info=InfoOutputRequested(
|
||||||
output_socket_name=output_socket_name,
|
output_socket_name=output_socket_name,
|
||||||
any_loose_output_socket=any_loose_output_socket,
|
|
||||||
kind=kind,
|
kind=kind,
|
||||||
depon_props=kwargs.get('props', set()),
|
depon_props=kwargs.get('props', set()),
|
||||||
depon_input_sockets=kwargs.get('input_sockets', set()),
|
depon_input_sockets=kwargs.get('input_sockets', set()),
|
||||||
|
|
Loading…
Reference in New Issue