fix: GN update for custom geonodes

We also implement `BLField` support for Blender `IDStruct` types.

There's a crash haunting us specifically with the cylinder array. Other
primitives (esp. ring) work just fine, as does previews of nested-linked
node groups. The crash triggers specifically when the file is saved and
reloaded, whereafter all the `load_post` handlers run fine (like -
extremely fine, we can perfectly access and dereference all of the node
groups, seemingly all the objects, etc.). Then, crash.

We're also discovering that `id_properties_ui` is completely useless for
after-the-fact updating of custom properties within sockets. Which is a
great surprise, and I have trouble thinking it's on purpose - the data
is stored somewhere, after all. All the forced updates/redraws/etc. in
the world don't seem to change this.

We may have to go back to the drawing board with dynamically-updated
min/max. The entire infrastructure with `SocketDef` altering sockets
after creation is entirely, _violently_ unsuited to do a static
modification. But the bare fact is, the dynamic modification methods are
falling short. It's kind of important stuff, this stuff.
main
Sofus Albert Høgsbro Rose 2024-05-17 12:39:11 +02:00
parent 7ac6b615de
commit 785c6f764c
Signed by: so-rose
GPG Key ID: AD901CB0F3701434
19 changed files with 353 additions and 199 deletions

View File

@ -149,6 +149,7 @@ def manage_pydeps(*_):
# path_addon_pydeps='',
# path_addon_reqs='',
# )
log.debug('PyDeps: Analyzing Post-File Load')
ct.addon.prefs().on_addon_pydeps_changed(show_popup_if_deps_invalid=True)

View File

