From 5b9769ac58cb4761519fc6d313432801c4cc5807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Hendrik=20M=C3=BCller?= <44469195+kolibril13@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:09:01 +0200 Subject: [PATCH] implement panel hide options --- .../tree_clipper/src/tree_clipper/common.py | 1 + .../src/tree_clipper/export_nodes.py | 21 ++++++ .../src/tree_clipper/import_nodes.py | 15 ++++- .../src/tree_clipper/specific_handlers.py | 37 ++++++++++ .../5_2_principled_bsdf.json | 4 +- .../blender_5_1_to_5_2/5_2_raycast.json | 4 +- .../tests/test_node_panel_states.py | 67 +++++++++++++++++++ 7 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 packages/tree_clipper/tests/test_node_panel_states.py diff --git a/packages/tree_clipper/src/tree_clipper/common.py b/packages/tree_clipper/src/tree_clipper/common.py index e538ac5..4d41f5f 100644 --- a/packages/tree_clipper/src/tree_clipper/common.py +++ b/packages/tree_clipper/src/tree_clipper/common.py @@ -55,6 +55,7 @@ PROP_TYPE_ENUM, } NODE_TREE = "node_tree" +PANEL_STATES = "panel_states" DIMENSIONS = "dimensions" diff --git a/packages/tree_clipper/src/tree_clipper/export_nodes.py b/packages/tree_clipper/src/tree_clipper/export_nodes.py index 217dbbc..a5cbc26 100644 --- a/packages/tree_clipper/src/tree_clipper/export_nodes.py +++ b/packages/tree_clipper/src/tree_clipper/export_nodes.py @@ -29,6 +29,7 @@ MATERIAL_NAME, NAME, NODE_TREE, + PANEL_STATES, PROP_TYPE_BOOLEAN, PROP_TYPE_COLLECTION, PROP_TYPE_ENUM, @@ -482,6 +483,26 @@ def serializer( from_root: FromRoot, ) -> dict[str, Any]: data = specific_handler(exporter, obj, from_root) + + # Blender 5.2 exposes the open/closed state of interface panels as + # a read-only collection on every node. It has to be requested + # explicitly because a node's specific handler owns all inherited + # Node properties. Avoid writing an empty collection for the many + # nodes that do not have panels. + if isinstance(obj, bpy.types.Node) and hasattr(obj, PANEL_STATES): + panel_states = getattr(obj, PANEL_STATES) + if len(panel_states) > 0: + prop = obj.bl_rna.properties[PANEL_STATES] + no_clobber( + data, + prop.identifier, + exporter._export_property_collection( + obj=obj, + prop=cast(bpy.types.CollectionProperty, prop), + from_root=from_root.add_prop(prop), + ), + ) + for prop in unhandled_properties: from_root_prop = from_root.add_prop(prop) diff --git a/packages/tree_clipper/src/tree_clipper/import_nodes.py b/packages/tree_clipper/src/tree_clipper/import_nodes.py index 78b0686..eb8a45a 100644 --- a/packages/tree_clipper/src/tree_clipper/import_nodes.py +++ b/packages/tree_clipper/src/tree_clipper/import_nodes.py @@ -5,7 +5,7 @@ from operator import xor from pathlib import Path from types import NoneType -from typing import Any +from typing import Any, cast import bpy @@ -28,6 +28,7 @@ MAGIC_STRING, MATERIAL_NAME, NAME, + PANEL_STATES, PROP_TYPE_COLLECTION, PROP_TYPE_ENUM, PROP_TYPE_POINTER, @@ -411,6 +412,18 @@ def deserializer( ) -> None: specific_handler(importer, getter, serialization, from_root) + # Panel declarations can depend on node-specific properties and + # sockets, so restore their UI state only after the specific node + # handler has finished rebuilding the node. + if isinstance(getter(), bpy.types.Node) and PANEL_STATES in serialization: + prop = getter().bl_rna.properties[PANEL_STATES] + self._import_property_collection( + getter=getter, + prop=cast(bpy.types.CollectionProperty, prop), + serialization=serialization[PANEL_STATES], + from_root=from_root.add_prop(prop), + ) + for identifier in unhandled_prop_ids: prop = getter().bl_rna.properties[identifier] prop_from_root = from_root.add_prop(prop) diff --git a/packages/tree_clipper/src/tree_clipper/specific_handlers.py b/packages/tree_clipper/src/tree_clipper/specific_handlers.py index 8281a49..a00617b 100644 --- a/packages/tree_clipper/src/tree_clipper/specific_handlers.py +++ b/packages/tree_clipper/src/tree_clipper/specific_handlers.py @@ -14,6 +14,7 @@ NODE_TREE, no_clobber, ) +from .export_nodes import Exporter from .import_nodes import Importer from .specific_abstract import ( _BUILT_IN_EXPORTER, @@ -418,6 +419,42 @@ def deserialize(self): _import_node_parent(self) +_node_panel_state_type: Any = getattr(bpy.types, "NodePanelState", None) +if _node_panel_state_type is not None: + + def _export_node_panel_state( + exporter: Exporter, + obj: bpy.types.bpy_struct, + from_root, + ): + # `identifier` is intentionally omitted. It matches the interface + # panel's read-only persistent UID, which may change when an interface + # containing gaps in its UIDs is recreated on import. The collection + # order follows the interface panel order. + return exporter.export_all_simple_writable_properties( + obj=obj, + assumed_type=_node_panel_state_type, + from_root=from_root, + ) + + def _import_node_panel_state( + importer: Importer, + getter: GETTER, + serialization: dict[str, Any], + from_root, + ): + importer.import_all_simple_writable_properties( + getter=getter, + serialization=serialization, + assumed_type=_node_panel_state_type, + forbidden=[], + from_root=from_root, + ) + + no_clobber(_BUILT_IN_EXPORTER, _node_panel_state_type, _export_node_panel_state) + no_clobber(_BUILT_IN_IMPORTER, _node_panel_state_type, _import_node_panel_state) + + class CompositorNodeGroupImporter(SpecificImporter[bpy.types.CompositorNodeGroup]): def deserialize(self): self.import_all_simple_writable_properties_and_list( diff --git a/packages/tree_clipper/tests/backwards_compatibility/blender_5_1_to_5_2/5_2_principled_bsdf.json b/packages/tree_clipper/tests/backwards_compatibility/blender_5_1_to_5_2/5_2_principled_bsdf.json index e0ba1c6..e197cda 100644 --- a/packages/tree_clipper/tests/backwards_compatibility/blender_5_1_to_5_2/5_2_principled_bsdf.json +++ b/packages/tree_clipper/tests/backwards_compatibility/blender_5_1_to_5_2/5_2_principled_bsdf.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a1f0be47e29ee8cfb5d832e30494ed7d4989b977d41492f447f02eeea37ac61 -size 157741 +oid sha256:a74a9e20faf23387967b0b87eabf631095103ab4415f8b982f3cadf2ae6bb473 +size 160940 diff --git a/packages/tree_clipper/tests/backwards_compatibility/blender_5_1_to_5_2/5_2_raycast.json b/packages/tree_clipper/tests/backwards_compatibility/blender_5_1_to_5_2/5_2_raycast.json index 5e29151..c5489f3 100644 --- a/packages/tree_clipper/tests/backwards_compatibility/blender_5_1_to_5_2/5_2_raycast.json +++ b/packages/tree_clipper/tests/backwards_compatibility/blender_5_1_to_5_2/5_2_raycast.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3340587b73efdd6e04c6ce2dbd5a36f989f487952b0d4786cdc7bf5c77b679b5 -size 50388 +oid sha256:19e405593ebc183ce4411c313c4f2fb6cfff8cbe5a5fa799fdf33eb3fe1f25e3 +size 51076 diff --git a/packages/tree_clipper/tests/test_node_panel_states.py b/packages/tree_clipper/tests/test_node_panel_states.py new file mode 100644 index 0000000..fbca863 --- /dev/null +++ b/packages/tree_clipper/tests/test_node_panel_states.py @@ -0,0 +1,67 @@ +import json + +import bpy + +from .util import ( + export_to_string, + make_test_node_tree, + round_trip_without_external, + save_failed, +) + +if (bpy.app.version[0] == 5 and bpy.app.version[1] >= 2) or bpy.app.version[0] > 5: + + def test_node_panel_states(): + try: + inner = make_test_node_tree(name="panel state inner") + + # Leave a gap in the persistent UIDs. Those identifiers are + # read-only and are therefore not guaranteed to survive import. + removed_panel = inner.interface.new_panel(name="removed") + inner.interface.remove(removed_panel) + + open_panel = inner.interface.new_panel(name="open") + closed_panel = inner.interface.new_panel(name="closed") + inner.interface.new_socket( + name="Open Value", + in_out="INPUT", + socket_type="NodeSocketFloat", + parent=open_panel, + ) + inner.interface.new_socket( + name="Closed Value", + in_out="INPUT", + socket_type="NodeSocketFloat", + parent=closed_panel, + ) + + outer = make_test_node_tree(name="panel state outer") + group_node = outer.nodes.new(type="GeometryNodeGroup") + group_node.node_tree = inner + + assert [state.identifier for state in group_node.panel_states] == [ + open_panel.persistent_uid, + closed_panel.persistent_uid, + ] + group_node.panel_states[0].is_collapsed = False + group_node.panel_states[1].is_collapsed = True + + serialization = json.loads(export_to_string(outer.name)) + serialized_outer = next( + tree + for tree in serialization["node_trees"] + if tree["data"]["name"] == outer.name + ) + serialized_node = serialized_outer["data"]["nodes"]["data"]["items"][0] + serialized_states = serialized_node["data"]["panel_states"]["data"]["items"] + + assert [state["data"]["is_collapsed"] for state in serialized_states] == [ + False, + True, + ] + assert all("identifier" not in state["data"] for state in serialized_states) + + round_trip_without_external(outer.name) + except: + save_failed(test_node_panel_states.__name__) + raise