diff --git a/__init__.py b/__init__.py index c84576f..abd8297 100644 --- a/__init__.py +++ b/__init__.py @@ -9,7 +9,7 @@ "name": "Node Runner", "description": "Import and export nodes as strings", "author": "Noah Thiering ", - "version": (1, 3, 9), + "version": (1, 4, 1), "blender": (4, 5, 0), "category": "Node", } diff --git a/blender_manifest.toml b/blender_manifest.toml index 5d8711d..85b75d5 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -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 " diff --git a/constants.py b/constants.py index e3ff5b9..b902f53 100644 --- a/constants.py +++ b/constants.py @@ -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", @@ -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 = { diff --git a/deserialize.py b/deserialize.py index 862bbf2..2053472 100644 --- a/deserialize.py +++ b/deserialize.py @@ -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. @@ -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"] @@ -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: diff --git a/operators.py b/operators.py index 24bc5f0..25a9a1a 100644 --- a/operators.py +++ b/operators.py @@ -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( @@ -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" @@ -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. @@ -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. @@ -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: @@ -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): diff --git a/serialize.py b/serialize.py index 34a7e2e..e79922f 100644 --- a/serialize.py +++ b/serialize.py @@ -11,7 +11,12 @@ import bpy import mathutils -from .constants import EXCLUDE_NODE_PROPS, SERIALIZE_READONLY_PROPS, PAIRED_NODE_TYPES +from .constants import ( + DYNAMIC_ITEM_PROPS, + EXCLUDE_NODE_PROPS, + PAIRED_NODE_TYPES, + SERIALIZE_READONLY_PROPS, +) log = logging.getLogger(__name__) @@ -204,6 +209,39 @@ def serialize_attr(node, attr): # Node serialization +def _serialize_dynamic_item(item): + """Serialize one entry of a dynamic node item collection. + + Captures the writable simple properties (plus ``name``) of e.g. a + CaptureAttributeItem or NodeGeometryBakeItem — enough to recreate + the item with ``collection.new()`` and ``setattr`` on import. + """ + entry = {} + for prop in item.bl_rna.properties: + if prop.identifier == "rna_type": + continue + if prop.is_readonly and prop.identifier != "name": + continue + value = getattr(item, prop.identifier) + if isinstance(value, (str, int, float, bool)): + entry[prop.identifier] = value + return entry + + +def _socket_entry(node, s, iface_by_id): + """Build a socket dict for group input/output ordering, with default if available.""" + entry = {"type": s.bl_idname, "name": s.name, "identifier": s.identifier} + iface = iface_by_id.get(s.identifier) + if iface is not None and hasattr(iface, "default_value"): + dv = iface.default_value + if dv is not None and not isinstance(dv, bpy.types.ID): + try: + entry["default"] = serialize_attr(node, dv) + except (TypeError, ValueError): + pass + return entry + + def serialize_node(node): """Serialize all properties of a single node into a dict. @@ -243,34 +281,15 @@ def serialize_node(node): if getattr(item, "item_type", None) == "SOCKET": iface_by_id[item.identifier] = item - def _socket_entry(s): - entry = { - "type": s.bl_idname, - "name": s.name, - "identifier": s.identifier, - } - iface = iface_by_id.get(s.identifier) - if iface is not None and hasattr(iface, "default_value"): - dv = iface.default_value - # Skip data-block references (collections, objects, - # materials, images) — they don't survive round-trip - # across files and would deserialize to None anyway. - if dv is not None and not isinstance(dv, bpy.types.ID): - try: - entry["default"] = serialize_attr(node, dv) - except (TypeError, ValueError): - pass - return entry - if prop_name == "inputs": node_dict["input_order"] = [ - _socket_entry(s) + _socket_entry(node, s, iface_by_id) for s in node.inputs if s.bl_idname != "NodeSocketVirtual" ] if prop_name == "outputs": node_dict["output_order"] = [ - _socket_entry(s) + _socket_entry(node, s, iface_by_id) for s in node.outputs if s.bl_idname != "NodeSocketVirtual" ] @@ -284,6 +303,16 @@ def _socket_entry(s): # Store absolute location for correct nested-frame positioning node_dict["location_absolute"] = list(node.location_absolute) + # Dynamic item collections (Capture Attribute, Bake, Index Switch): + # these define the node's socket layout and cannot be restored by the + # generic property loop, so store them explicitly. + for items_prop in DYNAMIC_ITEM_PROPS: + items = getattr(node, items_prop, None) + if items is not None: + node_dict.setdefault("_dynamic_items", {})[items_prop] = [ + _serialize_dynamic_item(item) for item in items + ] + # Store paired output reference for zone nodes (repeat, simulation, etc.) if node.bl_idname in PAIRED_NODE_TYPES: attr_name = PAIRED_NODE_TYPES[node.bl_idname] diff --git a/tests/test_geometry_nodes.py b/tests/test_geometry_nodes.py index 150ff73..99953d9 100644 --- a/tests/test_geometry_nodes.py +++ b/tests/test_geometry_nodes.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock -from tests.helpers import MockNode, MockNodeTree, MockSocket +from tests.helpers import MockNode, MockNodeTree from node_runner.serialize import serialize_node_tree from node_runner.encoding import ( @@ -29,8 +29,8 @@ def test_empty_geometry_tree(self): result = serialize_node_tree(tree) assert result["tree_type"] == "GeometryNodeTree" assert result["name"] == "Geo" - assert result["nodes"] == {} - assert result["links"] == [] + assert not result["nodes"] + assert not result["links"] def test_simple_set_position_graph(self): tree = MockNodeTree(name="Geo", bl_idname="GeometryNodeTree")