@ -220,7 +220,6 @@ class GeoNodes(enum.StrEnum):
####################
def import_geonodes(
_geonodes: GeoNodes,
force_append: bool = False,
) -> bpy.types.GeometryNodeGroup:
"""Given vendored GeoNodes tree link/append and return the local datablock.
@ -496,6 +495,7 @@ class GeoNodesToStructureNode(bpy.types.Operator):
)
bpy.ops.node.select_all(action='DESELECT')
node = node_tree.nodes.new(geonodes.dedicated_node_type)
node.sim_node_name = asset.name
node.select = True
node.location.x = node_location[0]
node.location.y = node_location[1]
@ -505,9 +505,9 @@ class GeoNodesToStructureNode(bpy.types.Operator):
## Since the node doesn't itself handle the structure, we must.
## We just import the GN tree, then attach the data block to the node.
if geonodes.dedicated_node_type == 'GeoNodesStructureNodeType':
## TODO: Is this too presumptuous? Or a fine little hack?
geonodes_data = import_geonodes(asset.name)
node.inputs['GeoNodes'].value = geonodes_data
## TODO: Is this too presumptuous? Or a fine little hack?
# Restore the Pre-Modal Mouse Cursor Shape
context.window.cursor_modal_restore()

Binary file not shown.

View File

@ -53,44 +53,44 @@ BLClass: typ.TypeAlias = (
| bpy.types.FileHandler
)
BLIDStruct: typ.TypeAlias = (
bpy.types.Action,
bpy.types.Armature,
bpy.types.Brush,
bpy.types.CacheFile,
bpy.types.Camera,
bpy.types.Collection,
bpy.types.Curve,
bpy.types.Curves,
bpy.types.FreestyleLineStyle,
bpy.types.GreasePencil,
bpy.types.Image,
bpy.types.Key,
bpy.types.Lattice,
bpy.types.Library,
bpy.types.Light,
bpy.types.LightProbe,
bpy.types.Mask,
bpy.types.Material,
bpy.types.Mesh,
bpy.types.MetaBall,
bpy.types.MovieClip,
bpy.types.NodeTree,
bpy.types.Object,
bpy.types.PaintCurve,
bpy.types.Palette,
bpy.types.ParticleSettings,
bpy.types.PointCloud,
bpy.types.Scene,
bpy.types.Screen,
bpy.types.Sound,
bpy.types.Speaker,
bpy.types.Text,
bpy.types.Texture,
bpy.types.VectorFont,
bpy.types.Volume,
bpy.types.WindowManager,
bpy.types.WorkSpace,
bpy.types.World,
bpy.types.Action
| bpy.types.Armature
| bpy.types.Brush
| bpy.types.CacheFile
| bpy.types.Camera
| bpy.types.Collection
| bpy.types.Curve
| bpy.types.Curves
| bpy.types.FreestyleLineStyle
| bpy.types.GreasePencil
| bpy.types.Image
| bpy.types.Key
| bpy.types.Lattice
| bpy.types.Library
| bpy.types.Light
| bpy.types.LightProbe
| bpy.types.Mask
| bpy.types.Material
| bpy.types.Mesh
| bpy.types.MetaBall
| bpy.types.MovieClip
| bpy.types.NodeTree
| bpy.types.Object
| bpy.types.PaintCurve
| bpy.types.Palette
| bpy.types.ParticleSettings
| bpy.types.PointCloud
| bpy.types.Scene
| bpy.types.Screen
| bpy.types.Sound
| bpy.types.Speaker
| bpy.types.Text
| bpy.types.Texture
| bpy.types.VectorFont
| bpy.types.Volume
| bpy.types.WindowManager
| bpy.types.WorkSpace
| bpy.types.World
)
BLKeymapItem: typ.TypeAlias = typ.Any ## TODO: Better Type
BLPropFlag: typ.TypeAlias = typ.Literal[

View File

@ -34,6 +34,9 @@ class OperatorType(enum.StrEnum):
GeoNodesToStructureNode = enum.auto()
# Socket: GeoNodesSocket
SocketGeoNodesReset = enum.auto()
# Socket: Tidy3DCloudTask
SocketCloudAuthenticate = enum.auto()
SocketReloadCloudFolderList = enum.auto()

View File

@ -37,11 +37,41 @@ class BLSocketInfo:
is_preview: bool
socket_type: SocketType | None
size: spux.NumberSize1D | None
mathtype: spux.MathType | None
physical_type: spux.PhysicalType | None
default_value: spux.ScalarUnitlessRealExpr
bl_isocket_identifier: spux.ScalarUnitlessRealExpr
def encode(
self, raw_value: typ.Any, unit_system: spux.UnitSystem | None
) -> typ.Any:
"""Encode a raw value, given a unit system, to be directly writable to a node socket.
This encoded form is also guaranteed to support writing to a node socket via a modifier interface.
"""
# Non-Numerical: Passthrough
if unit_system is None or self.physical_type is None:
return raw_value
# Numerical: Convert to Pure Python Type
if (
unit_system is not None
and self.physical_type is not spux.PhysicalType.NonPhysical
):
unitless_value = spux.scale_to_unit_system(raw_value, unit_system)
elif isinstance(raw_value, spux.SympyType):
unitless_value = spux.sympy_to_python(raw_value)
else:
unitless_value = raw_value
# Coerce int -> float w/Target is Real
## -> The value - modifier - GN path is more strict than properties.
if self.mathtype is spux.MathType.Real and isinstance(unitless_value, int):
return float(unitless_value)
return unitless_value
class BLSocketType(enum.StrEnum):
Virtual = 'NodeSocketVirtual'
@ -85,12 +115,20 @@ class BLSocketType(enum.StrEnum):
def from_bl_isocket(
bl_isocket: bpy.types.NodeTreeInterfaceSocket,
) -> typ.Self:
"""Deduce the exact `BLSocketType` represented by an interface socket.
Interface sockets are an abstraction of what any instance of a particular node tree _will have of input sockets_ once constructed.
"""
return BLSocketType(bl_isocket.bl_socket_idname)
@staticmethod
def info_from_bl_isocket(
bl_isocket: bpy.types.NodeTreeInterfaceSocket,
) -> typ.Self:
"""Deduce all `BLSocketInfo` from an interface socket.
This is a high-level method providing a clean way to chain `BLSocketType.from_bl_isocket()` together with `self.parse()`.
"""
bl_socket_type = BLSocketType.from_bl_isocket(bl_isocket)
if bl_socket_type.has_support:
return bl_socket_type.parse(
@ -103,13 +141,21 @@ class BLSocketType(enum.StrEnum):
####################
@property
def has_support(self) -> bool:
"""Decides whether the current `BLSocketType` is explicitly not supported.
Not all socket types make sense to represent in our node tree.
In general, these should be skipped.
"""
BLST = BLSocketType
return {
# Won't Fix
BLST.Virtual: False,
BLST.Geometry: False,
BLST.Shader: False,
BLST.FloatUnsigned: False,
BLST.IntUnsigned: False,
## TODO
BLST.Menu: False,
}.get(self, True)
@property
@ -132,12 +178,36 @@ class BLSocketType(enum.StrEnum):
ST = SocketType
return {
# Blender
BLST.Image: ST.BlenderImage,
BLST.Material: ST.BlenderMaterial,
BLST.Object: ST.BlenderObject,
BLST.Collection: ST.BlenderCollection,
# Basic
BLST.Bool: ST.Bool,
BLST.Bool: ST.BlenderCollection,
BLST.String: ST.String,
# Float
# Array-Like
BLST.Float: ST.Expr,
BLST.FloatAngle: ST.Expr,
BLST.FloatDistance: ST.Expr,
BLST.FloatFactor: ST.Expr,
BLST.FloatPercentage: ST.Expr,
BLST.FloatTime: ST.Expr,
BLST.FloatTimeAbsolute: ST.Expr,
# Int
BLST.Int: ST.Expr,
BLST.IntFactor: ST.Expr,
BLST.IntPercentage: ST.Expr,
# Vector
BLST.Color: ST.Color,
}.get(self, ST.Expr)
BLST.Rotation: ST.Expr,
BLST.Vector: ST.Expr,
BLST.VectorAcceleration: ST.Expr,
BLST.VectorDirection: ST.Expr,
BLST.VectorEuler: ST.Expr,
BLST.VectorTranslation: ST.Expr,
BLST.VectorVelocity: ST.Expr,
BLST.VectorXYZ: ST.Expr,
}[self]
@property
def mathtype(self) -> spux.MathType | None:
@ -240,7 +310,7 @@ class BLSocketType(enum.StrEnum):
BLST.FloatAngle: P.Angle,
BLST.FloatDistance: P.Length,
BLST.FloatTime: P.Time,
BLST.FloatTimeAbsolute: P.Time, ## What's the difference?
BLST.FloatTimeAbsolute: P.Time, ## TODO: What's the difference?
BLST.VectorAcceleration: P.Accel,
## BLST.VectorDirection: Directions are unitless (within cartesian)
BLST.VectorEuler: P.Angle,
@ -291,8 +361,8 @@ class BLSocketType(enum.StrEnum):
def parse(
self, bl_default_value: typ.Any, description: str, bl_isocket_identifier: str
) -> BLSocketInfo:
# Parse the Description
## TODO: Some kind of error on invalid parse if there is also no unambiguous physical type
# Unpack Description
## -> TODO: Raise an kind of error on invalid parse if there is also no unambiguous physical type
descr_params = description.split(BL_SOCKET_DESCR_ANNOT_STRING)[0]
directive = (
_tokens[0]
@ -300,33 +370,60 @@ class BLSocketType(enum.StrEnum):
else _tokens[1]
)
## Interpret the Description Parse
# Parse PhysicalType
## -> None if there is no appropriate MathType.
## -> Otherwise, prefer unambiguous - description hint - NonPhysical
has_physical_type = self.mathtype in [
spux.MathType.Integer,
spux.MathType.Rational,
spux.MathType.Real,
spux.MathType.Complex,
]
if has_physical_type:
parsed_physical_type = getattr(spux.PhysicalType, directive, None)
physical_type = (
self.unambiguous_physical_type
if self.unambiguous_physical_type is not None
else parsed_physical_type
else (
parsed_physical_type
if parsed_physical_type is not None
else spux.PhysicalType.NonPhysical
)
)
else:
physical_type = None
# Parse the Default Value
# Parse Default Value
## -> Read the Blender socket's default value and convrt it
if self.mathtype is not None and bl_default_value is not None:
# Scalar: Convert to Pure Python TYpe
if self.size == spux.NumberSize1D.Scalar:
default_value = self.mathtype.pytype(bl_default_value)
# 2D (Description Hint): Sympy Matrix
## -> The description hint "2D" is the trigger for this.
## -> Ignore the last component to get the effect of "2D".
elif description.startswith('2D'):
default_value = sp.Matrix(tuple(bl_default_value)[:2])
default_value = sp.ImmutableMatrix(tuple(bl_default_value)[:2])
# 3D/4D: Simple Parse to Sympy Matrix
## -> We don't explicitly check the size.
else:
default_value = sp.Matrix(tuple(bl_default_value))
default_value = sp.ImmutableMatrix(tuple(bl_default_value))
else:
# Non-Mathematical: Passthrough
default_value = bl_default_value
# Return Parsed Socket Information
## -> Combining directly known and parsed knowledge.
## -> Should contain everything needed to match the Blender socket.
## -> Should contain everything needed to create a socket in our tree.
return BLSocketInfo(
has_support=self.has_support,
is_preview=(directive == 'Preview'),
socket_type=self.socket_type,
size=self.size,
mathtype=self.mathtype,
physical_type=physical_type,
default_value=default_value,
bl_isocket_identifier=bl_isocket_identifier,

View File

@ -103,8 +103,8 @@ class ManagedBLMesh(base.ManagedObj):
"""
bl_object = bpy.data.objects.get(self.name)
if bl_object is None:
log.info('%s (ManagedBLMesh): Created BLObject for Preview', bl_object.name)
bl_object = self.bl_object()
log.info('%s (ManagedBLMesh): Created BLObject for Preview', bl_object.name)
if bl_object.name not in preview_collection().objects:
log.info('Moving "%s" to Preview Collection', bl_object.name)

