Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "Node Runner",
"description": "Import and export nodes as strings",
"author": "Noah Thiering <noah.thiering@gmail.com>",
"version": (1, 3, 9),
"version": (1, 4, 1),
"blender": (4, 5, 0),
"category": "Node",
}
Expand Down
2 changes: 1 addition & 1 deletion blender_manifest.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
schema_version = "1.0.0"

id = "node_runner"
version = "1.3.9"
version = "1.4.1"
name = "Node Runner"
tagline = "Import and export nodes as strings"
maintainer = "Noah Thiering <noah.thiering@gmail.com>"
Expand Down
17 changes: 17 additions & 0 deletions constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@
# Debug properties (Blender 4.x+)
"debug_zone_body_lazy_function_graph",
"debug_zone_lazy_function_graph",
# Dynamic item collections — handled via "_dynamic_items"
"capture_items",
"bake_items",
"index_switch_items",
# Active-item pointers of dynamic collections (UI state only)
"active_item",
# Read-only computed
"location_absolute",
"warning_propagation",
Expand Down Expand Up @@ -112,6 +118,17 @@
}
)

# Dynamic item-collection properties on nodes (Capture Attribute, Bake,
# Index Switch). The generic property loop cannot round-trip these:
# the items define the node's socket layout, so they are serialized
# explicitly under "_dynamic_items" and recreated at import time before
# socket defaults and links are applied.
DYNAMIC_ITEM_PROPS = (
"capture_items",
"bake_items",
"index_switch_items",
)

