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_pydeps='',
# path_addon_reqs='', # path_addon_reqs='',
# ) # )
log.debug('PyDeps: Analyzing Post-File Load')
ct.addon.prefs().on_addon_pydeps_changed(show_popup_if_deps_invalid=True) 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( def import_geonodes(
_geonodes: GeoNodes, _geonodes: GeoNodes,
force_append: bool = False,
) -> bpy.types.GeometryNodeGroup: ) -> bpy.types.GeometryNodeGroup:
"""Given vendored GeoNodes tree link/append and return the local datablock. """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') bpy.ops.node.select_all(action='DESELECT')
node = node_tree.nodes.new(geonodes.dedicated_node_type) node = node_tree.nodes.new(geonodes.dedicated_node_type)
node.sim_node_name = asset.name
node.select = True node.select = True
node.location.x = node_location[0] node.location.x = node_location[0]
node.location.y = node_location[1] 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. ## 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. ## We just import the GN tree, then attach the data block to the node.
if geonodes.dedicated_node_type == 'GeoNodesStructureNodeType': if geonodes.dedicated_node_type == 'GeoNodesStructureNodeType':
## TODO: Is this too presumptuous? Or a fine little hack?
geonodes_data = import_geonodes(asset.name) geonodes_data = import_geonodes(asset.name)
node.inputs['GeoNodes'].value = geonodes_data node.inputs['GeoNodes'].value = geonodes_data
## TODO: Is this too presumptuous? Or a fine little hack?
# Restore the Pre-Modal Mouse Cursor Shape # Restore the Pre-Modal Mouse Cursor Shape
context.window.cursor_modal_restore() context.window.cursor_modal_restore()

Binary file not shown.

View File

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

View File

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

View File