View File

@ -97,6 +97,7 @@ def write_modifier_geonodes(
modifier_altered = False
# Alter GeoNodes Group
## -> Check the existing node group, replace if it differs.
if bl_modifier.node_group != modifier_attrs['node_group']:
log.info(
'Changing GeoNodes Modifier NodeTree from "%s" to "%s"',
@ -106,30 +107,25 @@ def write_modifier_geonodes(
bl_modifier.node_group = modifier_attrs['node_group']
modifier_altered = True
# Alter GeoNodes Modifier Inputs
# Parse GeoNodes Socket Info
## -> TODO: Slow and hard to optimize, but very likely worth it.
socket_infos = bl_socket_map.info_from_geonodes(bl_modifier.node_group)
for socket_name in modifier_attrs['inputs']:
# Retrieve Modifier Interface ID
## -> iface_id translates "modifier socket" to "GN input socket".
iface_id = socket_infos[socket_name].bl_isocket_identifier
input_value = modifier_attrs['inputs'][socket_name]
if modifier_attrs['unit_system'] is not None and not isinstance(
input_value, bool
):
value_to_write = spux.scale_to_unit_system(
input_value, modifier_attrs['unit_system']
# Deduce Value to Write
## -> This may involve a unit system conversion.
## -> Special Case: Booleans do not go through unit conversion.
## -> TODO: A special case isn't clean enough.
bl_modifier[iface_id] = socket_infos[socket_name].encode(
raw_value=modifier_attrs['inputs'][socket_name],
unit_system=modifier_attrs['unit_system'],
)
else:
value_to_write = input_value
# Edge Case: int -> float
if isinstance(bl_modifier[iface_id], float) and isinstance(value_to_write, int):
bl_modifier[iface_id] = float(value_to_write)
else:
bl_modifier[iface_id] = value_to_write
modifier_altered = True
## TODO: More fine-grained alterations
## TODO: More fine-grained alterations?
return modifier_altered # noqa: RET504

View File

@ -435,6 +435,7 @@ def initialize_sim_tree_node_link_cache(_):
"""Whenever a file is loaded, create/regenerate the NodeLinkCache in all trees."""
for node_tree in bpy.data.node_groups:
if node_tree.bl_idname == 'MaxwellSimTree':
log.debug('%s: Initializing NodeLinkCache for NodeTree', str(node_tree))
node_tree.on_load()
@ -450,13 +451,26 @@ def populate_missing_persistence(_) -> None:
for _node_tree in bpy.data.node_groups
if _node_tree.bl_idname == ct.TreeType.MaxwellSim.value and _node_tree.is_active
]:
log.debug(
'%s: Regenerating Dynamic Field Persistance for NodeTree nodes/sockets',
str(node_tree),
)
# Iterate over MaxwellSim Nodes
# -> Excludes ex. frame and reroute nodes.
for node in [_node for _node in node_tree.nodes if hasattr(_node, 'node_type')]:
log.debug(
'-> %s: Regenerating Dynamic Field Persistance for Node',
str(node),
)
node.regenerate_dynamic_field_persistance()
for bl_sockets in [node.inputs, node.outputs]:
for bl_socket in bl_sockets:
log.debug(
'|-> %s: Regenerating Dynamic Field Persistance for Socket',
str(bl_socket),
)
bl_socket.regenerate_dynamic_field_persistance()
log.debug('Regenerated All Dynamic Field Persistance')
####################

View File

@ -737,6 +737,14 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
socket_name: The input socket that was altered, if any, in order to trigger this event.
pop_name: The property that was altered, if any, in order to trigger this event.
"""
# log.debug(
# '%s: Triggered Event %s (socket_name=%s, socket_kinds=%s, prop_name=%s)',
# self.sim_node_name,
# event,
# str(socket_name),
# str(socket_kinds),
# str(prop_name),
# )
# Outflow Socket Kinds
## -> Something has happened!
## -> The effect is yet to be determined...
@ -815,9 +823,9 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
for event_method in triggered_event_methods:
stop_propagation |= event_method.stop_propagation
# log.critical(
# '$[%s] [%s %s %s %s] Running: (%s)',
# '%s: Running %s',
# self.sim_node_name,
# event_method.callback_info,
# str(event_method.callback_info),
# )
event_method(self)

View File

@ -43,8 +43,6 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
'Medium': sockets.MaxwellMediumSocketDef(),
'Center': sockets.ExprSocketDef(
size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_unit=spu.micrometer,
default_value=sp.Matrix([0, 0, 0]),
),
@ -54,7 +52,6 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
}
managed_obj_types: typ.ClassVar = {
'mesh': managed_objs.ManagedBLMesh,
'modifier': managed_objs.ManagedBLModifier,
}
@ -64,7 +61,7 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
@events.computes_output_socket(
'Structure',
input_sockets={'Medium'},
managed_objs={'mesh'},
managed_objs={'modifier'},
)
def compute_structure(
self,
@ -74,7 +71,7 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
"""Computes a triangle-mesh based Tidy3D structure, by manually copying mesh data from Blender to a `td.TriangleMesh`."""
## TODO: mesh_as_arrays might not take the Center into account.
## - Alternatively, Tidy3D might have a way to transform?
mesh_as_arrays = managed_objs['mesh'].mesh_as_arrays
mesh_as_arrays = managed_objs['modifier'].mesh_as_arrays
return td.Structure(
geometry=td.TriangleMesh.from_vertices_faces(
mesh_as_arrays['verts'],
@ -84,71 +81,28 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
)
####################
# - Events: Preview Active Changed
# - UI
####################
@events.on_value_changed(
prop_name='preview_active',
props={'preview_active'},
managed_objs={'mesh'},
)
def on_preview_changed(self, props) -> None:
"""Enables/disables previewing of the GeoNodes-driven mesh, regardless of whether a particular GeoNodes tree is chosen."""
mesh = managed_objs['mesh']
def draw_label(self) -> None:
"""Show the extracted data (if any) in the node's header label.
# Push Preview State to Managed Mesh
if props['preview_active']:
mesh.show_preview()
else:
mesh.hide_preview()
####################
# - Events: GN Input Changed
####################
@events.on_value_changed(
socket_name={'Center'},
any_loose_input_socket=True,
# Pass Data
managed_objs={'mesh', 'modifier'},
input_sockets={'Center', 'GeoNodes'},
all_loose_input_sockets=True,
unit_systems={'BlenderUnits': ct.UNITS_BLENDER},
scale_input_sockets={'Center': 'BlenderUnits'},
)
def on_input_socket_changed(
self, input_sockets, loose_input_sockets, unit_systems
) -> None:
"""Pushes any change in GeoNodes-bound input sockets to the GeoNodes modifier.
Also pushes the `Center:Value` socket to govern the object's center in 3D space.
Notes:
Called by Blender to determine the text to place in the node's header.
"""
geonodes = input_sockets['GeoNodes']
has_geonodes = not ct.FlowSignal.check(geonodes)
geonodes = self._compute_input('GeoNodes')
if geonodes is None:
return self.bl_label
if has_geonodes:
mesh = managed_objs['mesh']
modifier = managed_objs['modifier']
center = input_sockets['Center']
unit_system = unit_systems['BlenderUnits']
# Push Loose Input Values to GeoNodes Modifier
modifier.bl_modifier(
mesh.bl_object(location=center),
'NODES',
{
'node_group': geonodes,
'inputs': loose_input_sockets,
'unit_system': unit_system,
},
)
return f'Structure: {self.sim_node_name}'
####################
# - Events: GN Tree Changed
# - Events: Swapped GN Node Tree
####################
@events.on_value_changed(
socket_name={'GeoNodes'},
# Pass Data
managed_objs={'mesh', 'modifier'},
input_sockets={'GeoNodes', 'Center'},
# Loaded
managed_objs={'modifier'},
input_sockets={'GeoNodes'},
)
def on_input_changed(
self,
@ -157,12 +111,9 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
) -> None:
"""Declares new loose input sockets in response to a new GeoNodes tree (if any)."""
geonodes = input_sockets['GeoNodes']
has_geonodes = not ct.FlowSignal.check(geonodes)
has_geonodes = not ct.FlowSignal.check(geonodes) and geonodes is not None
if has_geonodes:
mesh = managed_objs['mesh']
modifier = managed_objs['modifier']
# Fill the Loose Input Sockets
## -> The SocketDefs contain the default values from the interface.
log.info(
@ -176,9 +127,59 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
elif self.loose_input_sockets:
self.loose_input_sockets = {}
managed_objs['modifier'].free()
if modifier.name in mesh.bl_object().modifiers.keys().copy():
modifier.free_from_bl_object(mesh.bl_object())
####################
# - Events: Preview
####################
@events.on_value_changed(
# Trigger
prop_name='preview_active',
# Loaded
managed_objs={'modifier'},
props={'preview_active'},
)
def on_preview_changed(self, managed_objs, props):
if props['preview_active']:
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
@events.on_value_changed(
# Trigger
socket_name={'Center', 'GeoNodes'}, ## MUST run after on_input_changed
any_loose_input_socket=True,
# Loaded
managed_objs={'modifier'},
input_sockets={'Center', 'GeoNodes'},
all_loose_input_sockets=True,
unit_systems={'BlenderUnits': ct.UNITS_BLENDER},
scale_input_sockets={'Center': 'BlenderUnits'},
)
def on_input_socket_changed(
self, managed_objs, input_sockets, loose_input_sockets, unit_systems
) -> None:
"""Pushes any change in GeoNodes-bound input sockets to the GeoNodes modifier.
Warnings:
MUST be placed lower than `on_input_changed`, so it runs afterwards whenever the `GeoNodes` tree is changed.
Also pushes the `Center:Value` socket to govern the object's center in 3D space.
"""
geonodes = input_sockets['GeoNodes']
has_geonodes = not ct.FlowSignal.check(geonodes) and geonodes is not None
if has_geonodes:
# Push Loose Input Values to GeoNodes Modifier
managed_objs['modifier'].bl_modifier(
'NODES',
{
'node_group': geonodes,
'inputs': loose_input_sockets,
'unit_system': unit_systems['BlenderUnits'],
},
location=input_sockets['Center'],
)
####################

View File

@ -83,7 +83,7 @@ class BoxStructureNode(base.MaxwellSimNode):
)
####################
# - Preview
# - Events: Preview
####################
@events.on_value_changed(
# Trigger

View File

@ -14,30 +14,40 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import typing as typ
import bpy
import sympy as sp
import sympy.physics.units as spu
import tidy3d as td
from blender_maxwell.assets.geonodes import GeoNodes, import_geonodes
from blender_maxwell.utils import bl_cache, logger
from blender_maxwell.utils import extra_sympy_units as spux
from ... import contracts as ct
from .. import base
log = logger.get(__name__)
####################
# - Operators
####################
class BlenderMaxwellResetGeoNodesSocket(bpy.types.Operator):
bl_idname = 'blender_maxwell.reset_geo_nodes_socket'
bl_label = 'Reset GeoNodes Socket'
"""Simulate a change to the geometry nodes group of the attached `GeoNodes` socket.
node_tree_name: bpy.props.StringProperty(name='Node Tree Name')
node_name: bpy.props.StringProperty(name='Node Name')
socket_name: bpy.props.StringProperty(name='Socket Name')
This causes updates to the GN group (ex. internal logic) to be immediately caught.
"""
bl_idname = ct.OperatorType.SocketGeoNodesReset
bl_label = 'Reset GeoNodes Group'
def execute(self, context):
node_tree = bpy.data.node_groups[self.node_tree_name]
node = node_tree.nodes[self.node_name]
socket = node.inputs[self.socket_name]
bl_socket = context.socket
# Report as though the GeoNodes Tree Changed
socket.on_prop_changed('raw_value', context)
bl_socket.on_prop_changed('raw_value', context)
return {'FINISHED'}
@ -52,35 +62,18 @@ class BlenderGeoNodesBLSocket(base.MaxwellSimSocket):
####################
# - Properties
####################
raw_value: bpy.props.PointerProperty(
name='Blender GeoNodes Tree',
description='Represents a Blender GeoNodes Tree',
type=bpy.types.NodeTree,
poll=(lambda self, obj: obj.bl_idname == 'GeometryNodeTree'),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
raw_value: bpy.types.NodeTree = bl_cache.BLField(
bltype_poll=lambda self, obj: self.filter_gn_trees(obj)
)
####################
# - UI
####################
# def draw_label_row(self, label_col_row, text):
# label_col_row.label(text=text)
# if not self.raw_value: return
#
# op = label_col_row.operator(
# BlenderMaxwellResetGeoNodesSocket.bl_idname,
# text="",
# icon="FILE_REFRESH",
# )
# op.socket_name = self.name
# op.node_name = self.node.name
# op.node_tree_name = self.node.id_data.name
def filter_gn_trees(self, obj: ct.BLIDStruct) -> bool:
return obj.bl_idname == 'GeometryNodeTree' and not obj.name.startswith('_')
####################
# - UI
####################
def draw_value(self, col: bpy.types.UILayout) -> None:
col.prop(self, 'raw_value', text='')
col.prop(self, self.blfields['raw_value'], text='')
####################
# - Default Value

View File

@ -36,6 +36,7 @@ StringPropSubType: typ.TypeAlias = typ.Literal[
'FILE_PATH', 'DIR_PATH', 'FILE_NAME', 'BYTE_STRING', 'PASSWORD', 'NONE'
]
PollMethod: typ.TypeAlias = typ.Callable[[bl_instance.BLInstance, typ.Any], bool]
StrMethod: typ.TypeAlias = typ.Callable[
[bl_instance.BLInstance, bpy.types.Context, str], list[tuple[str, str]]
]
@ -72,8 +73,7 @@ class BLField:
float_prec: int | None = None,
str_secret: bool | None = None,
path_type: typ.Literal['dir', 'file'] | None = None,
# blptr_type: typ.Any | None = None, ## A Blender ID type
## TODO: Test/Implement
bltype_poll: PollMethod | None = None,
## Dynamic
str_cb: StrMethod | None = None,
enum_cb: EnumMethod | None = None,
@ -110,6 +110,10 @@ class BLField:
Only meaningful for `pathlib.Path` properties.
**NOTE**: No effort is made to make paths portable between operating systems.
Use with care.
bltype_poll: User-provided method for filtering selectable datablocks.
Whenever the annotated type is `ct.BLIDStruct`, it may be desirable to constrain which datablocks the user should be able to select.
This allows doing so.
**NOTE**: The method will be run very often, and must therefore be cheap.
str_cb: Method used to determine all valid strings, which presents to the user as a fuzzy-style search dropdown.
Only meaningful for `str` properties.
Results are not persisted, and must therefore re-run when reloading the file.
@ -121,12 +125,6 @@ class BLField:
cb_depends_on: Declares that `str_cb` / `enum_cb` should be regenerated whenever any of the given property names change.
This allows fully automating the invocation of `Signal.ResetEnumItems` / `Signal.ResetStrSearch` in common cases.
"""
log.debug(
'Initializing BLField (default_value=%s, use_prop_update=%s)',
str(default_value),
str(use_prop_update),
)
self.use_dynamic_enum = enum_cb is not None
self.use_str_search = str_cb is not None
@ -143,7 +141,7 @@ class BLField:
'step': float_step,
'precision': float_prec,
# BLPointer: ID Type
#'blptr_type': blptr_type,
'bltype_poll': bltype_poll,
# Str | Path | Enum: Flag Setters
'str_secret': str_secret,
'path_type': path_type,
@ -222,6 +220,14 @@ class BLField:
)
self.bl_prop_str_search.init_bl_type(owner)
log.debug(
'Initialized BLField "%s" (prop_type=%s, type=%s, default_value=%s)',
str(self.bl_prop.name),
str(self.bl_prop.bl_prop_type),
str(self.bl_prop.prop_type),
str(self.bl_prop.default_value),
)
def __get__(
self,
bl_instance: bl_instance.BLInstance | None,
@ -290,6 +296,9 @@ class BLField:
# Reset Enum Items
elif value is Signal.ResetEnumItems:
if self.bl_prop_enum_items is None:
return
# Retrieve Old Items
## -> This is verbatim what is being persisted, currently.
## -> len(0): Manually replaced w/fallback to guarantee >=len(1)
@ -356,6 +365,9 @@ class BLField:
# Reset Str Search
elif value is Signal.ResetStrSearch:
if self.bl_prop_str_search is None:
return
self.bl_prop_str_search.invalidate_nonpersist(bl_instance)
# General __set__

View File

@ -173,6 +173,10 @@ class BLProp:
def read_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> typ.Any:
"""Read the non-persistent cache value for this property.
Notes:
**Never reads cached BLPointers**; invokes `self.read()` instead.
`BLPointer`s must align perfectly with Blender's internal logic, and as such, the cache cannot get involved, else we risk access-after-free crashes.
Returns:
Generally, the cache value, with two exceptions.
@ -184,6 +188,8 @@ class BLProp:
- `Signal.CacheEmpty`: When the cache has no entry.
A good idea might be to fill it immediately with `self.write_nonpersist(bl_instance)`.
"""
if self.bl_prop_type is BLPropType.BLPointer:
return self.read(bl_instance)
return managed_cache.read(
bl_instance,
self.bl_name,
@ -215,11 +221,17 @@ class BLProp:
use_nonpersist=False,
use_persist=True,
)
if self.bl_prop_type is BLPropType.BLPointer:
return
self.write_nonpersist(bl_instance, value)
def write_nonpersist(
self, bl_instance: bl_instance.BLInstance, value: typ.Any
) -> None:
if self.bl_prop_type is BLPropType.BLPointer:
return ## We can't always write here, so we can only do nothing.
managed_cache.write(
bl_instance,
self.bl_name,
@ -229,6 +241,9 @@ class BLProp:
)
def invalidate_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> None:
if self.bl_prop_type is BLPropType.BLPointer:
return
managed_cache.invalidate_nonpersist(
bl_instance,
self.bl_name,

View File

@ -82,6 +82,10 @@ def _is_strenum(T: type) -> bool: # noqa: N803
return inspect.isclass(T) and issubclass(T, enum.StrEnum)
def _is_bl_id_struct(T: type) -> bool: # noqa: N803
return T in BLIDStructs
####################
# - Blender Property Type
####################
@ -323,7 +327,7 @@ class BLPropType(enum.StrEnum):
BPT.SingleDynEnum: ['enum_dynamic'],
BPT.SetDynEnum: ['enum_dynamic'],
# Special
BPT.BLPointer: ['blptr_type'],
BPT.BLPointer: [],
BPT.Serialized: [],
}[self]
@ -365,6 +369,12 @@ class BLPropType(enum.StrEnum):
# Define Information -> KWArg Getter
def g_kwarg(name: str, force_key: str | None = None):
"""Retrieve a dictionary mapping a name to its `prop_info[name]` value.
If `name` is not defined in `prop_info`, then return an empty dictionary.
If `force_key` is set, then use it as to override `name` as the key when returning the dictionary.
"""
key = force_key if force_key is not None else name
return {key: prop_info[name]} if prop_info.get(name) is not None else {}
@ -482,10 +492,13 @@ class BLPropType(enum.StrEnum):
# BLPointer
case BPT.BLPointer:
kwargs |= encoded_default
# BLPointer: ID Type
kwargs |= g_kwarg('blptr_type', force_key='type')
## -> This is the type of datablock that will be selectable.
kwargs |= {'type': obj_type}
# BLPointer: Poll Method
## -> This allows the user filter selectable datablocks.
kwargs |= g_kwarg('bltype_poll', force_key='poll')
# BLPointer
case BPT.Serialized:
@ -573,7 +586,7 @@ class BLPropType(enum.StrEnum):
return {str(v) for v in value}
# BLPointer: Don't Alter
case BPT.BLPointer if value in BLIDStructs or value is None:
case BPT.BLPointer:
return value
# Serialized: Serialize To UTF-8
@ -662,7 +675,7 @@ class BLPropType(enum.StrEnum):
# BLPointer
## -> None is always valid when it comes to BLPointers.
case BPT.BLPointer if raw_value in BLIDStructs or raw_value is None:
case BPT.BLPointer:
return raw_value
# Serialized: Deserialize the Argument
@ -748,7 +761,7 @@ class BLPropType(enum.StrEnum):
return BPT.SetEnum
# Match BLPointers
if obj_type in BLIDStructs:
if _is_bl_id_struct(obj_type):
return BPT.BLPointer
# Fallback: Serializable Object

View File

@ -1285,6 +1285,7 @@ UnitSystem: typ.TypeAlias = dict[PhysicalType, Unit]
_PT = PhysicalType
UNITS_SI: UnitSystem = {
_PT.NonPhysical: None,
# Global
_PT.Time: spu.second,
_PT.Angle: spu.radian,
@ -1396,7 +1397,7 @@ def strip_unit_system(sp_obj: SympyExpr, unit_system: UnitSystem) -> SympyExpr:
Notes:
You should probably use `scale_to_unit_system()` or `convert_to_unit_system()`.
"""
return sp_obj.subs({unit: 1 for unit in unit_system.values()})
return sp_obj.subs({unit: 1 for unit in unit_system.values() if unit is not None})
def scale_to_unit_system(