# Paired zone node types: bl_idname to attribute name for the paired output.
# These nodes must be paired with their output before sockets become valid.
PAIRED_NODE_TYPES = {
Expand Down
70 changes: 70 additions & 0 deletions deserialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,69 @@ def deserialize_outputs(node, data, node_data, node_tree, socket_id_map):
# Node deserializer


# Attribute data types (as stored on dynamic items) mapped to the socket
# type enum that ``collection.new()`` expects.
_ITEM_SOCKET_TYPE_MAP = {
"FLOAT_VECTOR": "VECTOR",
"FLOAT2": "VECTOR",
"INT32_2D": "VECTOR",
"FLOAT_COLOR": "RGBA",
"BYTE_COLOR": "RGBA",
"QUATERNION": "ROTATION",
"FLOAT4X4": "MATRIX",
"INT8": "INT",
}


def deserialize_dynamic_items(node, dynamic_items):
"""Recreate dynamic item collections (capture_items, bake_items, ...).

Must run before socket defaults and links are applied — the items
define the node's socket layout. ``new()`` signatures differ per
collection type, so several call shapes are attempted.
"""
for items_prop, entries in dynamic_items.items():
collection = getattr(node, items_prop, None)
if collection is None:
continue
try:
collection.clear()
except AttributeError:
pass
for entry in entries:
socket_type = entry.get("socket_type") or entry.get("data_type")
name = entry.get("name")
candidates = []
if socket_type is not None and name is not None:
candidates.append((socket_type, name))
mapped = _ITEM_SOCKET_TYPE_MAP.get(socket_type)
if mapped is not None:
candidates.append((mapped, name))
if name is not None:
candidates.append((name,))
candidates.append(())
item = None
for args in candidates:
try:
item = collection.new(*args)
break
except (TypeError, RuntimeError):
continue
if item is None:
log.warning(
"Could not recreate %s entry %r on '%s'",
items_prop,
name,
node.name,
)
continue
for key, value in entry.items():
try:
setattr(item, key, value)
except (AttributeError, TypeError, ValueError):
pass


def deserialize_node(node_data, node_tree, socket_id_map, defer_io=False):
"""Create a new node and apply all serialized properties.

Expand All @@ -369,6 +432,11 @@ def deserialize_node(node_data, node_tree, socket_id_map, defer_io=False):
new_node = node_tree.nodes.new(type=node_type)
new_node.label = node_data.get("label", "")

# Recreate dynamic item collections first — they define the node's
# socket layout, which everything below depends on.
if "_dynamic_items" in node_data:
deserialize_dynamic_items(new_node, node_data["_dynamic_items"])

# Node tree must be created before inputs/outputs
if "node_tree" in node_data:
nt_data = node_data["node_tree"]
Expand Down Expand Up @@ -428,6 +496,8 @@ def deserialize_node(node_data, node_tree, socket_id_map, defer_io=False):
elif prop_name in ("parent", "_paired_output"):
# Handled at the node-tree level
pass
elif prop_name == "_dynamic_items":
pass # Already applied right after node creation
elif prop_name in ("label", "type", "name"):
continue
else:
Expand Down
119 changes: 102 additions & 17 deletions operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,23 +426,25 @@ def _apply_import(

# When we auto-created a Geometry Nodes modifier, its per-instance
# input values were initialized before the imported tree's interface
# sockets existed. Rebinding pushes the freshly deserialized
# interface defaults into the modifier so a fresh import renders the
# same result the source tree did, without the user having to type
# in every value by hand. Then overlay any modifier_values captured
# from the source binding so the exact look (Leaf Density = 8 etc.)
# is reproduced — those override the interface defaults.
# sockets existed, so they're all at the seed defaults. Re-create the
# modifier so it re-initializes from the freshly deserialized
# interface defaults — a fresh import then renders the same result the
# source tree did, without the user typing every value by hand. Then
# overlay any modifier_values captured from the source binding so the
# exact look (Leaf Density = 8 etc.) is reproduced.
#
# NOTE: this used to just rebind (``mod.node_group = None; = ng``), but
# Blender 5.2 doesn't refresh an existing modifier's stored inputs that
# way (nor when the interface defaults change) — only a freshly added
# modifier picks up the current defaults. Re-adding is harmless on 4.x.
if auto_created and edit_tree.bl_idname == "GeometryNodeTree":
obj = getattr(context, "active_object", None)
if obj is not None:
for mod in obj.modifiers:
if mod.type == "NODES" and mod.node_group is edit_tree:
mod.node_group = None
mod.node_group = edit_tree
_apply_modifier_values(
mod, data.get("modifier_values"), socket_id_map
)
break
mod = _reinit_gn_modifier(obj, edit_tree)
if mod is not None:
_apply_modifier_values(
mod, data.get("modifier_values"), socket_id_map
)

node_count = len(new_nodes)
operator.report(
Expand All @@ -456,7 +458,7 @@ def _apply_import(

def _format_extension(fmt):
"""Return the conventional file extension (with dot) for *fmt*."""
if fmt == FORMAT_JSON or fmt == FORMAT_AI_JSON:
if fmt in (FORMAT_JSON, FORMAT_AI_JSON):
return ".json"
if fmt == FORMAT_XML:
return ".xml"
Expand Down Expand Up @@ -485,6 +487,30 @@ def _find_modifier_for_tree(context, edit_tree):
return None


def _reinit_gn_modifier(obj, node_group):
"""Re-create *obj*'s NODES modifier bound to *node_group*.

Returns the fresh modifier (or ``None`` if no matching modifier was
found). A newly added modifier initializes all of its inputs from the
node group's current interface defaults, which is the only reliable
way to push those defaults into the binding on Blender 5.2. The
auto-created modifier is the most recent one, so re-adding keeps it in
the same (last) slot.
"""
old = None
for mod in obj.modifiers:
if mod.type == "NODES" and mod.node_group is node_group:
old = mod
break
if old is None:
return None
name = old.name
obj.modifiers.remove(old)
new_mod = obj.modifiers.new(name=name, type="NODES")
new_mod.node_group = node_group
return new_mod


def _serialize_modifier_value(value):
"""Convert a modifier socket value to a JSON-friendly representation.

Expand All @@ -509,15 +535,53 @@ def _collect_modifier_values(mod):

Skips the ``_use_attribute`` / ``_attribute_name`` companion keys —
those are toggle metadata, not the user-facing values.

Blender 4.x exposed a GN modifier's inputs as IDProperties
(``mod["Socket_3"]``). Blender 5.2 dropped that — ``mod.keys()`` and
item access raise ``TypeError`` — so we fall back to the node group's
interface socket defaults, which are the live input values there.
"""
try:
keys = list(mod.keys())
except TypeError:
return _collect_interface_values(mod.node_group)
out = {}
for key in mod.keys():
for key in keys:
if key.endswith("_use_attribute") or key.endswith("_attribute_name"):
continue
out[key] = _serialize_modifier_value(mod[key])
return out


def _interface_input_sockets(node_group):
"""Yield the INPUT interface sockets of *node_group* (5.2-safe)."""
if node_group is None:
return
items = getattr(getattr(node_group, "interface", None), "items_tree", None)
if not items:
return
for item in items:
if getattr(item, "item_type", None) != "SOCKET":
continue
if getattr(item, "in_out", None) != "INPUT":
continue
if not hasattr(item, "default_value"):
continue
yield item


def _collect_interface_values(node_group):
"""Capture INPUT interface socket defaults keyed by identifier.

Used on Blender 5.2+, where GN modifier inputs are no longer
IDProperties and the interface default *is* the input value.
"""
return {
item.identifier: _serialize_modifier_value(item.default_value)
for item in _interface_input_sockets(node_group)
}


def _apply_modifier_values(mod, values, socket_id_map):
"""Restore per-instance modifier values captured at export time.

Expand All @@ -529,6 +593,7 @@ def _apply_modifier_values(mod, values, socket_id_map):
"""
if not values:
return
node_group = getattr(mod, "node_group", None)
for old_id, raw_value in values.items():
new_id = socket_id_map.get(old_id, old_id)
try:
Expand All @@ -541,7 +606,27 @@ def _apply_modifier_values(mod, values, socket_id_map):
try:
mod[new_id] = value
except (TypeError, KeyError, AttributeError):
log.debug("Could not set modifier value '%s'", new_id)
# Blender 5.2+: GN modifier inputs aren't IDProperties, so the
# item assignment above isn't available. Write the value onto
# the interface socket default instead — that's the live input.
if not _set_interface_default(node_group, new_id, value):
log.debug("Could not set modifier value '%s'", new_id)


def _set_interface_default(node_group, identifier, value):
"""Set an interface socket's default by identifier (Blender 5.2+).

Returns ``True`` if a matching socket was found and assigned.
"""
for item in _interface_input_sockets(node_group):
if item.identifier != identifier:
continue
try:
item.default_value = value
except (TypeError, ValueError):
log.debug("Could not set interface default '%s'", identifier)
return True
return False


def _resolve_id_value(payload):
Expand Down
Loading