@ -37,11 +37,41 @@ class BLSocketInfo:
is_preview: bool is_preview: bool
socket_type: SocketType | None socket_type: SocketType | None
size: spux.NumberSize1D | None size: spux.NumberSize1D | None
mathtype: spux.MathType | None
physical_type: spux.PhysicalType | None physical_type: spux.PhysicalType | None
default_value: spux.ScalarUnitlessRealExpr default_value: spux.ScalarUnitlessRealExpr
bl_isocket_identifier: 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): class BLSocketType(enum.StrEnum):
Virtual = 'NodeSocketVirtual' Virtual = 'NodeSocketVirtual'
@ -85,12 +115,20 @@ class BLSocketType(enum.StrEnum):
def from_bl_isocket( def from_bl_isocket(
bl_isocket: bpy.types.NodeTreeInterfaceSocket, bl_isocket: bpy.types.NodeTreeInterfaceSocket,
) -> typ.Self: ) -> 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) return BLSocketType(bl_isocket.bl_socket_idname)
@staticmethod @staticmethod
def info_from_bl_isocket( def info_from_bl_isocket(
bl_isocket: bpy.types.NodeTreeInterfaceSocket, bl_isocket: bpy.types.NodeTreeInterfaceSocket,
) -> typ.Self: ) -> 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) bl_socket_type = BLSocketType.from_bl_isocket(bl_isocket)
if bl_socket_type.has_support: if bl_socket_type.has_support:
return bl_socket_type.parse( return bl_socket_type.parse(
@ -103,13 +141,21 @@ class BLSocketType(enum.StrEnum):
#################### ####################
@property @property
def has_support(self) -> bool: 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 BLST = BLSocketType
return { return {
# Won't Fix
BLST.Virtual: False, BLST.Virtual: False,
BLST.Geometry: False, BLST.Geometry: False,
BLST.Shader: False, BLST.Shader: False,
BLST.FloatUnsigned: False, BLST.FloatUnsigned: False,
BLST.IntUnsigned: False, BLST.IntUnsigned: False,
## TODO
BLST.Menu: False,
}.get(self, True) }.get(self, True)
@property @property
@ -132,12 +178,36 @@ class BLSocketType(enum.StrEnum):
ST = SocketType ST = SocketType
return { return {
# Blender # Blender
BLST.Image: ST.BlenderImage,
BLST.Material: ST.BlenderMaterial,
BLST.Object: ST.BlenderObject,
BLST.Collection: ST.BlenderCollection,
# Basic # Basic
BLST.Bool: ST.Bool, BLST.Bool: ST.BlenderCollection,
BLST.String: ST.String,
# Float # 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, 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 @property
def mathtype(self) -> spux.MathType | None: def mathtype(self) -> spux.MathType | None:
@ -240,7 +310,7 @@ class BLSocketType(enum.StrEnum):
BLST.FloatAngle: P.Angle, BLST.FloatAngle: P.Angle,
BLST.FloatDistance: P.Length, BLST.FloatDistance: P.Length,
BLST.FloatTime: P.Time, 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.VectorAcceleration: P.Accel,
## BLST.VectorDirection: Directions are unitless (within cartesian) ## BLST.VectorDirection: Directions are unitless (within cartesian)
BLST.VectorEuler: P.Angle, BLST.VectorEuler: P.Angle,
@ -291,8 +361,8 @@ class BLSocketType(enum.StrEnum):
def parse( def parse(
self, bl_default_value: typ.Any, description: str, bl_isocket_identifier: str self, bl_default_value: typ.Any, description: str, bl_isocket_identifier: str
) -> BLSocketInfo: ) -> BLSocketInfo:
# Parse the Description # Unpack Description
## TODO: Some kind of error on invalid parse if there is also no unambiguous physical type ## -> 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] descr_params = description.split(BL_SOCKET_DESCR_ANNOT_STRING)[0]
directive = ( directive = (
_tokens[0] _tokens[0]
@ -300,33 +370,60 @@ class BLSocketType(enum.StrEnum):
else _tokens[1] else _tokens[1]
) )
## Interpret the Description Parse # Parse PhysicalType
parsed_physical_type = getattr(spux.PhysicalType, directive, None) ## -> None if there is no appropriate MathType.
physical_type = ( ## -> Otherwise, prefer unambiguous - description hint - NonPhysical
self.unambiguous_physical_type has_physical_type = self.mathtype in [
if self.unambiguous_physical_type is not None spux.MathType.Integer,
else parsed_physical_type 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
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: 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: if self.size == spux.NumberSize1D.Scalar:
default_value = self.mathtype.pytype(bl_default_value) 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'): 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: else:
default_value = sp.Matrix(tuple(bl_default_value)) default_value = sp.ImmutableMatrix(tuple(bl_default_value))
else: else:
# Non-Mathematical: Passthrough
default_value = bl_default_value default_value = bl_default_value
# Return Parsed Socket Information # Return Parsed Socket Information
## -> Combining directly known and parsed knowledge. ## -> 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( return BLSocketInfo(
has_support=self.has_support, has_support=self.has_support,
is_preview=(directive == 'Preview'), is_preview=(directive == 'Preview'),
socket_type=self.socket_type, socket_type=self.socket_type,
size=self.size, size=self.size,
mathtype=self.mathtype,
physical_type=physical_type, physical_type=physical_type,
default_value=default_value, default_value=default_value,
bl_isocket_identifier=bl_isocket_identifier, bl_isocket_identifier=bl_isocket_identifier,

View File

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

View File

@ -97,6 +97,7 @@ def write_modifier_geonodes(
modifier_altered = False modifier_altered = False
# Alter GeoNodes Group # Alter GeoNodes Group
## -> Check the existing node group, replace if it differs.
if bl_modifier.node_group != modifier_attrs['node_group']: if bl_modifier.node_group != modifier_attrs['node_group']:
log.info( log.info(
'Changing GeoNodes Modifier NodeTree from "%s" to "%s"', 'Changing GeoNodes Modifier NodeTree from "%s" to "%s"',
@ -106,30 +107,25 @@ def write_modifier_geonodes(
bl_modifier.node_group = modifier_attrs['node_group'] bl_modifier.node_group = modifier_attrs['node_group']
modifier_altered = True 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) socket_infos = bl_socket_map.info_from_geonodes(bl_modifier.node_group)
for socket_name in modifier_attrs['inputs']: 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 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( # Deduce Value to Write
input_value, bool ## -> This may involve a unit system conversion.
): ## -> Special Case: Booleans do not go through unit conversion.
value_to_write = spux.scale_to_unit_system( ## -> TODO: A special case isn't clean enough.
input_value, modifier_attrs['unit_system'] bl_modifier[iface_id] = socket_infos[socket_name].encode(
) raw_value=modifier_attrs['inputs'][socket_name],
else: unit_system=modifier_attrs['unit_system'],
value_to_write = input_value )
modifier_altered = True
# Edge Case: int -> float ## TODO: More fine-grained alterations?
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
return modifier_altered # noqa: RET504 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.""" """Whenever a file is loaded, create/regenerate the NodeLinkCache in all trees."""
for node_tree in bpy.data.node_groups: for node_tree in bpy.data.node_groups:
if node_tree.bl_idname == 'MaxwellSimTree': if node_tree.bl_idname == 'MaxwellSimTree':
log.debug('%s: Initializing NodeLinkCache for NodeTree', str(node_tree))
node_tree.on_load() node_tree.on_load()
@ -450,13 +451,26 @@ def populate_missing_persistence(_) -> None:
for _node_tree in bpy.data.node_groups for _node_tree in bpy.data.node_groups
if _node_tree.bl_idname == ct.TreeType.MaxwellSim.value and _node_tree.is_active 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 # Iterate over MaxwellSim Nodes
# -> Excludes ex. frame and reroute nodes. # -> Excludes ex. frame and reroute nodes.
for node in [_node for _node in node_tree.nodes if hasattr(_node, 'node_type')]: 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() node.regenerate_dynamic_field_persistance()
for bl_sockets in [node.inputs, node.outputs]: for bl_sockets in [node.inputs, node.outputs]:
for bl_socket in bl_sockets: for bl_socket in bl_sockets:
log.debug(
'|-> %s: Regenerating Dynamic Field Persistance for Socket',
str(bl_socket),
)
bl_socket.regenerate_dynamic_field_persistance() 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. 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. 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 # Outflow Socket Kinds
## -> Something has happened! ## -> Something has happened!
## -> The effect is yet to be determined... ## -> 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: for event_method in triggered_event_methods:
stop_propagation |= event_method.stop_propagation stop_propagation |= event_method.stop_propagation
# log.critical( # log.critical(
# '$[%s] [%s %s %s %s] Running: (%s)', # '%s: Running %s',
# self.sim_node_name, # self.sim_node_name,
# event_method.callback_info, # str(event_method.callback_info),
# ) # )
event_method(self) event_method(self)

View File

@ -43,8 +43,6 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
'Medium': sockets.MaxwellMediumSocketDef(), 'Medium': sockets.MaxwellMediumSocketDef(),
'Center': sockets.ExprSocketDef( 'Center': sockets.ExprSocketDef(
size=spux.NumberSize1D.Vec3, size=spux.NumberSize1D.Vec3,
mathtype=spux.MathType.Real,
physical_type=spux.PhysicalType.Length,
default_unit=spu.micrometer, default_unit=spu.micrometer,
default_value=sp.Matrix([0, 0, 0]), default_value=sp.Matrix([0, 0, 0]),
), ),
@ -54,7 +52,6 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
} }
managed_obj_types: typ.ClassVar = { managed_obj_types: typ.ClassVar = {
'mesh': managed_objs.ManagedBLMesh,
'modifier': managed_objs.ManagedBLModifier, 'modifier': managed_objs.ManagedBLModifier,
} }
@ -64,7 +61,7 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
@events.computes_output_socket( @events.computes_output_socket(
'Structure', 'Structure',
input_sockets={'Medium'}, input_sockets={'Medium'},
managed_objs={'mesh'}, managed_objs={'modifier'},
) )
def compute_structure( def compute_structure(
self, 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`.""" """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. ## TODO: mesh_as_arrays might not take the Center into account.
## - Alternatively, Tidy3D might have a way to transform? ## - 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( return td.Structure(
geometry=td.TriangleMesh.from_vertices_faces( geometry=td.TriangleMesh.from_vertices_faces(
mesh_as_arrays['verts'], mesh_as_arrays['verts'],
@ -84,71 +81,28 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
) )
#################### ####################
# - Events: Preview Active Changed # - UI
#################### ####################
@events.on_value_changed( def draw_label(self) -> None:
prop_name='preview_active', """Show the extracted data (if any) in the node's header label.
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']
# Push Preview State to Managed Mesh Notes:
if props['preview_active']: Called by Blender to determine the text to place in the node's header.
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.
""" """
geonodes = input_sockets['GeoNodes'] geonodes = self._compute_input('GeoNodes')
has_geonodes = not ct.FlowSignal.check(geonodes) if geonodes is None:
return self.bl_label
if has_geonodes: return f'Structure: {self.sim_node_name}'
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,
},
)
#################### ####################
# - Events: GN Tree Changed # - Events: Swapped GN Node Tree
#################### ####################
@events.on_value_changed( @events.on_value_changed(
socket_name={'GeoNodes'}, socket_name={'GeoNodes'},
# Pass Data # Loaded
managed_objs={'mesh', 'modifier'}, managed_objs={'modifier'},
input_sockets={'GeoNodes', 'Center'}, input_sockets={'GeoNodes'},
) )
def on_input_changed( def on_input_changed(
self, self,
@ -157,12 +111,9 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
) -> None: ) -> None:
"""Declares new loose input sockets in response to a new GeoNodes tree (if any).""" """Declares new loose input sockets in response to a new GeoNodes tree (if any)."""
geonodes = input_sockets['GeoNodes'] 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: if has_geonodes:
mesh = managed_objs['mesh']
modifier = managed_objs['modifier']
# Fill the Loose Input Sockets # Fill the Loose Input Sockets
## -> The SocketDefs contain the default values from the interface. ## -> The SocketDefs contain the default values from the interface.
log.info( log.info(
@ -176,9 +127,59 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
elif self.loose_input_sockets: elif self.loose_input_sockets:
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( @events.on_value_changed(
# Trigger # Trigger

View File

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

View File

@ -36,6 +36,7 @@ StringPropSubType: typ.TypeAlias = typ.Literal[
'FILE_PATH', 'DIR_PATH', 'FILE_NAME', 'BYTE_STRING', 'PASSWORD', 'NONE' '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[ StrMethod: typ.TypeAlias = typ.Callable[
[bl_instance.BLInstance, bpy.types.Context, str], list[tuple[str, str]] [bl_instance.BLInstance, bpy.types.Context, str], list[tuple[str, str]]
] ]
@ -72,8 +73,7 @@ class BLField:
float_prec: int | None = None, float_prec: int | None = None,
str_secret: bool | None = None, str_secret: bool | None = None,
path_type: typ.Literal['dir', 'file'] | None = None, path_type: typ.Literal['dir', 'file'] | None = None,
# blptr_type: typ.Any | None = None, ## A Blender ID type bltype_poll: PollMethod | None = None,
## TODO: Test/Implement
## Dynamic ## Dynamic
str_cb: StrMethod | None = None, str_cb: StrMethod | None = None,
enum_cb: EnumMethod | None = None, enum_cb: EnumMethod | None = None,
@ -110,6 +110,10 @@ class BLField:
Only meaningful for `pathlib.Path` properties. Only meaningful for `pathlib.Path` properties.
**NOTE**: No effort is made to make paths portable between operating systems. **NOTE**: No effort is made to make paths portable between operating systems.
Use with care. 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. 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. Only meaningful for `str` properties.
Results are not persisted, and must therefore re-run when reloading the file. 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. 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. 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_dynamic_enum = enum_cb is not None
self.use_str_search = str_cb is not None self.use_str_search = str_cb is not None
@ -143,7 +141,7 @@ class BLField:
'step': float_step, 'step': float_step,
'precision': float_prec, 'precision': float_prec,
# BLPointer: ID Type # BLPointer: ID Type
#'blptr_type': blptr_type, 'bltype_poll': bltype_poll,
# Str | Path | Enum: Flag Setters # Str | Path | Enum: Flag Setters
'str_secret': str_secret, 'str_secret': str_secret,
'path_type': path_type, 'path_type': path_type,
@ -222,6 +220,14 @@ class BLField:
) )
self.bl_prop_str_search.init_bl_type(owner) 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__( def __get__(
self, self,
bl_instance: bl_instance.BLInstance | None, bl_instance: bl_instance.BLInstance | None,
@ -290,6 +296,9 @@ class BLField:
# Reset Enum Items # Reset Enum Items
elif value is Signal.ResetEnumItems: elif value is Signal.ResetEnumItems:
if self.bl_prop_enum_items is None:
return
# Retrieve Old Items # Retrieve Old Items
## -> This is verbatim what is being persisted, currently. ## -> This is verbatim what is being persisted, currently.
## -> len(0): Manually replaced w/fallback to guarantee >=len(1) ## -> len(0): Manually replaced w/fallback to guarantee >=len(1)
@ -356,6 +365,9 @@ class BLField:
# Reset Str Search # Reset Str Search
elif value is Signal.ResetStrSearch: elif value is Signal.ResetStrSearch:
if self.bl_prop_str_search is None:
return
self.bl_prop_str_search.invalidate_nonpersist(bl_instance) self.bl_prop_str_search.invalidate_nonpersist(bl_instance)
# General __set__ # General __set__

View File

@ -173,6 +173,10 @@ class BLProp:
def read_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> typ.Any: def read_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> typ.Any:
"""Read the non-persistent cache value for this property. """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: Returns:
Generally, the cache value, with two exceptions. Generally, the cache value, with two exceptions.
@ -184,6 +188,8 @@ class BLProp:
- `Signal.CacheEmpty`: When the cache has no entry. - `Signal.CacheEmpty`: When the cache has no entry.
A good idea might be to fill it immediately with `self.write_nonpersist(bl_instance)`. 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( return managed_cache.read(
bl_instance, bl_instance,
self.bl_name, self.bl_name,
@ -215,11 +221,17 @@ class BLProp:
use_nonpersist=False, use_nonpersist=False,
use_persist=True, use_persist=True,
) )
if self.bl_prop_type is BLPropType.BLPointer:
return
self.write_nonpersist(bl_instance, value) self.write_nonpersist(bl_instance, value)
def write_nonpersist( def write_nonpersist(
self, bl_instance: bl_instance.BLInstance, value: typ.Any self, bl_instance: bl_instance.BLInstance, value: typ.Any
) -> None: ) -> 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( managed_cache.write(
bl_instance, bl_instance,
self.bl_name, self.bl_name,
@ -229,6 +241,9 @@ class BLProp:
) )
def invalidate_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> None: def invalidate_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> None:
if self.bl_prop_type is BLPropType.BLPointer:
return
managed_cache.invalidate_nonpersist( managed_cache.invalidate_nonpersist(
bl_instance, bl_instance,
self.bl_name, 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) 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 # - Blender Property Type
#################### ####################
@ -323,7 +327,7 @@ class BLPropType(enum.StrEnum):
BPT.SingleDynEnum: ['enum_dynamic'], BPT.SingleDynEnum: ['enum_dynamic'],
BPT.SetDynEnum: ['enum_dynamic'], BPT.SetDynEnum: ['enum_dynamic'],
# Special # Special
BPT.BLPointer: ['blptr_type'], BPT.BLPointer: [],
BPT.Serialized: [], BPT.Serialized: [],
}[self] }[self]
@ -365,6 +369,12 @@ class BLPropType(enum.StrEnum):
# Define Information -> KWArg Getter # Define Information -> KWArg Getter
def g_kwarg(name: str, force_key: str | None = None): 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 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 {} return {key: prop_info[name]} if prop_info.get(name) is not None else {}
@ -482,10 +492,13 @@ class BLPropType(enum.StrEnum):
# BLPointer # BLPointer
case BPT.BLPointer: case BPT.BLPointer:
kwargs |= encoded_default
# BLPointer: ID Type # 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 # BLPointer
case BPT.Serialized: case BPT.Serialized:
@ -573,7 +586,7 @@ class BLPropType(enum.StrEnum):
return {str(v) for v in value} return {str(v) for v in value}
# BLPointer: Don't Alter # BLPointer: Don't Alter
case BPT.BLPointer if value in BLIDStructs or value is None: case BPT.BLPointer:
return value return value
# Serialized: Serialize To UTF-8 # Serialized: Serialize To UTF-8
@ -662,7 +675,7 @@ class BLPropType(enum.StrEnum):
# BLPointer # BLPointer
## -> None is always valid when it comes to BLPointers. ## -> 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 return raw_value
# Serialized: Deserialize the Argument # Serialized: Deserialize the Argument
@ -748,7 +761,7 @@ class BLPropType(enum.StrEnum):
return BPT.SetEnum return BPT.SetEnum
# Match BLPointers # Match BLPointers
if obj_type in BLIDStructs: if _is_bl_id_struct(obj_type):
return BPT.BLPointer return BPT.BLPointer
# Fallback: Serializable Object # Fallback: Serializable Object

View File

@ -1285,6 +1285,7 @@ UnitSystem: typ.TypeAlias = dict[PhysicalType, Unit]
_PT = PhysicalType _PT = PhysicalType
UNITS_SI: UnitSystem = { UNITS_SI: UnitSystem = {
_PT.NonPhysical: None,
# Global # Global
_PT.Time: spu.second, _PT.Time: spu.second,
_PT.Angle: spu.radian, _PT.Angle: spu.radian,
@ -1396,7 +1397,7 @@ def strip_unit_system(sp_obj: SympyExpr, unit_system: UnitSystem) -> SympyExpr:
Notes: Notes:
You should probably use `scale_to_unit_system()` or `convert_to_unit_system()`. 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( def scale_to_unit_system(