oscillode/oscillode/node_trees/maxwell_sim_nodes/sockets/base.py

1145 lines
36 KiB
Python

# oscillode
# Copyright (C) 2024 oscillode Project Contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# blender_maxwell
# Copyright (C) 2024 blender_maxwell Project Contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import abc
import typing as typ
import bpy
import pydantic as pyd
from blender_maxwell.utils import bl_cache, bl_instance, logger, serialize
from .. import contracts as ct
log = logger.get(__name__)
FK = ct.FlowKind
FS = ct.FlowSignal
####################
# - SocketDef
####################
class SocketDef(pyd.BaseModel, abc.ABC):
"""Defines everything needed to initialize a `MaxwellSimSocket`.
Used by nodes to specify which sockets to use as inputs/outputs.
Notes:
Not instantiated directly - rather, individual sockets each define a SocketDef subclass tailored to its own needs.
Attributes:
socket_type: The socket type to initialize.
"""
socket_type: ct.SocketType
active_kind: typ.Literal[
ct.FlowKind.Value,
ct.FlowKind.Array,
ct.FlowKind.Range,
ct.FlowKind.Func,
] = ct.FlowKind.Value
####################
# - Socket Interaction
####################
def preinit(self, bl_socket: bpy.types.NodeSocket) -> None:
"""Pre-initialize a real Blender node socket from this socket definition.
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()
bl_socket.active_kind = self.active_kind
# 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.
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:
"""Initializes a real Blender node socket from this socket definition.
Parameters:
bl_socket: The Blender node socket to alter using data from this SocketDef.
"""
####################
# - Comparison
####################
def compare(self, bl_socket: bpy.types.NodeSocket) -> bool:
"""Whether this `SocketDef` can be considered to uniquely define the given `bl_socket`.
The general criteria for "uniquely defines" is whether **the same `bl_socket`** could be created using this `SocketDef`.
The extent to which user-altered properties are considered in this regard is a matter of taste, encapsulated entirely within `self.local_compare()`.
Notes:
Used when determining whether to replace sockets with newer variants when synchronizing changes.
**NOTE**: Removing/replacing loose input sockets
Parameters:
bl_socket: The Blender node socket to alter using data from this SocketDef.
"""
return (
bl_socket.socket_type is self.socket_type
and bl_socket.active_kind is self.active_kind
and self.local_compare(bl_socket)
)
def local_compare(self, bl_socket: bpy.types.NodeSocket) -> None:
"""Compare this `SocketDef` to an established `bl_socket` in a manner specific to the node.
Notes:
Run by `self.compare()`.
Optionally overriden by individual sockets.
When not overridden, it will always return `False`, indicating that the socket is _never_ uniquely defined by this `SocketDef`.
Parameters:
bl_socket: The Blender node socket to alter using data from this SocketDef.
"""
return False
####################
# - Serialization
####################
def dump_as_msgspec(self) -> serialize.NaiveRepresentation:
"""Transforms this `SocketDef` into an object that can be natively serialized by `msgspec`.
Notes:
Makes use of `pydantic.BaseModel.model_dump()` to cast any special fields into a serializable format.
If this method is failing, check that `pydantic` can actually cast all the fields in your model.
Returns:
A particular `list`, with three elements:
1. The `serialize`-provided "Type Identifier", to differentiate this list from generic list.
2. The name of this subclass, so that the correct `SocketDef` can be reconstructed on deserialization.
3. A dictionary containing simple Python types, as cast by `pydantic`.
"""
return [serialize.TypeID.SocketDef, self.__class__.__name__, self.model_dump()]
@staticmethod
def parse_as_msgspec(obj: serialize.NaiveRepresentation) -> typ.Self:
"""Transforms an object made by `self.dump_as_msgspec()` into a subclass of `SocketDef`.
Notes:
The method presumes that the deserialized object produced by `msgspec` perfectly matches the object originally created by `self.dump_as_msgspec()`.
This is a **mostly robust** presumption, as `pydantic` attempts to be quite consistent in how to interpret types with almost identical semantics.
Still, yet-unknown edge cases may challenge these presumptions.
Returns:
A new subclass of `SocketDef`, initialized using the `model_dump()` dictionary.
"""
initialized_classes = [
subclass(**obj[2])
for subclass in SocketDef.__subclasses__()
if subclass.__name__ == obj[1]
]
if not initialized_classes:
msg = f'No "SocketDef" subclass found for name {obj[1]}. Please report this error'
RuntimeError(msg)
return initialized_classes[0]
####################
# - Socket
####################
FLOW_ERROR_COLOR: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 1.0)
MANDATORY_PROPS: set[str] = {'socket_type', 'bl_label'}
class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
"""A specialized Blender socket for nodes in a Maxwell simulation.
Attributes:
instance_id: A unique ID attached to a particular socket instance.
Guaranteed to be unchanged so long as the socket lives.
Used as a socket-specific cache index.
locked: The lock-state of a particular socket, which determines the socket's user editability
"""
# Properties
## Class
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
# BLFields
## Identifying
is_initializing: bool = bl_cache.BLField(True, use_prop_update=False)
active_kind: ct.FlowKind = bl_cache.BLField(ct.FlowKind.Value)
## UI
use_info_draw: bool = bl_cache.BLField(False, use_prop_update=False)
use_prelock: bool = bl_cache.BLField(False, use_prop_update=False)
locked: bool = bl_cache.BLField(False, use_prop_update=False)
use_socket_color: bool = bl_cache.BLField(False, use_prop_update=False)
socket_color: tuple[float, float, float, float] = bl_cache.BLField(
(0, 0, 0, 0), use_prop_update=False
)
flow_error: bool = bl_cache.BLField(False, use_prop_update=False)
####################
# - Initialization
####################
def __init_subclass__(cls, **kwargs: typ.Any):
"""Initializes socket properties and attributes for use.
Notes:
Run when initializing any subclass of MaxwellSimSocket.
"""
log.debug('Initializing Socket: %s', cls.socket_type)
super().__init_subclass__(**kwargs)
cls.assert_attrs_valid(MANDATORY_PROPS)
cls.bl_idname: str = str(cls.socket_type.value)
####################
# - Property Event: On Update
####################
def on_active_kind_changed(self) -> None:
"""Matches the display shape to the active `FlowKind`.
Notes:
Called by `self.on_prop_changed()` when `self.active_kind` was changed.
"""
self.display_shape = self.active_kind.socket_shape
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 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_names: The set of property names that were changed.
"""
def on_prop_changed(self, prop_name: str) -> None:
"""Called when a property has been updated.
Contrary to `node.on_prop_changed()`, socket-specific callbacks are baked into this function:
- **Active Kind** (`self.active_kind`): Sets the socket shape to reflect the active `FlowKind`.
**MAY NOT** rely on `FlowEvent` driven caches.
- **Overrided Local Events** (`self.active_kind`): Sets the socket shape to reflect the active `FlowKind`.
**MAY NOT** rely on `FlowEvent` driven caches.
Attributes:
prop_name: The name of the property that was changed.
"""
# 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
## -> 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
# 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
####################
def allow_add_link(self, link: bpy.types.NodeLink) -> bool:
"""Called to ask whether a link may be added to this (input) socket.
- **Locked**: Locked sockets may not have links added.
- **Capabilities**: Capabilities of both sockets participating in the link must be compatible.
Notes:
In practice, the link in question has already been added.
This function determines **whether the new link should be instantly removed** - if so, the removal producing the _practical effect_ of the link "not being added" at all.
Attributes:
link: The node link that was already added, whose continued existance is in question.
Returns:
Whether or not consent is given to add the link.
In practice, the link will simply remain if consent is given.
If consent is not given, the new link will be removed.
Raises:
RuntimeError: If this socket is an output socket.
"""
# Output Socket Check
if self.is_output:
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to ask output socket for consent to add link'
raise RuntimeError(msg)
# Lock Check
if self.locked:
log.error(
'Attempted to link output socket "%s" (%s) to input socket "%s" (%s), but input socket is locked',
link.from_socket.bl_label,
link.from_socket.capabilities,
self.bl_label,
self.capabilities,
)
return False
# Capability Check
## -> "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 FS.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,
incoming_capabilities,
self.bl_label,
self.capabilities,
)
return False
return True
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.
Attributes:
link: The node link that was added.
Currently unused.
"""
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`.
- **Locked**: Locked sockets may not have links removed.
- **Capabilities**: Capabilities of both sockets participating in the link must be compatible.
Notes:
In practice, the link in question has already been removed.
Therefore, only the `from_socket` that the link _was_ attached to is provided.
Attributes:
from_socket: The node socket that was attached to before link removal.
Currently unused.
Returns:
Whether or not consent is given to remove the link.
If so, nothing will happen.
If consent is not given, a new link will be added that is identical to the old one.
Raises:
RuntimeError: If this socket is an output socket.
"""
# Output Socket Check
if self.is_output:
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to ask output socket for consent to remove link'
raise RuntimeError(msg)
# Lock Check
if self.locked:
return False
return True
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.
Attributes:
from_socket: The node socket that was attached to before link removal.
Currently unused.
"""
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.
Links are removed with a simple `node_tree.links.remove()`, which directly emulates a user trying to remove the node link.
**Note** that all of the usual consent-semantics apply just the same as if the user had manually tried to remove the link.
Notes:
Called by nodes directly on their sockets, after altering any property that might influence the capabilities of that socket.
This prevents invalid use when the user alters a property, which **would** disallow adding a _new_ link identical to one that already exists.
In such a case, the existing (non-capability-respecting) link should be removed, as it has become invalid.
"""
node_tree = self.id_data
for link in self.links:
if not link.from_socket.capabilities.is_compatible_with(
link.to_socket.capabilities
):
log.error(
'Deleted link between "%s" (%s) and "%s" (%s) due to invalidated capabilities',
link.from_socket.bl_label,
link.from_socket.capabilities,
link.to_socket.bl_label,
link.to_socket.capabilities,
)
node_tree.links.remove(link)
####################
# - 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.
"""
# Run Socket Callbacks
self.on_socket_data_changed(socket_kinds)
# Clear FlowErrors
## -> We should presume by default that the updated value is OK.
if self.flow_error:
bpy.app.timers.register(self.clear_flow_error)
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,
socket_kinds: set[ct.FlowKind] | None = None,
) -> None:
"""Responds to and triggers subsequent events along the node tree.
- **Locking**: `EnableLock` or `DisableLock` will always affect this socket's lock.
- **Input Socket -> Input**: Trigger event on `from_socket`s along input links.
- **Input Socket -> Output**: Trigger event on node (w/`socket_name`).
- **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.
A `LinkChanged` (->Output) event will trigger a `DataChanged` event on the node.
**This may change** if it becomes important for the node to differentiate between "change in data" and "change in link".
Parameters:
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] Socket 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 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):
case (False, 'input'):
for link in self.links:
link.from_socket.trigger_event(event, socket_kinds=socket_kinds)
case (False, 'output'):
if event is ct.FlowEvent.LinkChanged:
self.node.trigger_event(
ct.FlowEvent.DataChanged,
socket_name=self.name,
socket_kinds=socket_kinds,
)
else:
self.node.trigger_event(
event, socket_name=self.name, socket_kinds=socket_kinds
)
case (True, 'input'):
self.node.trigger_event(
event, socket_name=self.name, socket_kinds=socket_kinds
)
case (True, 'output'):
for link in self.links:
link.to_socket.trigger_event(event, socket_kinds=socket_kinds)
####################
# - 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.
Notes:
See `ct.FlowKind` for more information.
"""
return ct.CapabilitiesFlow(
socket_type=self.socket_type,
active_kind=self.active_kind,
)
# Info
@property
def info(self) -> ct.InfoFlow:
"""Signal that no information is declared by this socket.
Notes:
See `ct.FlowKind` for more information.
Returns:
An empty `ct.InfoFlow`.
"""
return FS.NoFlow
# Param
@property
def params(self) -> ct.ParamsFlow:
"""Signal that no params are declared by this socket.
Notes:
See `ct.FlowKind` for more information.
Returns:
An empty `ct.ParamsFlow`.
"""
return FS.NoFlow
####################
# - FlowKind: Auxiliary
####################
# Value
@property
def value(self) -> ct.ValueFlow:
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
return FS.NoFlow
@value.setter
def value(self, value: ct.ValueFlow) -> None:
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to set "ct.FlowKind.Value", but socket does not define it'
raise NotImplementedError(msg)
# Array
@property
def array(self) -> ct.ArrayFlow:
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
return FS.NoFlow
@array.setter
def array(self, value: ct.ArrayFlow) -> None:
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to set "ct.FlowKind.Array", but socket does not define it'
raise NotImplementedError(msg)
# Func
@property
def lazy_func(self) -> ct.FuncFlow:
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
return FS.NoFlow
@lazy_func.setter
def lazy_func(self, lazy_func: ct.FuncFlow) -> None:
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to set "ct.FlowKind.Func", but socket does not define it'
raise NotImplementedError(msg)
# Range
@property
def lazy_range(self) -> ct.RangeFlow:
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
return FS.NoFlow
@lazy_range.setter
def lazy_range(self, value: ct.RangeFlow) -> None:
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to set "ct.FlowKind.Range", but socket does not define it'
raise NotImplementedError(msg)
####################
# - Data Chain Computation
####################
def _compute_data(
self,
kind: ct.FlowKind = ct.FlowKind.Value,
) -> typ.Any:
"""Low-level method to computes the data contained within this socket, for a particular `ct.FlowKind`.
Notes:
Not all `ct.FlowKind`s are meant to be computed; namely, `Capabilities` should be directly referenced.
Raises:
ValueError: When referencing a socket that's meant to be directly referenced.
"""
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,
}[kind]()
def compute_data(
self,
kind: ct.FlowKind = ct.FlowKind.Value,
) -> typ.Any:
"""Computes internal or link-sourced data represented by this socket.
- **Input Socket | Unlinked**: Use socket's own data, by calling `_compute_data`.
- **Input Socket | Linked**: Call `compute_data` on the linked `from_socket`.
- **Output Socket**: Use the node's output data, by calling `node.compute_output()`.
Notes:
This can be an unpredictably heavy function, depending on the node graph topology.
Parameters:
kind: The `ct.FlowKind` to reference when retrieving the data.
Returns:
The computed data, whever it came from.
Raises:
NotImplementedError: If multi-input sockets are used (no support yet as of Blender 4.1).
"""
# Compute Output Socket
if self.is_output:
flow = self.node.compute_output(self.name, kind=kind)
# Compute Input Socket
## -> Unlinked: Retrieve Socket Value
elif not self.is_linked:
flow = self._compute_data(kind)
else:
# Linked: Compute Data on Linked Socket
## -> Capabilities are guaranteed compatible by 'allow_link_add'.
## -> There is no point in rechecking every time data flows.
linked_values = [link.from_socket.compute_data(kind) for link in self.links]
# Return Single Value / List of Values
## -> Multi-input sockets are not (yet) supported.
if linked_values: # noqa: SIM108
flow = linked_values[0]
# Edge Case: While Dragging Link (but not yet removed)
## While the user is dragging a link:
## - 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.
else:
flow = self._compute_data(kind)
if FS.check_single(flow, FS.FlowPending) and not self.flow_error:
bpy.app.timers.register(self.declare_flow_error)
# elif self.flow_error:
# bpy.app.timers.register(self.clear_flow_error)
return flow
def declare_flow_error(self):
self.flow_error = True
def clear_flow_error(self):
self.flow_error = False
####################
# - UI - Color
####################
def draw_color(
self,
_: bpy.types.Context,
node: bpy.types.Node, # noqa: ARG002
) -> tuple[float, float, float, float]:
"""Draw the socket color depending on context.
When `self.use_socket_color` is set, the property `socket_color` can be used to control the socket color directly.
Otherwise, a default based on `self.socket_type` will be used.
Notes:
Called by Blender to call the socket color.
"""
if self.flow_error:
return FLOW_ERROR_COLOR
if self.use_socket_color:
return self.socket_color
return ct.SOCKET_COLORS[self.socket_type]
####################
# - UI
####################
def draw(
self,
context: bpy.types.Context,
layout: bpy.types.UILayout,
node: bpy.types.Node,
text: str,
) -> None:
"""Draw the socket UI.
- **Input Socket**: Will use `self.draw_input()`.
- **Output Socket**: Will use `self.draw_output()`.
Parameters:
context: The current Blender context.
layout: Target for defining UI elements.
node: The node within which the socket is embedded.
text: The socket's name in the UI.
"""
if self.is_output:
self.draw_output(context, layout, node, text)
else:
self.draw_input(context, layout, node, text)
def draw_prelock(
self,
context: bpy.types.Context,
col: bpy.types.UILayout,
node: bpy.types.Node,
text: str,
) -> None:
"""Draw the "prelock" UI, which is usable regardless of the `self.locked` state.
Notes:
If a "prelock" UI is needed by a socket, it should set `self.use_prelock` and override this method.
Parameters:
context: The current Blender context.
col: Target for defining UI elements.
node: The node within which the socket is embedded.
text: The socket's name in the UI.
"""
####################
# - UI: Input / Output Socket
####################
def draw_input(
self,
context: bpy.types.Context,
layout: bpy.types.UILayout,
node: bpy.types.Node,
text: str,
) -> None:
"""Draw the UI of the input socket.
- **Locked** (`self.locked`): The UI will be unusable.
- **Linked** (`self.is_linked`): Only the socket label will display.
- **Use Prelock** (`self.use_prelock`): The "prelock" UI drawn with `self.draw_prelock()`, which shows **regardless of `self.locked`**.
- **FlowKind**: The `FlowKind`-specific UI corresponding to the current `self.active_kind`.
Notes:
Shouldn't be overridden.
Parameters:
context: The current Blender context.
layout: Target for defining UI elements.
node: The node within which the socket is embedded.
text: The socket's name in the UI.
"""
col = layout.column()
# Row: Label
row = col.row()
row.alignment = 'LEFT'
## Lock Check
if self.locked:
row.enabled = False
## Link Check
if self.is_linked:
self.draw_input_label_row(row, text)
else:
self.draw_label_row(row, text)
# User Prelock Row
row = col.row(align=False)
if self.use_prelock:
_col = row.column(align=False)
_col.enabled = True
self.draw_prelock(context, _col, node, text)
if self.locked:
row = col.row(align=False)
row.enabled = False
elif self.locked:
row.enabled = False
# FlowKind Draw Row
col = row.column(align=True)
{
ct.FlowKind.Capabilities: lambda *_: None,
ct.FlowKind.Previews: lambda *_: None,
ct.FlowKind.Value: self.draw_value,
ct.FlowKind.Array: self.draw_array,
ct.FlowKind.Range: self.draw_lazy_range,
ct.FlowKind.Func: self.draw_lazy_func,
ct.FlowKind.Params: lambda *_: None,
ct.FlowKind.Info: lambda *_: None,
}[self.active_kind](col)
# Info Drawing
if self.use_info_draw:
info = self.compute_data(kind=ct.FlowKind.Info)
if not FS.check(info):
self.draw_info(info, col)
def draw_output(
self,
context: bpy.types.Context, # noqa: ARG002
layout: bpy.types.UILayout,
node: bpy.types.Node, # noqa: ARG002
text: str,
) -> None:
"""Draw the label text on the output socket.
Notes:
Shouldn't be overridden.
Parameters:
context: The current Blender context.
layout: Target for defining UI elements.
node: The node within which the socket is embedded.
text: The socket's name in the UI.
"""
col = layout.column()
# Row: Label
row = col.row()
row.alignment = 'RIGHT'
self.draw_output_label_row(row, text)
# Draw FlowKind.Info related Information
if self.use_info_draw:
info = self.compute_data(kind=ct.FlowKind.Info)
if not FS.check(info):
self.draw_info(info, col)
####################
# - UI Methods: Label Rows
####################
def draw_label_row(
self,
row: bpy.types.UILayout,
text: str,
) -> None:
"""Draw the label row, which is at the same height as the socket shape.
Will only display if the socket is an **unlinked input socket**.
Notes:
Can be overriden by individual socket classes, if they need to alter the way that the label row is drawn.
Parameters:
row: Target for defining UI elements.
text: The socket's name in the UI.
"""
row.label(text=text)
def draw_input_label_row(
self,
row: bpy.types.UILayout,
text: str,
) -> None:
"""Draw the label row, which is at the same height as the socket shape.
Will only display if the socket is a **linked input socket**.
Notes:
Can be overriden by individual socket classes, if they need to alter the way that the label row is drawn.
Parameters:
row: Target for defining UI elements.
text: The socket's name in the UI.
"""
row.label(text=text)
def draw_output_label_row(
self,
row: bpy.types.UILayout,
text: str,
) -> None:
"""Draw the output label row, which is at the same height as the socket shape.
Will only display if the socket is an **output socket**.
Notes:
Can be overriden by individual socket classes, if they need to alter the way that the output label row is drawn.
Parameters:
row: Target for defining UI elements.
text: The socket's name in the UI.
"""
row.label(text=text)
####################
# - UI Methods: Active FlowKind
####################
def draw_value(self, col: bpy.types.UILayout) -> None:
"""Draws the socket value on its own line.
Notes:
Should be overriden by individual socket classes, if they have an editable `FlowKind.Value`.
Parameters:
col: Target for defining UI elements.
"""
def draw_lazy_range(self, col: bpy.types.UILayout) -> None:
"""Draws the socket lazy array range on its own line.
Notes:
Should be overriden by individual socket classes, if they have an editable `FlowKind.Range`.
Parameters:
col: Target for defining UI elements.
"""
def draw_array(self, col: bpy.types.UILayout) -> None:
"""Draws the socket array UI on its own line.
Notes:
Should be overriden by individual socket classes, if they have an editable `FlowKind.Array`.
Parameters:
col: Target for defining UI elements.
"""
def draw_lazy_func(self, col: bpy.types.UILayout) -> None:
"""Draws the socket lazy value function UI on its own line.
Notes:
Should be overriden by individual socket classes, if they have an editable `FlowKind.Func`.
Parameters:
col: Target for defining UI elements.
"""
####################
# - UI Methods: Auxilliary
####################
def draw_info(self, info: ct.InfoFlow, col: bpy.types.UILayout) -> None:
"""Draws the socket info on its own line.
Notes:
Should be overriden by individual socket classes, if they might output a `FlowKind.Info`.
Parameters:
col: Target for defining UI elements.
"""