-
Notifications
You must be signed in to change notification settings - Fork 2
Preserve node panel open/closed state in Blender 5.2 #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,6 +55,7 @@ | |
| PROP_TYPE_ENUM, | ||
| } | ||
| NODE_TREE = "node_tree" | ||
| PANEL_STATES = "panel_states" | ||
| DIMENSIONS = "dimensions" | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
| ), | ||
| ) | ||
|
|
||
|
Comment on lines
+486
to
+505
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not written anywhere explicitly, but the idea for With that in mind, this code seems out of place and should be handled in the specific_handlers file instead. It would also be more consistent to keep the comments shorter and link to the GitHub issue. |
||
| for prop in unhandled_properties: | ||
| from_root_prop = from_root.add_prop(prop) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
| ) | ||
|
|
||
|
Comment on lines
+415
to
+426
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to the export, it seems better in the specific_handlers file. |
||
| for identifier in unhandled_prop_ids: | ||
| prop = getter().bl_rna.properties[identifier] | ||
| prop_from_root = from_root.add_prop(prop) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
Comment on lines
+422
to
+423
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We usually check the Blender version instead. |
||
|
|
||
| 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) | ||
|
Comment on lines
+425
to
+455
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe this is unnecessary, as the default property handling should suffice. EDIT: the identifier is unexpectetly exported, this is a separate bug in the default handling.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The wording I've choosen of the comment on the The helper classes should always be used if possible. |
||
|
|
||
|
|
||
| class CompositorNodeGroupImporter(SpecificImporter[bpy.types.CompositorNodeGroup]): | ||
| def deserialize(self): | ||
| self.import_all_simple_writable_properties_and_list( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Comment on lines
+16
to
+40
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems easier to use a built-in node that has a collapsible panel, like |
||
|
|
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An empty collection is fine.
We generally export "too much", but that puts us on the safe side for extending the import later.
(In fact, it would have been nice if we were already exporting the panel_states)