diff --git a/.gitignore b/.gitignore index 7b54387..e7ac114 100644 --- a/.gitignore +++ b/.gitignore @@ -215,4 +215,7 @@ __marimo__/ # Streamlit .streamlit/secrets.toml -packages/tree_clipper_addon/src/tree_clipper_addon/_vendor/ \ No newline at end of file +packages/tree_clipper_addon/src/tree_clipper_addon/_vendor/ + +# Build artifacts +*.zip \ No newline at end of file diff --git a/packages/tree_clipper/pyproject.toml b/packages/tree_clipper/pyproject.toml index 1a2c7e9..f6bf457 100644 --- a/packages/tree_clipper/pyproject.toml +++ b/packages/tree_clipper/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tree-clipper" -version = "0.1.6" +version = "0.2.0" dependencies = [] maintainers = [ { name="Algebraic", email="tree.clipper@algebraic.games" }, diff --git a/packages/tree_clipper/src/tree_clipper/common.py b/packages/tree_clipper/src/tree_clipper/common.py index 34bc622..6d533d2 100644 --- a/packages/tree_clipper/src/tree_clipper/common.py +++ b/packages/tree_clipper/src/tree_clipper/common.py @@ -13,7 +13,7 @@ # these fields are in the top level JSON object BLENDER_VERSION = "blender_version" TREE_CLIPPER_VERSION = "tree_clipper_version" -CURRENT_TREE_CLIPPER_VERSION = "0.1.6" # tested to match pyproject.toml +CURRENT_TREE_CLIPPER_VERSION = "0.2.0" # tested to match pyproject.toml MATERIAL_NAME = "name" TREES = "node_trees" EXTERNAL = "external" diff --git a/packages/tree_clipper/src/tree_clipper/import_nodes.py b/packages/tree_clipper/src/tree_clipper/import_nodes.py index d020c57..b0c2ab3 100644 --- a/packages/tree_clipper/src/tree_clipper/import_nodes.py +++ b/packages/tree_clipper/src/tree_clipper/import_nodes.py @@ -195,8 +195,13 @@ def _import_property_simple( ): if self.debug_prints: print(f"{from_root.to_str()}: defer setting enum default for now") + location = from_root.to_str() self.set_socket_enum_defaults.append( - lambda: setattr(getter(), identifier, serialization) + ( + lambda: setattr(getter(), identifier, serialization), + location, + serialization, + ) ) return @@ -518,8 +523,14 @@ def getter() -> bpy.types.ShaderNodeTree: ) self.current_tree = None - for func in self.set_socket_enum_defaults: - func() + for func, location, value in self.set_socket_enum_defaults: + try: + func() + except (TypeError, ValueError) as e: + self.report.warnings.append( + f"{location}: could not set enum default value {value!r} " + f"(valid options may be unavailable in this context): {e}" + ) self.set_socket_enum_defaults.clear() self.report.last_getter = getter diff --git a/packages/tree_clipper_addon/src/tree_clipper_addon/__init__.py b/packages/tree_clipper_addon/src/tree_clipper_addon/__init__.py index bfb20f9..4eed79a 100644 --- a/packages/tree_clipper_addon/src/tree_clipper_addon/__init__.py +++ b/packages/tree_clipper_addon/src/tree_clipper_addon/__init__.py @@ -28,6 +28,7 @@ SCENE_OT_Tree_Clipper_Import_Cache, SCENE_OT_Tree_Clipper_Import_File_Prepare, SCENE_OT_Tree_Clipper_Import_Clipboard_Prepare, + SCENE_OT_Tree_Clipper_Paste_As_Nodes, SCENE_OT_Tree_Clipper_Import_Modal, ) @@ -48,6 +49,7 @@ SCENE_OT_Tree_Clipper_Import_Cache, SCENE_OT_Tree_Clipper_Import_File_Prepare, SCENE_OT_Tree_Clipper_Import_Clipboard_Prepare, + SCENE_OT_Tree_Clipper_Paste_As_Nodes, SCENE_PT_Tree_Clipper_Panel, TreeClipperPreferences, ] diff --git a/packages/tree_clipper_addon/src/tree_clipper_addon/blender_manifest.toml b/packages/tree_clipper_addon/src/tree_clipper_addon/blender_manifest.toml index 60aeac5..6fb989d 100644 --- a/packages/tree_clipper_addon/src/tree_clipper_addon/blender_manifest.toml +++ b/packages/tree_clipper_addon/src/tree_clipper_addon/blender_manifest.toml @@ -3,7 +3,7 @@ schema_version = "1.0.0" # Example of manifest file for a Blender extension # Change the values according to your extension id = "tree_clipper" -version = "0.1.6" # should match tree_clipper (core logic) +version = "0.2.0" # should match tree_clipper (core logic) name = "Tree Clipper" tagline = "Export and import Blender node trees as JSON" maintainer = "Algebraic " diff --git a/packages/tree_clipper_addon/src/tree_clipper_addon/operators_import.py b/packages/tree_clipper_addon/src/tree_clipper_addon/operators_import.py index e3a20e6..69adad1 100644 --- a/packages/tree_clipper_addon/src/tree_clipper_addon/operators_import.py +++ b/packages/tree_clipper_addon/src/tree_clipper_addon/operators_import.py @@ -23,6 +23,48 @@ _INTERMEDIATE_IMPORT_CACHE = None TIMER = None +# Whether the next import should be unpacked directly into the active node tree +# (loose nodes on the canvas) instead of being wrapped in a single group node. +_UNPACK_INTO_ACTIVE_TREE = False + + +def _prepare_import_cache( + operator: bpy.types.Operator, + *, + string: str | None = None, + file_path: "Path | None" = None, +) -> bool: + """Build the import cache from the clipboard or a file. + + Reports a readable error (instead of raising a traceback) when the input is + empty or doesn't contain a valid Tree Clipper node tree. Returns True on + success, False if the calling operator should cancel. + """ + global _INTERMEDIATE_IMPORT_CACHE + source = "clipboard" if string is not None else "file" + + if string is not None and not string.strip(): + _INTERMEDIATE_IMPORT_CACHE = None + operator.report( + {"ERROR"}, + "The clipboard is empty - copy a node tree (or a Tree Clipper string) first.", + ) + return False + + try: + _INTERMEDIATE_IMPORT_CACHE = ImportIntermediate( + string=string, file_path=file_path + ) + except (ValueError, KeyError, RuntimeError, OSError) as exception: + _INTERMEDIATE_IMPORT_CACHE = None + operator.report( + {"ERROR"}, + f"Could not read a Tree Clipper node tree from the {source}: {exception}", + ) + return False + + return True + class SCENE_OT_Tree_Clipper_Import_File_Prepare(bpy.types.Operator): bl_idname = "scene.tree_clipper_import_file_prepare" @@ -43,8 +85,10 @@ def invoke( def execute( self, context: bpy.types.Context ) -> set["rna_enums.OperatorReturnItems"]: - global _INTERMEDIATE_IMPORT_CACHE - _INTERMEDIATE_IMPORT_CACHE = ImportIntermediate(file_path=Path(self.input_file)) + global _UNPACK_INTO_ACTIVE_TREE + if not _prepare_import_cache(self, file_path=Path(self.input_file)): + return {"CANCELLED"} + _UNPACK_INTO_ACTIVE_TREE = False # seems impossible to use bl_idname here bpy.ops.scene.tree_clipper_import_cache("INVOKE_DEFAULT") # ty: ignore[unresolved-attribute] @@ -59,10 +103,38 @@ class SCENE_OT_Tree_Clipper_Import_Clipboard_Prepare(bpy.types.Operator): def execute( self, context: bpy.types.Context ) -> set["rna_enums.OperatorReturnItems"]: - global _INTERMEDIATE_IMPORT_CACHE - _INTERMEDIATE_IMPORT_CACHE = ImportIntermediate( - string=bpy.context.window_manager.clipboard # ty:ignore[possibly-missing-attribute] - ) + global _UNPACK_INTO_ACTIVE_TREE + if not _prepare_import_cache( + self, + string=bpy.context.window_manager.clipboard, # ty:ignore[possibly-missing-attribute] + ): + return {"CANCELLED"} + _UNPACK_INTO_ACTIVE_TREE = False + + # seems impossible to use bl_idname here + bpy.ops.scene.tree_clipper_import_cache("INVOKE_DEFAULT") # ty: ignore[unresolved-attribute] + return {"FINISHED"} + + +class SCENE_OT_Tree_Clipper_Paste_As_Nodes(bpy.types.Operator): + bl_idname = "scene.tree_clipper_paste_as_nodes" + bl_label = "Paste as Nodes" + bl_description = ( + "Import the node tree from the clipboard and unpack it directly into the " + "active node tree, instead of wrapping it in a single group node" + ) + bl_options = {"REGISTER"} + + def execute( + self, context: bpy.types.Context + ) -> set["rna_enums.OperatorReturnItems"]: + global _UNPACK_INTO_ACTIVE_TREE + if not _prepare_import_cache( + self, + string=bpy.context.window_manager.clipboard, # ty:ignore[possibly-missing-attribute] + ): + return {"CANCELLED"} + _UNPACK_INTO_ACTIVE_TREE = True # seems impossible to use bl_idname here bpy.ops.scene.tree_clipper_import_cache("INVOKE_DEFAULT") # ty: ignore[unresolved-attribute] @@ -250,6 +322,10 @@ def modal(self, context: bpy.types.Context, event: bpy.types.Event): _INTERMEDIATE_IMPORT_CACHE = None - post_import(context=context, event=event, report=report) + global _UNPACK_INTO_ACTIVE_TREE + unpack = _UNPACK_INTO_ACTIVE_TREE + _UNPACK_INTO_ACTIVE_TREE = False + + post_import(context=context, event=event, report=report, unpack=unpack) return {"FINISHED"} diff --git a/packages/tree_clipper_addon/src/tree_clipper_addon/panel.py b/packages/tree_clipper_addon/src/tree_clipper_addon/panel.py index bb6793f..2ba3900 100644 --- a/packages/tree_clipper_addon/src/tree_clipper_addon/panel.py +++ b/packages/tree_clipper_addon/src/tree_clipper_addon/panel.py @@ -3,6 +3,7 @@ from .operators_export import SCENE_OT_Tree_Clipper_Export_Prepare from .operators_import import ( SCENE_OT_Tree_Clipper_Import_Clipboard_Prepare, + SCENE_OT_Tree_Clipper_Paste_As_Nodes, SCENE_OT_Tree_Clipper_Import_File_Prepare, ) @@ -40,4 +41,5 @@ def draw(self, context: bpy.types.Context) -> None: import_col = self.layout.column() # ty:ignore[possibly-missing-attribute] import_col.operator(SCENE_OT_Tree_Clipper_Import_Clipboard_Prepare.bl_idname) + import_col.operator(SCENE_OT_Tree_Clipper_Paste_As_Nodes.bl_idname) import_col.operator(SCENE_OT_Tree_Clipper_Import_File_Prepare.bl_idname) diff --git a/packages/tree_clipper_addon/src/tree_clipper_addon/post_import.py b/packages/tree_clipper_addon/src/tree_clipper_addon/post_import.py index 3761661..d97ffbf 100644 --- a/packages/tree_clipper_addon/src/tree_clipper_addon/post_import.py +++ b/packages/tree_clipper_addon/src/tree_clipper_addon/post_import.py @@ -9,13 +9,302 @@ bpy.types.TextureNodeTree: bpy.types.TextureNodeGroup, } +GROUP_IO_IDNAMES = {"NodeGroupInput", "NodeGroupOutput"} + + +def _merge_interface( + src_tree: bpy.types.NodeTree, dst_tree: bpy.types.NodeTree +) -> dict[str, str]: + """Copy ``src_tree``'s interface onto ``dst_tree`` and return a map from each + source interface item identifier to the matching identifier on ``dst_tree``. + + Blender's node clipboard cannot carry a tree interface, so when the imported + nodes are pasted loose into the active tree their Group Input/Output nodes + would otherwise bind to the target tree's interface. We append the imported + sockets (reusing pre-existing ones so a second paste doesn't pile up + duplicates) and hand back the mapping so the group-io links can be rebuilt. + + We deliberately work with identifier strings rather than item references: + adding interface items can invalidate previously fetched Python wrappers. + """ + src_iface = src_tree.interface + dst_iface = dst_tree.interface + assert src_iface is not None and dst_iface is not None + + # Snapshot the sockets that already existed on the target as plain data and + # consume each match once, so repeated names (e.g. several "Scale" inputs) + # still map to distinct sockets. + existing = [ + (item.identifier, (item.name, item.in_out, item.socket_type)) + for item in dst_iface.items_tree + if item.item_type == "SOCKET" + ] + consumed: set[str] = set() + + def match_existing(key: tuple) -> str | None: + for identifier, existing_key in existing: + if identifier in consumed: + continue + if existing_key == key: + consumed.add(identifier) + return identifier + return None + + def find_panel(identifier: str) -> bpy.types.NodeTreeInterfacePanel | None: + for item in dst_iface.items_tree: + if item.item_type == "PANEL" and item.identifier == identifier: + return item # ty:ignore[invalid-return-type, unresolved-attribute] + return None + + iface_map: dict[str, str] = {} + panel_map: dict[str, str] = {} # src panel identifier -> dst panel identifier + + for item in src_iface.items_tree: + parent_dst_id: str | None = None + if item.parent is not None and item.parent.index >= 0: + parent_dst_id = panel_map.get(item.parent.identifier) # ty:ignore[unresolved-attribute] + + if item.item_type == "PANEL": + new_panel = dst_iface.new_panel( + name=item.name, + description=item.description, + default_closed=item.default_closed, + ) + new_identifier = new_panel.identifier # ty:ignore[unresolved-attribute] + if parent_dst_id is not None: + parent = find_panel(parent_dst_id) + if parent is not None: + dst_iface.move_to_parent( + item=new_panel, + parent=parent, + to_position=len(parent.interface_items), + ) + panel_map[item.identifier] = new_identifier + iface_map[item.identifier] = new_identifier + continue + + matched = match_existing((item.name, item.in_out, item.socket_type)) + if matched is not None: + iface_map[item.identifier] = matched + continue + + parent = find_panel(parent_dst_id) if parent_dst_id is not None else None + new_socket = dst_iface.new_socket( + name=item.name, + description=item.description, + in_out=item.in_out, + socket_type=item.socket_type, + parent=parent, + ) + iface_map[item.identifier] = new_socket.identifier + + return iface_map + def post_import( *, context: bpy.types.Context, event: bpy.types.Event, report: ImportReport, + unpack: bool = False, ) -> None: + def add_unpacked() -> str | None: + if not isinstance(context.space_data, bpy.types.SpaceNodeEditor): + return "Not a node editor." + + space = context.space_data + target_tree = space.edit_tree + if target_tree is None: + return "No active tree to attach to." + + assert report.last_getter is not None + imported_root = report.last_getter() + + if target_tree.bl_rna.identifier != imported_root.bl_rna.identifier: # ty:ignore[unresolved-attribute] + return f"Editor type is {target_tree.bl_rna.identifier}, but imported {imported_root.bl_rna.identifier}." # ty:ignore[unresolved-attribute] + + # Only a reusable node-group datablock can be unpacked; an embedded + # tree (e.g. a material's node tree) has no entry here and stays grouped. + if imported_root.name not in bpy.data.node_groups: + return "Imported tree is embedded (not a node group); cannot unpack it onto the canvas." + + def is_group_io(node: bpy.types.Node) -> bool: + return node.bl_idname in GROUP_IO_IDNAMES + + # A self-contained group carries its own interface *and* Group + # Input/Output nodes. Pasted loose it would duplicate the host tree's + # interface nodes, blend unrelated interface sockets together and leave a + # dead "Unused Output", so such content belongs in a group node: hand it + # to the grouped path instead of unpacking it. + interface = imported_root.interface # ty:ignore[unresolved-attribute] + if ( + interface is not None + and len(interface.items_tree) > 0 + and any(is_group_io(node) for node in imported_root.nodes) # ty:ignore[unresolved-attribute] + ): + return add_as_group() + + # Selection is expressed by what's present in the magic string: the web + # renderer filters unselected nodes out on copy, so any Group Input/Output + # node that survived into the import was meant to come across. We keep them + # (with the group's interface) whenever they're present, and otherwise + # skip the interface merge so we don't pollute the target tree. + keep_group_io = any( + is_group_io(node) + for node in imported_root.nodes # ty:ignore[unresolved-attribute] + ) + + # The clipboard can't carry a tree interface or its Group Input/Output + # links, so when we keep those nodes we capture both before copying. We + # tag every imported node with a unique sentinel name (the clipboard + # preserves unique names) to pair it with its pasted copy afterwards, and + # record each link touching a group-io node to rebuild once the interface + # is in place. + sentinel_prefix = "_tc_unpack_sentinel_" + original_names: dict[str, str] = {} + # (from_sentinel, from_socket_id, from_via_iface, + # to_sentinel, to_socket_id, to_via_iface) + group_io_links: list[tuple[str, str, bool, str, str, bool]] = [] + if keep_group_io: + for index, node in enumerate(imported_root.nodes): # ty:ignore[unresolved-attribute] + sentinel = f"{sentinel_prefix}{index}" + original_names[sentinel] = node.name + node.name = sentinel + + for link in imported_root.links: # ty:ignore[unresolved-attribute] + if not (is_group_io(link.from_node) or is_group_io(link.to_node)): + continue + group_io_links.append( + ( + link.from_node.name, + link.from_socket.identifier, + is_group_io(link.from_node), + link.to_node.name, + link.to_socket.identifier, + is_group_io(link.to_node), + ) + ) + + # Reproduce the imported nodes in the active tree via Blender's own node + # clipboard, so links, nested groups and every property come across + # faithfully. We briefly push the imported root onto the editor path to + # copy from it, then pop back so the user's current location is restored. + try: + space.path.append(imported_root) # ty:ignore[possibly-missing-attribute] + try: + for node in imported_root.nodes: # ty:ignore[unresolved-attribute] + node.select = True + bpy.ops.node.clipboard_copy() + finally: + space.path.pop() # ty:ignore[possibly-missing-attribute] + except RuntimeError as exception: + for sentinel, name in original_names.items(): + imported_root.nodes[sentinel].name = name # ty:ignore[unresolved-attribute] + return f"Could not copy imported nodes: {exception}" + + for node in target_tree.nodes: # ty:ignore[unresolved-attribute] + node.select = False + + bpy.ops.node.clipboard_paste() + + # Bring the imported group's interface onto the target tree so the pasted + # Group Input/Output nodes expose the right sockets, then rebuild the + # links the clipboard dropped. Sentinel names still identify the freshly + # pasted nodes at this point; we restore the original names afterwards. + if keep_group_io: + iface_map = _merge_interface(imported_root, target_tree) # ty:ignore[invalid-argument-type] + pasted_by_sentinel = { + node.name: node + for node in target_tree.nodes # ty:ignore[unresolved-attribute] + if node.name in original_names + } + + def resolve_socket( + node: bpy.types.Node, + socket_id: str, + *, + want_input: bool, + via_iface: bool, + ) -> bpy.types.NodeSocket | None: + if via_iface: + mapped = iface_map.get(socket_id) + if mapped is None: + return None + socket_id = mapped + sockets = node.inputs if want_input else node.outputs + for socket in sockets: + if socket.identifier == socket_id: + return socket + return None + + for ( + from_sentinel, + from_socket_id, + from_via_iface, + to_sentinel, + to_socket_id, + to_via_iface, + ) in group_io_links: + from_node = pasted_by_sentinel.get(from_sentinel) + to_node = pasted_by_sentinel.get(to_sentinel) + if from_node is None or to_node is None: + continue + from_socket = resolve_socket( + from_node, from_socket_id, want_input=False, via_iface=from_via_iface + ) + to_socket = resolve_socket( + to_node, to_socket_id, want_input=True, via_iface=to_via_iface + ) + if from_socket is not None and to_socket is not None: + target_tree.links.new(from_socket, to_socket) # ty:ignore[unresolved-attribute] + + # Hand the user-facing names back to the pasted nodes (Blender + # resolves any collisions with existing nodes automatically). + for sentinel, name in original_names.items(): + node = pasted_by_sentinel.get(sentinel) + if node is not None: + node.name = name + + # Move the freshly pasted (and selected) nodes so their center sits at the + # mouse cursor. We only shift parentless nodes so framed nodes aren't moved + # twice. The region->view conversion and ui_scale division mirror the + # single-group placement in add_as_group (node space = view space / ui_scale). + pasted_roots = [ + node + for node in target_tree.nodes # ty:ignore[unresolved-attribute] + if node.select and node.parent is None + ] + if pasted_roots: + target = context.region.view2d.region_to_view( # ty:ignore[possibly-missing-attribute] + event.mouse_region_x, event.mouse_region_y + ) + ui_scale = context.preferences.system.ui_scale # ty:ignore[possibly-missing-attribute] + target_x = target[0] / ui_scale + target_y = target[1] / ui_scale + + center_x = ( + min(node.location.x for node in pasted_roots) + + max(node.location.x for node in pasted_roots) + ) / 2 + center_y = ( + min(node.location.y for node in pasted_roots) + + max(node.location.y for node in pasted_roots) + ) / 2 + + offset_x = target_x - center_x + offset_y = target_y - center_y + for node in pasted_roots: + node.location.x += offset_x + node.location.y += offset_y + + # The root group only existed to ferry the nodes across; the pasted nodes + # are now independent in the active tree. Nested groups are referenced, not + # owned by it, so they survive its removal. + bpy.data.node_groups.remove(imported_root) # ty:ignore[invalid-argument-type] + + # leave the user in a grab so they can reposition before dropping + bpy.ops.node.translate_attach_remove_on_cancel("INVOKE_DEFAULT") + def add_as_group() -> str | None: if not isinstance(context.space_data, bpy.types.SpaceNodeEditor): return "Not a node editor." @@ -50,7 +339,7 @@ def add_as_group() -> str | None: bpy.ops.node.translate_attach_remove_on_cancel("INVOKE_DEFAULT") - failure_reason = add_as_group() + failure_reason = add_unpacked() if unpack else add_as_group() if failure_reason is not None: def warn_popup(): diff --git a/packages/tree_clipper_addon/src/tree_clipper_addon/tree_clipper-0.2.0.zip b/packages/tree_clipper_addon/src/tree_clipper_addon/tree_clipper-0.2.0.zip new file mode 100644 index 0000000..e649001 Binary files /dev/null and b/packages/tree_clipper_addon/src/tree_clipper_addon/tree_clipper-0.2.0.zip differ diff --git a/uv.lock b/uv.lock index 96b71b4..0b933a0 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,9 @@ version = 1 revision = 3 requires-python = "==3.13.*" +[options] +prerelease-mode = "allow" + [manifest] members = [ "tree-clipper", @@ -31,9 +34,9 @@ dependencies = [ { name = "zstandard" }, ] wheels = [ - { url = "https://github.com/BradyAJohnston/dailybpy/releases/download/daily-2026-06-05/bpy-5.2.0b0-cp313-cp313-macosx_11_0_arm64.whl" }, - { url = "https://github.com/BradyAJohnston/dailybpy/releases/download/daily-2026-06-05/bpy-5.2.0b0-cp313-cp313-manylinux_2_39_x86_64.whl" }, - { url = "https://github.com/BradyAJohnston/dailybpy/releases/download/daily-2026-06-05/bpy-5.2.0b0-cp313-cp313-win_amd64.whl" }, + { url = "https://github.com/BradyAJohnston/dailybpy/releases/download/daily-2026-06-16/bpy-5.2.0b0-cp313-cp313-macosx_11_0_arm64.whl" }, + { url = "https://github.com/BradyAJohnston/dailybpy/releases/download/daily-2026-06-16/bpy-5.2.0b0-cp313-cp313-manylinux_2_39_x86_64.whl" }, + { url = "https://github.com/BradyAJohnston/dailybpy/releases/download/daily-2026-06-16/bpy-5.2.0b0-cp313-cp313-win_amd64.whl" }, ] [[package]] @@ -275,7 +278,7 @@ wheels = [ [[package]] name = "tree-clipper" -version = "0.1.6" +version = "0.2.0" source = { editable = "packages/tree_clipper" } [package.optional-dependencies]