From ba6956458ac0d3bebe99d144cfd81dbdea878c01 Mon Sep 17 00:00:00 2001 From: Vollkornaffe Date: Wed, 2 Sep 2026 18:38:05 +0200 Subject: [PATCH 1/3] feat: add util function to import to asset --- .../src/tree_clipper/import_to_asset_file.py | 91 +++++++++++++++++++ .../tests/test_import_to_asset_file.py | 82 +++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 packages/tree_clipper/src/tree_clipper/import_to_asset_file.py create mode 100644 packages/tree_clipper/tests/test_import_to_asset_file.py diff --git a/packages/tree_clipper/src/tree_clipper/import_to_asset_file.py b/packages/tree_clipper/src/tree_clipper/import_to_asset_file.py new file mode 100644 index 0000000..2ea37fc --- /dev/null +++ b/packages/tree_clipper/src/tree_clipper/import_to_asset_file.py @@ -0,0 +1,91 @@ +import uuid +from pathlib import Path +from typing import Literal + +import bpy + +from .import_nodes import ImportIntermediate, ImportParameters, ImportReport + + +def _collect_created_datablocks(report: ImportReport) -> set[bpy.types.ID]: + datablocks: set[bpy.types.ID] = { + bpy.data.node_groups[name] for name in report.renames_node_group.values() + } + if report.rename_material is not None: + _original_name, name = report.rename_material + datablocks.add(bpy.data.materials[name]) + return datablocks + + +def _remove_created_datablocks(datablocks: set[bpy.types.ID]) -> None: + for datablock in datablocks: + if isinstance(datablock, bpy.types.NodeTree): + bpy.data.node_groups.remove(datablock) + else: + assert isinstance(datablock, bpy.types.Material) + bpy.data.materials.remove(datablock) + + +def move_import_to_asset_file( + *, + report: ImportReport, + asset_directory: Path | None = None, + asset_file_path: Path | None = None, + path_remap: str = "NONE", + mark_as_asset: bool = True, + fake_user: bool = False, + compress: bool = False, +) -> Path: + datablocks = _collect_created_datablocks(report) + + if report.rename_material is not None: + _original_name, root_name = report.rename_material + bpy.data.materials[root_name].asset_mark() + else: + root_name = report.last_getter().name # ty:ignore[unresolved-attribute, call-non-callable] + bpy.data.node_groups[root_name].asset_mark() + + if asset_file_path is None: + assert asset_directory is not None + asset_file_path = ( + asset_directory + / f"{bpy.path.clean_name(root_name)}-{uuid.uuid4().hex[:8]}.blend" + ) + + bpy.data.libraries.write( + str(asset_file_path), + datablocks, + path_remap=path_remap, + fake_user=fake_user, + compress=compress, + ) + + # bpy.data.libraries.write() expands indirectly referenced datablocks (e.g. externals) + # into the asset file on its own; only the ones tree_clipper created here get moved out. + _remove_created_datablocks(datablocks) + + return asset_file_path + + +def import_to_asset_file( + *, + import_intermediate: ImportIntermediate, + parameters: ImportParameters, + asset_directory: Path | None = None, + asset_file_path: Path | None = None, + path_remap: str = "NONE", + mark_as_asset: bool = True, + fake_user: bool = False, + compress: bool = False, +) -> tuple[ImportReport, Path]: + report = import_intermediate.import_all(parameters) + + asset_file_path = move_import_to_asset_file( + report=report, + asset_file_path=asset_file_path, + path_remap=path_remap, + fake_user=fake_user, + compress=compress, + ) + + return report, asset_file_path diff --git a/packages/tree_clipper/tests/test_import_to_asset_file.py b/packages/tree_clipper/tests/test_import_to_asset_file.py new file mode 100644 index 0000000..f3dc1e3 --- /dev/null +++ b/packages/tree_clipper/tests/test_import_to_asset_file.py @@ -0,0 +1,82 @@ +from pathlib import Path + +import bpy +from tree_clipper.export_nodes import ExportIntermediate, ExportParameters +from tree_clipper.import_nodes import ImportIntermediate, ImportParameters +from tree_clipper.import_to_asset_file import import_to_asset_file +from tree_clipper.specific_handlers import BUILT_IN_EXPORTER, BUILT_IN_IMPORTER + +from tests.util import export_to_string, make_test_node_tree + + +def test_import_to_asset_file(tmp_path: Path): + tree = make_test_node_tree() + original_name = tree.name + + before = export_to_string(original_name) + + # start fresh, as if importing into a different file + bpy.data.node_groups.remove(tree) + + asset_file_path = tmp_path / "asset.blend" + + import_intermediate = ImportIntermediate(string=before) + report, _file_path = import_to_asset_file( + import_intermediate=import_intermediate, + parameters=ImportParameters( + specific_handlers=BUILT_IN_IMPORTER, + debug_prints=False, + ), + asset_file_path=asset_file_path, + ) + + imported_name = report.renames_node_group[original_name] + + assert imported_name not in bpy.data.node_groups + assert asset_file_path.exists() + + with bpy.data.libraries.load(str(asset_file_path)) as (data_from, _data_to): + assert imported_name in data_from.node_groups + + +def test_import_to_asset_file_material(tmp_path: Path): + mat = bpy.data.materials.new("test_material") + mat.use_nodes = True + original_name = mat.name + + export_intermediate = ExportIntermediate( + parameters=ExportParameters( + is_material=True, + name=original_name, + specific_handlers=BUILT_IN_EXPORTER, + export_sub_trees=True, + debug_prints=False, + write_from_roots=False, + ) + ) + while export_intermediate.step(): + pass + before = export_intermediate.export_to_str(compress=False, json_indent=4) + + bpy.data.materials.remove(mat) + + asset_file_path = tmp_path / "asset.blend" + + import_intermediate = ImportIntermediate(string=before) + report, _file_path = import_to_asset_file( + import_intermediate=import_intermediate, + parameters=ImportParameters( + specific_handlers=BUILT_IN_IMPORTER, + debug_prints=False, + ), + asset_file_path=asset_file_path, + ) + + assert report.rename_material is not None + _original_name, imported_name = report.rename_material + + assert imported_name not in bpy.data.materials + assert asset_file_path.exists() + + with bpy.data.libraries.load(str(asset_file_path)) as (data_from, _data_to): + assert imported_name in data_from.materials From 7b59e6fee43e58fe36e25090b0152ffa85598a6a Mon Sep 17 00:00:00 2001 From: Vollkornaffe Date: Wed, 2 Sep 2026 18:39:07 +0200 Subject: [PATCH 2/3] feat: add asset preferences and refactor a bit --- .../src/tree_clipper_addon/preferences.py | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/tree_clipper_addon/src/tree_clipper_addon/preferences.py b/packages/tree_clipper_addon/src/tree_clipper_addon/preferences.py index 35558fd..279135d 100644 --- a/packages/tree_clipper_addon/src/tree_clipper_addon/preferences.py +++ b/packages/tree_clipper_addon/src/tree_clipper_addon/preferences.py @@ -21,21 +21,46 @@ class TreeClipperPreferences(bpy.types.AddonPreferences): default=False, ) # type: ignore + import_as_asset: bpy.props.BoolProperty( + name="Import as Asset", + description="""Instead of having the imported trees in the current .blend file, +store the imports in a local asset library.") # type: ignore""", + default=True, + ) # type :ignore + + asset_directory: bpy.props.StringProperty( + name="Asset Directory", + description="When importing as asset, store the asset files in this directory.", + default=bpy.utils.extension_path_user( + __package__, # ty:ignore[invalid-argument-type] + path="assets", + create=True, + ), + subtype="DIR_PATH", + ) # ty:ignore[invalid-type-form] + def draw(self, context: bpy.types.Context) -> None: self.layout.prop(self, "max_clipboard_megabyte") self.layout.prop(self, "show_advanced_options") + self.layout.prop(self, "import_as_asset") + self.layout.prop(self, "asset_directory") + + +def _get_preferences(): + return bpy.context.preferences.addons.get(__package__).preferences # ty:ignore[unresolved-attribute] def get_max_clipboard_bytes(): - return ( - 1_000_000 - * bpy.context.preferences.addons.get( # ty:ignore[possibly-missing-attribute] - __package__ # ty:ignore[invalid-argument-type] - ).preferences.max_clipboard_megabyte - ) # ty:ignore[possibly-missing-attribute] + return 1_000_000 * _get_preferences().max_clipboard_megabyte def get_show_advanced_options(): - return bpy.context.preferences.addons.get( # ty:ignore[possibly-missing-attribute] - __package__ # ty:ignore[invalid-argument-type] - ).preferences.show_advanced_options # ty:ignore[possibly-missing-attribute] + return _get_preferences().show_advanced_options + + +def get_import_as_asset(): + return _get_preferences().import_as_asset + + +def get_asset_directory(): + return _get_preferences().asset_directory From f8cfc0083648d5880fb974d17bac336a5f1bd634 Mon Sep 17 00:00:00 2001 From: Vollkornaffe Date: Wed, 2 Sep 2026 18:39:43 +0200 Subject: [PATCH 3/3] feat: modify import to handle import as linked & packed asset --- .../src/tree_clipper/import_to_asset_file.py | 1 - .../tree_clipper_addon/operators_import.py | 40 ++++++++++++++++++- .../src/tree_clipper_addon/post_import.py | 7 +--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/tree_clipper/src/tree_clipper/import_to_asset_file.py b/packages/tree_clipper/src/tree_clipper/import_to_asset_file.py index 2ea37fc..32e87be 100644 --- a/packages/tree_clipper/src/tree_clipper/import_to_asset_file.py +++ b/packages/tree_clipper/src/tree_clipper/import_to_asset_file.py @@ -1,6 +1,5 @@ import uuid from pathlib import Path -from typing import Literal import bpy 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 f18f06b..cc772c8 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 @@ -16,11 +16,16 @@ ImportParameters, preliminary_check, ) +from ._vendor.tree_clipper.import_to_asset_file import move_import_to_asset_file from ._vendor.tree_clipper.specific_handlers import ( BUILT_IN_IMPORTER, ) from .post_import import post_import -from .preferences import get_show_advanced_options +from .preferences import ( + get_asset_directory, + get_import_as_asset, + get_show_advanced_options, +) _INTERMEDIATE_IMPORT_CACHE = None TIMER = None @@ -255,8 +260,39 @@ def modal(self, context: bpy.types.Context, event: bpy.types.Event): for warning in report.warnings: self.report({"WARNING"}, warning) + if not get_import_as_asset(): + post_import( + context=context, event=event, imported_root=report.last_getter() + ) + return {"FINISHED"} + + root_is_material = report.rename_material is not None + if root_is_material: + _original_name, root_name = report.rename_material + else: + root_name = report.last_getter().name + + asset_file_path = move_import_to_asset_file( + report=report, asset_directory=Path(get_asset_directory()) + ) + + with bpy.data.libraries.load(str(asset_file_path), link=True, pack=True) as ( + _data_from, + data_to, + ): + if root_is_material: + data_to.materials = [root_name] + else: + data_to.node_groups = [root_name] + + imported_root = ( + data_to.materials[0].node_tree + if root_is_material + else data_to.node_groups[0] + ) + _INTERMEDIATE_IMPORT_CACHE = None - post_import(context=context, event=event, report=report) + post_import(context=context, event=event, imported_root=imported_root) return {"FINISHED"} 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 8d9f48a..e6df407 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 @@ -1,7 +1,5 @@ import bpy -from ._vendor.tree_clipper.import_nodes import ImportReport - TREE_TYPE_TO_GROUP_TYPE = { bpy.types.CompositorNodeTree: bpy.types.CompositorNodeGroup, bpy.types.GeometryNodeTree: bpy.types.GeometryNodeGroup, @@ -14,7 +12,7 @@ def post_import( *, context: bpy.types.Context, event: bpy.types.Event, - report: ImportReport, + imported_root: bpy.types.NodeTree, ) -> None: def add_as_group() -> str | None: if not isinstance(context.space_data, bpy.types.SpaceNodeEditor): @@ -24,9 +22,6 @@ def add_as_group() -> str | None: if node_tree is None: return "No active tree to attach to." - assert report.last_getter is not None - imported_root = report.last_getter() - if node_tree.bl_rna.identifier != imported_root.bl_rna.identifier: # ty:ignore[unresolved-attribute] return f"Editor type is {node_tree.bl_rna.identifier}, but imported {imported_root.bl_rna.identifier}." # ty:ignore[unresolved-attribute]