feat: We did it, GeoNodes node w/live update!

We also implemented the TriMesh node, and established a strong
convention for updating nodes from sockets via. socket superclass
method. `trigger_updates`. It should be triggered as the
`update=` callback on **ALL PROPERTIES IN ALL SOCKETS**. This
method in turn calls the nodal `update()` function, which in turn
causes the node to chain-update all nodes linked to any output socket.

By default, `update()` is `pass`, so performance shouldn't be a concern,
but we should think about this deeper at some point.

Because update-chaining is done, we're ready for preview toggles on
node outputs. A lot of exciting things to do now!
Sofus Albert Høgsbro Rose 2024-02-20 13:16:23 +01:00
parent 62006da0f9
commit 27fdb38262
29 changed files with 396 additions and 28 deletions

View File

@ -450,6 +450,41 @@ SocketType_to_color = {
SocketType.MaxwellSimGridAxis: (0.4, 0.3, 0.25, 1.0), # Darkest Gold SocketType.MaxwellSimGridAxis: (0.4, 0.3, 0.25, 1.0), # Darkest Gold
} }
BLNodeSocket_to_SocketType = {
"NodeSocketBool": SocketType.Bool,
"NodeSocketCollection": SocketType.BlenderCollection,
"NodeSocketColor": SocketType.Real3DVector,
"NodeSocketFloat": SocketType.RealNumber,
"NodeSocketFloatAngle": SocketType.RealNumber,
"NodeSocketFloatDistance": SocketType.RealNumber,
"NodeSocketFloatFactor": SocketType.RealNumber,
"NodeSocketFloatPercentage": SocketType.RealNumber,
"NodeSocketFloatTime": SocketType.RealNumber,
"NodeSocketFloatTimeAbsolute": SocketType.RealNumber,
"NodeSocketFloatUnsigned": SocketType.RealNumber,
"NodeSocketGeometry": SocketType.Any,
"NodeSocketImage": SocketType.BlenderImage,
"NodeSocketInt": SocketType.IntegerNumber,
"NodeSocketIntFactor": SocketType.IntegerNumber,
"NodeSocketIntPercentage": SocketType.IntegerNumber,
"NodeSocketIntUnsigned": SocketType.IntegerNumber,
"NodeSocketMaterial": SocketType.Any,
"NodeSocketObject": SocketType.BlenderObject,
"NodeSocketRotation": SocketType.Real3DVector,
"NodeSocketShader": SocketType.Any,
"NodeSocketStandard": SocketType.Any,
"NodeSocketString": SocketType.Text,
"NodeSocketTexture": SocketType.Any,
"NodeSocketVector": SocketType.Real3DVector,
"NodeSocketVectorAcceleration": SocketType.Real3DVector,
"NodeSocketVectorDirection": SocketType.Real3DVector,
"NodeSocketVectorEuler": SocketType.Real3DVector,
"NodeSocketVectorTranslation": SocketType.Real3DVector,
"NodeSocketVectorVelocity": SocketType.Real3DVector,
"NodeSocketVectorXYZ": SocketType.Real3DVector,
"NodeSocketVirtual": SocketType.Any,
}
#################### ####################
# - Node Types # - Node Types
#################### ####################

View File

