diff --git a/blender_asset_tracer/pack/__init__.py b/blender_asset_tracer/pack/__init__.py index 3b832da..e499358 100755 --- a/blender_asset_tracer/pack/__init__.py +++ b/blender_asset_tracer/pack/__init__.py @@ -142,6 +142,8 @@ class Packer: self._top_level_paths = set() # type: typing.Set[pathlib.Path] self._output_path = None # type: typing.Optional[pathlib.PurePath] self._output_paths = [] # type: typing.List[pathlib.PurePath] + self._rewrite_targets = {} # type: typing.Dict[int, pathlib.PurePath] + """Destinations keyed by usage identity for path-only rewrites.""" # Filled by execute() self._file_transferer = None # type: typing.Optional[transfer.FileTransferer] @@ -303,6 +305,70 @@ class Packer: self._output_path = self._output_paths[0] # backward compat self._find_new_paths() self._group_rewrites() + self._normalise_linked_blend_paths() + + def _normalise_linked_blend_paths(self) -> None: + """Make absolute paths in copied libraries portable. + + Dependency tracing intentionally visits only datablocks used by the + top-level blend files. Linked blend files are nevertheless copied in + full, so unused datablocks can otherwise retain absolute paths such as + ``R:/project/asset.vdb``. Normalize those paths to their expected pack + locations without adding the referenced files to the pack. + """ + if self.relative_only: + return + + linked_blends = [ + path + for path, action in self._actions.items() + if path not in self._top_level_paths + and path.suffix.lower() == ".blend" + and action.new_path is not None + ] + def usage_key(usage: result.BlockUsage) -> typing.Tuple: + field_names: typing.Tuple[bytes, ...] + if usage.path_full_field is not None: + field_names = (usage.path_full_field.name.name_full,) + else: + assert usage.path_dir_field is not None + assert usage.path_base_field is not None + field_names = ( + usage.path_dir_field.name.name_full, + usage.path_base_field.name.name_full, + ) + return usage.block.addr_old, field_names + + for bfile_path in linked_blends: + action = self._actions[bfile_path] + existing_usages = {usage_key(usage) for usage in action.rewrites} + for usage in trace.local_deps(bfile_path): + key = usage_key(usage) + if key in existing_usages: + continue + if usage.asset_path.is_blendfile_relative(): + continue + + asset_path = usage.abspath + if self.keep_hierarchy: + asset_pp = self._target_path / bpathlib.strip_root(asset_path) + elif self._path_in_project(asset_path): + asset_pp = self._target_path / asset_path.relative_to(self.project) + else: + asset_pp = ( + self._target_path + / "_outside_project" + / bpathlib.strip_root(asset_path) + ) + + log.info( + "Normalizing uncopied path in %s: %s", + bfile_path, + usage.asset_path, + ) + action.rewrites.append(usage) + self._rewrite_targets[id(usage)] = pathlib.Path(asset_pp) + existing_usages.add(key) def _visit_sequence(self, asset_path: pathlib.Path, usage: result.BlockUsage): assert usage.is_sequence @@ -543,8 +609,10 @@ class Packer: for usage in action.rewrites: self._check_aborted() assert isinstance(usage, result.BlockUsage) - asset_pp = self._actions[usage.abspath].new_path - assert isinstance(asset_pp, pathlib.Path) + asset_pp = self._rewrite_targets.get(id(usage)) + if asset_pp is None: + asset_pp = self._actions[usage.abspath].new_path + assert isinstance(asset_pp, pathlib.PurePath) log.debug(" - %s is packed at %s", usage.asset_path, asset_pp) relpath = bpathlib.BlendPath.mkrelative(asset_pp, bfile_pp) diff --git a/blender_asset_tracer/trace/__init__.py b/blender_asset_tracer/trace/__init__.py index 3c86574..4a711b3 100644 --- a/blender_asset_tracer/trace/__init__.py +++ b/blender_asset_tracer/trace/__init__.py @@ -68,6 +68,18 @@ def deps( yield block_usage +def local_deps(bfilepath: pathlib.Path) -> typing.Iterator[result.BlockUsage]: + """Report assets from every local block, without following libraries. + + This is used for path-only normalization of linked blend files that are + copied whole into a pack. Unlike :func:`deps`, it deliberately inspects + unused datablocks too, but does not recursively collect their assets. + """ + bfile = blendfile.open_cached(bfilepath) + for block in asset_holding_blocks(bfile.blocks): + yield from blocks2assets.iter_assets(block) + + def asset_holding_blocks( blocks: typing.Iterable[blendfile.BlendFileBlock], ) -> typing.Iterator[blendfile.BlendFileBlock]: diff --git a/tests/test_pack.py b/tests/test_pack.py index a20358d..6bea930 100755 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -10,6 +10,7 @@ from unittest import mock from blender_asset_tracer import blendfile, pack, bpathlib from blender_asset_tracer.pack import progress +from blender_asset_tracer.trace import result from tests.abstract_test import AbstractBlendFileTest @@ -151,6 +152,49 @@ class PackTest(AbstractPackTest): self.assertEqual(b"LILib.002", rw_dbllink[1].block_name) self.assertEqual(b"//../material_textures.blend", rw_dbllink[1].asset_path) + def test_normalises_unused_path_without_packing_asset(self): + infile = self.blendfiles / "doubly_linked.blend" + linked_file = self.blendfiles / "linked_cube.blend" + uncopied_asset = self.blendfiles / "uncopied.blend" + + linked_bfile = blendfile.open_cached(linked_file) + library_block = linked_bfile.code_index[b"LI"][0] + _, path_field = library_block.get(b"name", return_field=True) + unused_usage = result.BlockUsage( + library_block, + bpathlib.BlendPath(str(uncopied_asset).encode()), + path_full_field=path_field, + ) + + def local_deps(path): + if path == linked_file: + return iter([unused_usage]) + return iter(()) + + with mock.patch( + "blender_asset_tracer.pack.trace.local_deps", side_effect=local_deps + ): + with pack.Packer(infile, self.blendfiles, self.tpath) as packer: + packer.strategise() + + self.assertNotIn(uncopied_asset, packer._actions) + self.assertIn( + unused_usage, packer._actions[linked_file].rewrites + ) + self.assertEqual( + self.tpath / "uncopied.blend", + packer._rewrite_targets[id(unused_usage)], + ) + + packer.execute() + + packed_linked = blendfile.open_cached( + self.tpath / "linked_cube.blend", assert_cached=False + ) + packed_library = packed_linked.code_index[b"LI"][0] + self.assertEqual(b"//uncopied.blend", packed_library[b"name"]) + self.assertFalse((self.tpath / "uncopied.blend").exists()) + def test_strategise_relative_only(self): infile = self.blendfiles / "absolute_path.blend"