@ -155,11 +155,6 @@ class MaxwellSimTreeNode(bpy.types.Node):
# Declare Node Property: 'preset' EnumProperty # Declare Node Property: 'preset' EnumProperty
if hasattr(cls, "input_socket_sets") or hasattr(cls, "output_socket_sets"): if hasattr(cls, "input_socket_sets") or hasattr(cls, "output_socket_sets"):
if not hasattr(cls, "input_socket_sets"):
cls.input_socket_sets = {}
if not hasattr(cls, "output_socket_sets"):
cls.output_socket_sets = {}
socket_set_keys = [ socket_set_keys = [
input_socket_set_key input_socket_set_key
for input_socket_set_key in cls.input_socket_sets.keys() for input_socket_set_key in cls.input_socket_sets.keys()
@ -188,6 +183,11 @@ class MaxwellSimTreeNode(bpy.types.Node):
default=socket_set_keys[0] default=socket_set_keys[0]
) )
if not hasattr(cls, "input_socket_sets"):
cls.input_socket_sets = {}
if not hasattr(cls, "output_socket_sets"):
cls.output_socket_sets = {}
# Declare Node Property: 'preset' EnumProperty # Declare Node Property: 'preset' EnumProperty
if hasattr(cls, "presets"): if hasattr(cls, "presets"):
first_preset = list(cls.presets.keys())[0] first_preset = list(cls.presets.keys())[0]
@ -292,6 +292,18 @@ class MaxwellSimTreeNode(bpy.types.Node):
return ntree.bl_idname == contracts.TreeType.MaxwellSim.value return ntree.bl_idname == contracts.TreeType.MaxwellSim.value
def update(self) -> None:
"""Called when some node properties (ex. links) change,
and/or by custom code."""
if hasattr(self, "update_cb"):
self.update_cb()
for bl_socket in self.outputs:
if bl_socket.is_linked:
for node_link in bl_socket.links:
linked_node = node_link.to_node
linked_node.update()
def _update_socket(self): def _update_socket(self):
if not hasattr(self, "socket_set"): if not hasattr(self, "socket_set"):
raise ValueError("no socket") raise ValueError("no socket")
@ -379,7 +391,7 @@ class MaxwellSimTreeNode(bpy.types.Node):
return self.inputs[self.input_sockets[input_socket_name].label] return self.inputs[self.input_sockets[input_socket_name].label]
elif hasattr(self, "input_socket_sets"): elif hasattr(self, "socket_set"):
# You're on your own, chump # You're on your own, chump
return self.inputs[next( return self.inputs[next(
@ -412,7 +424,7 @@ class MaxwellSimTreeNode(bpy.types.Node):
return self.outputs[self.output_sockets[output_socket_name].label] return self.outputs[self.output_sockets[output_socket_name].label]
elif hasattr(self, "input_socket_sets"): elif hasattr(self, "socket_set"):
return self.outputs[next( return self.outputs[next(
socket_def.label socket_def.label
for socket_set, socket_dict in self.input_socket_sets.items() for socket_set, socket_dict in self.input_socket_sets.items()
@ -424,7 +436,7 @@ class MaxwellSimTreeNode(bpy.types.Node):
self, self,
output_bl_socket_name: contracts.BLSocketName, output_bl_socket_name: contracts.BLSocketName,
) -> contracts.SocketName: ) -> contracts.SocketName:
if hasattr(self, "output_socket_sets"): if hasattr(self, "socket_set"):
return next( return next(
socket_name socket_name
for socket_set, socket_dict in self.output_socket_sets.items() for socket_set, socket_dict in self.output_socket_sets.items()

View File

@ -1,5 +1,177 @@
import tidy3d as td
import numpy as np
import sympy as sp
import sympy.physics.units as spu
import bpy
import bmesh
from ... import contracts
from ... import sockets
from .. import base
GEONODES_MODIFIER_NAME = "BLMaxwell_GeoNodes"
class GeoNodesStructureNode(base.MaxwellSimTreeNode):
node_type = contracts.NodeType.GeoNodesStructure
bl_label = "GeoNodes Structure"
#bl_icon = ...
####################
# - Sockets
####################
input_sockets = {
"medium": sockets.MaxwellMediumSocketDef(
label="Medium",
),
"object": sockets.BlenderObjectSocketDef(
label="Object",
),
"geo_nodes": sockets.BlenderGeoNodesSocketDef(
label="GeoNodes",
),
}
output_sockets = {
"structure": sockets.MaxwellStructureSocketDef(
label="Structure",
),
}
####################
# - Output Socket Computation
####################
@base.computes_output_socket("structure")
def compute_simulation(self: contracts.NodeTypeProtocol) -> td.TriangleMesh:
# Extract the Blender Object
bl_object = self.compute_input("object")
# Ensure Updated Geometry
bpy.context.view_layer.update()
# Triangulate Object Mesh
bmesh_mesh = bmesh.new()
bmesh_mesh.from_mesh(bl_object.data)
bmesh.ops.triangulate(bmesh_mesh, faces=bmesh_mesh.faces)
mesh = bpy.data.meshes.new(name="TriangulatedMesh")
bmesh_mesh.to_mesh(mesh)
bmesh_mesh.free()
# Extract Vertices and Faces
vertices = np.array([vert.co for vert in mesh.vertices])
faces = np.array([
[vert for vert in poly.vertices]
for poly in mesh.polygons
])
# Remove Temporary Mesh
bpy.data.meshes.remove(mesh)
return td.Structure(
geometry=td.TriangleMesh.from_vertices_faces(vertices, faces),
medium=self.compute_input("medium")
)
####################
# - Update Function
####################
def update_cb(self) -> None:
bl_object = self.compute_input("object")
if bl_object is None: return
geo_nodes = self.compute_input("geo_nodes")
if geo_nodes is None: return
bl_modifier = bl_object.modifiers.get(GEONODES_MODIFIER_NAME)
if bl_modifier is None: return
# Set GeoNodes Modifier Attributes
for idx, interface_item in enumerate(
geo_nodes.interface.items_tree.values()
):
if idx == 0: continue ## Always-on "Geometry" Input (from Object)
bl_socket = self.inputs[
interface_item.name
]
if bl_socket.is_linked:
linked_bl_socket = bl_socket.links[0].from_socket
linked_bl_node = bl_socket.links[0].from_node
val = linked_bl_node.compute_output(
linked_bl_node.g_output_socket_name(
linked_bl_socket.name
)
) ## What a bunch of spaghetti
else:
val = self.inputs[
interface_item.name
].default_value
# Conservatively Set Differing Values
if bl_modifier[interface_item.identifier] != val:
bl_modifier[interface_item.identifier] = val
# Update DepGraph
bl_object.data.update()
def update_sockets_from_geonodes(self) -> None:
# Remove All "Loose" Sockets
socket_labels = {
socket_def.label
for socket_def in self.input_sockets.values()
} | {
socket_def.label
for socket_set_name, socket_set in self.input_socket_sets.items()
for socket_name, socket_def in socket_set.items()
}
bl_sockets_to_remove = {
bl_socket
for bl_socket_name, bl_socket in self.inputs.items()
if bl_socket_name not in socket_labels
}
for bl_socket in bl_sockets_to_remove:
self.inputs.remove(bl_socket)
# Query for Blender Object / Geo Nodes
bl_object = self.compute_input("object")
if bl_object is None: return
## TODO: Make object? Gray out geonodes if object not defined?
geo_nodes = self.compute_input("geo_nodes")
if geo_nodes is None: return
# Add Non-Static Sockets from GeoNodes
for bl_socket_name, bl_socket in geo_nodes.interface.items_tree.items():
# For now, don't allow Geometry inputs.
if bl_socket.socket_type == "NodeSocketGeometry": continue
self.inputs.new(
contracts.BLNodeSocket_to_SocketType[bl_socket.socket_type],
bl_socket_name,
)
# Create New GeoNodes Modifier
if GEONODES_MODIFIER_NAME not in bl_object.modifiers:
modifier = bl_object.modifiers.new(
name=GEONODES_MODIFIER_NAME,
type="NODES",
)
modifier.node_group = geo_nodes
self.update()
#################### ####################
# - Blender Registration # - Blender Registration
#################### ####################
BL_REGISTER = [] BL_REGISTER = [
BL_NODES = {} GeoNodesStructureNode,
]
BL_NODES = {
contracts.NodeType.GeoNodesStructure: (
contracts.NodeCategory.MAXWELLSIM_STRUCTURES
)
}

View File

@ -1,6 +1,83 @@
import tidy3d as td
import numpy as np
import sympy as sp
import sympy.physics.units as spu
import bpy
import bmesh
from ... import contracts
from ... import sockets
from .. import base
class ObjectStructureNode(base.MaxwellSimTreeNode):
node_type = contracts.NodeType.ObjectStructure
bl_label = "Object Structure"
#bl_icon = ...
####################
# - Sockets
####################
input_sockets = {
"medium": sockets.MaxwellMediumSocketDef(
label="Medium",
),
"object": sockets.BlenderObjectSocketDef(
label="Object",
),
}
output_sockets = {
"structure": sockets.MaxwellStructureSocketDef(
label="Structure",
),
}
####################
# - Output Socket Computation
####################
@base.computes_output_socket("structure")
def compute_structure(self: contracts.NodeTypeProtocol) -> td.Structure:
# Extract the Blender Object
bl_object = self.compute_input("object")
# Ensure Updated Geometry
bpy.context.view_layer.update()
# Triangulate Object Mesh
bmesh_mesh = bmesh.new()
bmesh_mesh.from_mesh(bl_object.data)
bmesh.ops.triangulate(bmesh_mesh, faces=bmesh_mesh.faces)
mesh = bpy.data.meshes.new(name="TriangulatedMesh")
bmesh_mesh.to_mesh(mesh)
bmesh_mesh.free()
# Extract Vertices and Faces
vertices = np.array([vert.co for vert in mesh.vertices])
faces = np.array([
[vert for vert in poly.vertices]
for poly in mesh.polygons
])
# Remove Temporary Mesh
bpy.data.meshes.remove(mesh)
print(vertices)
return td.Structure(
geometry=td.TriangleMesh.from_vertices_faces(vertices, faces),
medium=self.compute_input("medium")
)
#################### ####################
# - Blender Registration # - Blender Registration
#################### ####################
BL_REGISTER = [] BL_REGISTER = [
BL_NODES = {} ObjectStructureNode,
]
BL_NODES = {
contracts.NodeType.ObjectStructure: (
contracts.NodeCategory.MAXWELLSIM_STRUCTURES
)
}

View File

@ -140,6 +140,13 @@ class BLSocket(bpy.types.NodeSocket):
self._unit_previous = self.unit self._unit_previous = self.unit
####################
# - Callback Dispatcher
####################
def trigger_updates(self) -> None:
if not self.is_output:
self.node.update()
#################### ####################
# - Methods # - Methods
#################### ####################

View File

@ -25,6 +25,7 @@ class BoolBLSocket(base.BLSocket):
name="Boolean", name="Boolean",
description="Represents a boolean", description="Represents a boolean",
default=False, default=False,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -27,6 +27,7 @@ class FilePathBLSocket(base.BLSocket):
description="Represents the path to a file", description="Represents the path to a file",
#default="", #default="",
subtype="FILE_PATH", subtype="FILE_PATH",
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -27,6 +27,7 @@ class TextBLSocket(base.BLSocket):
name="Text", name="Text",
description="Represents some text", description="Represents some text",
default="", default="",
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -1,5 +1,6 @@
import typing as typ import typing as typ
import bpy
import pydantic as pyd import pydantic as pyd
from .. import base from .. import base
@ -12,16 +13,26 @@ class BlenderCollectionBLSocket(base.BLSocket):
socket_type = contracts.SocketType.BlenderCollection socket_type = contracts.SocketType.BlenderCollection
bl_label = "BlenderCollection" bl_label = "BlenderCollection"
####################
# - Properties
####################
raw_value: bpy.props.PointerProperty(
name="Blender Collection",
description="Represents a Blender collection",
type=bpy.types.Collection,
update=(lambda self, context: self.trigger_updates()),
)
#################### ####################
# - Default Value # - Default Value
#################### ####################
@property @property
def default_value(self) -> None: def default_value(self) -> bpy.types.Collection | None:
pass return self.raw_value
@default_value.setter @default_value.setter
def default_value(self, value: typ.Any) -> None: def default_value(self, value: bpy.types.Collection) -> None:
pass self.raw_value = value
#################### ####################
# - Socket Configuration # - Socket Configuration

View File

@ -1,5 +1,6 @@
import typing as typ import typing as typ
import bpy
import pydantic as pyd import pydantic as pyd
from .. import base from .. import base
@ -12,16 +13,36 @@ class BlenderGeoNodesBLSocket(base.BLSocket):
socket_type = contracts.SocketType.BlenderGeoNodes socket_type = contracts.SocketType.BlenderGeoNodes
bl_label = "BlenderGeoNodes" bl_label = "BlenderGeoNodes"
####################
# - Properties
####################
def update_geonodes_node(self):
if hasattr(self.node, "update_sockets_from_geonodes"):
self.node.update_sockets_from_geonodes()
else:
raise ValueError("Node doesn't have GeoNodes socket update method.")
# Run the Usual Updates
self.trigger_updates()
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.update_geonodes_node()),
)
#################### ####################
# - Default Value # - Default Value
#################### ####################
@property @property
def default_value(self) -> None: def default_value(self) -> bpy.types.Object | None:
pass return self.raw_value
@default_value.setter @default_value.setter
def default_value(self, value: typ.Any) -> None: def default_value(self, value: bpy.types.Object) -> None:
pass self.raw_value = value
#################### ####################
# - Socket Configuration # - Socket Configuration

View File

@ -1,5 +1,6 @@
import typing as typ import typing as typ
import bpy
import pydantic as pyd import pydantic as pyd
from .. import base from .. import base

View File

@ -1,5 +1,6 @@
import typing as typ import typing as typ
import bpy
import pydantic as pyd import pydantic as pyd
from .. import base from .. import base
@ -12,16 +13,26 @@ class BlenderObjectBLSocket(base.BLSocket):
socket_type = contracts.SocketType.BlenderObject socket_type = contracts.SocketType.BlenderObject
bl_label = "BlenderObject" bl_label = "BlenderObject"
####################
# - Properties
####################
raw_value: bpy.props.PointerProperty(
name="Blender Object",
description="Represents a Blender object",
type=bpy.types.Object,
update=(lambda self, context: self.trigger_updates()),
)
#################### ####################
# - Default Value # - Default Value
#################### ####################
@property @property
def default_value(self) -> None: def default_value(self) -> bpy.types.Object | None:
pass return self.raw_value
@default_value.setter @default_value.setter
def default_value(self, value: typ.Any) -> None: def default_value(self, value: bpy.types.Object) -> None:
pass self.raw_value = value
#################### ####################
# - Socket Configuration # - Socket Configuration

View File

@ -1,5 +1,6 @@
import typing as typ import typing as typ
import bpy
import pydantic as pyd import pydantic as pyd
from .. import base from .. import base

View File

@ -1,5 +1,6 @@
import typing as typ import typing as typ
import bpy
import pydantic as pyd import pydantic as pyd
from .. import base from .. import base

View File

@ -23,6 +23,7 @@ class MaxwellMediumBLSocket(base.BLSocket):
description="Represents a simple, real permittivity.", description="Represents a simple, real permittivity.",
default=0.0, default=0.0,
precision=4, precision=4,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -30,7 +30,8 @@ class ComplexNumberBLSocket(base.BLSocket):
description="Represents a complex number (real, imaginary)", description="Represents a complex number (real, imaginary)",
size=2, size=2,
default=(0.0, 0.0), default=(0.0, 0.0),
subtype='NONE' subtype='NONE',
update=(lambda self, context: self.trigger_updates()),
) )
coord_sys: bpy.props.EnumProperty( coord_sys: bpy.props.EnumProperty(
name="Coordinate System", name="Coordinate System",
@ -136,6 +137,8 @@ class ComplexNumberBLSocket(base.BLSocket):
sp.arg(cart_value) if y != 0 else 0, sp.arg(cart_value) if y != 0 else 0,
) )
self.trigger_updates()
#################### ####################
# - Socket Configuration # - Socket Configuration
#################### ####################

View File

@ -30,6 +30,7 @@ class RealNumberBLSocket(base.BLSocket):
description="Represents a real number", description="Represents a real number",
default=0.0, default=0.0,
precision=6, precision=6,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -22,6 +22,7 @@ class PhysicalAccelScalarBLSocket(base.BLSocket):
description="Represents the unitless part of the acceleration", description="Represents the unitless part of the acceleration",
default=0.0, default=0.0,
precision=6, precision=6,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -22,6 +22,7 @@ class PhysicalAngleBLSocket(base.BLSocket):
description="Represents the unitless part of the acceleration", description="Represents the unitless part of the acceleration",
default=0.0, default=0.0,
precision=4, precision=4,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -32,6 +32,7 @@ class PhysicalAreaBLSocket(base.BLSocket):
description="Represents the unitless part of the area", description="Represents the unitless part of the area",
default=0.0, default=0.0,
precision=6, precision=6,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -22,6 +22,7 @@ class PhysicalForceScalarBLSocket(base.BLSocket):
description="Represents the unitless part of the force", description="Represents the unitless part of the force",
default=0.0, default=0.0,
precision=6, precision=6,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -22,6 +22,7 @@ class PhysicalFreqBLSocket(base.BLSocket):
description="Represents the unitless part of the frequency", description="Represents the unitless part of the frequency",
default=0.0, default=0.0,
precision=6, precision=6,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -22,6 +22,7 @@ class PhysicalLengthBLSocket(base.BLSocket):
description="Represents the unitless part of the force", description="Represents the unitless part of the force",
default=0.0, default=0.0,
precision=6, precision=6,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -33,6 +33,7 @@ class PhysicalPoint3DBLSocket(base.BLSocket):
size=3, size=3,
default=(0.0, 0.0, 0.0), default=(0.0, 0.0, 0.0),
precision=4, precision=4,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -33,6 +33,7 @@ class PhysicalSize3DBLSocket(base.BLSocket):
size=3, size=3,
default=(1.0, 1.0, 1.0), default=(1.0, 1.0, 1.0),
precision=4, precision=4,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -22,6 +22,7 @@ class PhysicalTimeBLSocket(base.BLSocket):
description="Represents the unitless part of the force", description="Represents the unitless part of the force",
default=0.0, default=0.0,
precision=6, precision=6,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -32,6 +32,7 @@ class PhysicalVolumeBLSocket(base.BLSocket):
description="Represents the unitless part of the area", description="Represents the unitless part of the area",
default=0.0, default=0.0,
precision=6, precision=6,
update=(lambda self, context: self.trigger_updates()),
) )
#################### ####################

View File

@ -2,3 +2,4 @@ tidy3d==2.5.2
pydantic==2.6.0 pydantic==2.6.0
sympy==1.12 sympy==1.12
scipy==1.12.0 scipy==1.12.0
trimesh==4.1.4

BIN
demo.blend (Stored with Git LFS)

Binary file not shown.