Core: Add packed linked data-blocks

This adds support for packed linked data. This is a key part of an improved
asset workflow in Blender.

Packed IDs remain considered as linked data (i.e. they cannot be edited),
but they are stored in the current blendfile. This means that they:
* Are not lost in case the library data becomes unavailable.
* Are not changed in case the library data is updated.

These packed IDs are de-duplicated across blend-files, so e.g. if a shot
file and several of its dependencies all use the same util geometry node,
there will be a single copy of that geometry node in the shot file.

In case there are several versions of a same ID (e.g. linked at different
moments from a same library, which has been modified in-between), there
will be several packed IDs.

Name collisions are averted by storing these packed IDs into a new type of
'archive' libraries (and their namespaces). These libraries:
* Only contain packed IDs.
* Are owned and managed by their 'real' library data-block, called an
  'archive parent'.

For more in-depth, technical design: #132167
UI/UX design: #140870

Co-authored-by: Bastien Montagne <bastien@blender.org>
Pull Request: https://projects.blender.org/blender/blender/pulls/133801
This commit is contained in:
Jacques Lucke
2025-09-26 10:53:40 +02:00
committed by Bastien Montagne
parent 44194579a5
commit 4e4976804e
55 changed files with 2225 additions and 214 deletions

View File

@@ -486,6 +486,202 @@ class TestBlendLibAppendReuseID(TestBlendLibLinkHelper):
self.assertEqual(len(bpy.data.collections), 0) # Scene's master collection is not listed here
class TestBlendLibPackedLinkedID(TestBlendLibLinkHelper):
def __init__(self, args):
super().__init__(args)
def test_link_pack_basic(self):
output_dir = self.args.output_dir
output_lib_path = self.init_lib_data_basic()
# Link of a single Object, and make it packed.
self.reset_blender()
link_dir = os.path.join(output_lib_path, "Object")
bpy.ops.wm.link(directory=link_dir, filename="LibMesh", instance_object_data=False)
self.assertEqual(len(bpy.data.libraries), 1)
library = bpy.data.libraries[0]
self.assertEqual(len(bpy.data.meshes), 1)
for me in bpy.data.meshes:
self.assertEqual(me.library, library)
self.assertEqual(me.users, 1)
self.assertEqual(len(bpy.data.objects), 1)
for ob in bpy.data.objects:
self.assertEqual(ob.library, library)
self.assertEqual(len(bpy.data.collections), 0) # Scene's master collection is not listed here
ob_packed = bpy.data.pack_linked_ids_hierarchy(bpy.data.objects[0])
# Need to ensure that the newly packed linked object is used, and kept in the scene.
bpy.data.scenes[0].collection.objects.link(ob_packed)
self.assertEqual(len(bpy.data.libraries), 2)
library = bpy.data.libraries[0]
archive_library = bpy.data.libraries[1]
def check_valid():
self.assertFalse(library.is_archive)
self.assertEqual(len(library.archive_libraries), 1)
self.assertEqual(library.archive_libraries[0], archive_library)
self.assertTrue(archive_library.is_archive)
self.assertEqual(archive_library.archive_parent_library, library)
self.assertEqual(len(bpy.data.meshes), 2)
self.assertEqual(bpy.data.meshes[0].library, library)
self.assertEqual(bpy.data.meshes[0].users, 1)
self.assertEqual(bpy.data.meshes[1].library, archive_library)
self.assertEqual(bpy.data.meshes[1].users, 1)
self.assertEqual(len(bpy.data.objects), 2)
self.assertEqual(bpy.data.objects[0].library, library)
self.assertEqual(bpy.data.objects[0].data, bpy.data.meshes[0])
self.assertEqual(bpy.data.objects[1].library, archive_library)
self.assertEqual(bpy.data.objects[1].data, bpy.data.meshes[1])
check_valid()
output_work_path = os.path.join(output_dir, self.unique_blendfile_name("blendfile"))
bpy.ops.wm.save_as_mainfile(filepath=output_work_path, check_existing=False, compress=False)
self.reset_blender()
bpy.ops.wm.open_mainfile(filepath=output_work_path, load_ui=False)
self.assertEqual(len(bpy.data.libraries), 2)
library = bpy.data.libraries[0]
archive_library = bpy.data.libraries[1]
check_valid()
def test_link_pack_indirect(self):
# Test handling of indirectly linked packed data (when packed in another library),
# packing linked data using other packed linked data, etc.
output_dir = self.args.output_dir
output_lib_path = self.init_lib_data_packed_indirect_lib()
# Link of a single Object, and make it packed.
self.reset_blender()
link_dir = os.path.join(output_lib_path, "Object")
bpy.ops.wm.link(directory=link_dir, filename="LibMesh", instance_object_data=False)
# Directly linked library, indirectly linked one (though empty), and its packed archive version.
self.assertEqual(len(bpy.data.libraries), 3)
library = bpy.data.libraries[0]
library_indirect = bpy.data.libraries[1]
library_indirect_archive = bpy.data.libraries[2]
def check_valid():
self.assertFalse(library.is_archive)
self.assertFalse(library_indirect.is_archive)
self.assertTrue(library_indirect_archive.is_archive)
self.assertTrue(library_indirect_archive.name in library_indirect.archive_libraries)
self.assertEqual(len(bpy.data.images), 1)
for im in bpy.data.images:
self.assertEqual(im.library, library_indirect_archive)
self.assertEqual(im.users, 1)
self.assertTrue(im.is_linked_packed)
self.assertEqual(len(bpy.data.materials), 1)
for ma in bpy.data.materials:
self.assertEqual(ma.library, library_indirect_archive)
self.assertEqual(ma.users, 1)
self.assertTrue(ma.is_linked_packed)
self.assertEqual(len(bpy.data.meshes), 1)
for me in bpy.data.meshes:
self.assertEqual(me.library, library)
self.assertEqual(me.users, 1)
self.assertEqual(len(bpy.data.objects), 1)
for ob in bpy.data.objects:
self.assertEqual(ob.library, library)
self.assertEqual(len(bpy.data.collections), 0) # Scene's master collection is not listed here
check_valid()
output_work_path = os.path.join(output_dir, self.unique_blendfile_name("blendfile"))
bpy.ops.wm.save_as_mainfile(filepath=output_work_path, check_existing=False, compress=False)
self.reset_blender()
bpy.ops.wm.open_mainfile(filepath=output_work_path, load_ui=False)
self.assertEqual(len(bpy.data.libraries), 3)
library = bpy.data.libraries[0]
library_indirect = bpy.data.libraries[1]
library_indirect_archive = bpy.data.libraries[2]
check_valid()
ob_packed = bpy.data.pack_linked_ids_hierarchy(bpy.data.objects[0])
# Need to ensure that the newly packed linked object is used, and kept in the scene.
bpy.data.scenes[0].collection.objects.link(ob_packed)
self.assertEqual(len(bpy.data.libraries), 4)
# Due to ID name sorting, the newly ceratedt archive library should be second now, after its parent one.
archive_library = bpy.data.libraries[1]
def check_valid():
self.assertFalse(library.is_archive)
self.assertEqual(len(library.archive_libraries), 1)
self.assertEqual(library.archive_libraries[0], archive_library)
self.assertTrue(archive_library.is_archive)
self.assertEqual(archive_library.archive_parent_library, library)
self.assertFalse(library_indirect.is_archive)
self.assertTrue(library_indirect_archive.is_archive)
self.assertTrue(library_indirect_archive.name in library_indirect.archive_libraries)
self.assertEqual(len(bpy.data.images), 1)
for im in bpy.data.images:
self.assertEqual(im.library, library_indirect_archive)
self.assertEqual(im.users, 1)
self.assertTrue(im.is_linked_packed)
self.assertEqual(len(bpy.data.materials), 1)
for ma in bpy.data.materials:
self.assertEqual(ma.library, library_indirect_archive)
self.assertEqual(ma.users, 2)
self.assertTrue(ma.is_linked_packed)
self.assertEqual(len(bpy.data.meshes), 2)
self.assertEqual(bpy.data.meshes[0].library, library)
self.assertEqual(bpy.data.meshes[0].users, 1)
self.assertEqual(bpy.data.meshes[1].library, archive_library)
self.assertEqual(bpy.data.meshes[1].users, 1)
self.assertEqual(len(bpy.data.objects), 2)
self.assertEqual(bpy.data.objects[0].library, library)
self.assertEqual(bpy.data.objects[0].data, bpy.data.meshes[0])
self.assertEqual(bpy.data.objects[1].library, archive_library)
self.assertEqual(bpy.data.objects[1].data, bpy.data.meshes[1])
self.assertEqual(len(bpy.data.collections), 0) # Scene's master collection is not listed here
check_valid()
bpy.ops.wm.save_as_mainfile(filepath=output_work_path, check_existing=False, compress=False)
self.reset_blender()
bpy.ops.wm.open_mainfile(filepath=output_work_path, load_ui=False)
self.assertEqual(len(bpy.data.libraries), 4)
library = bpy.data.libraries[0]
archive_library = bpy.data.libraries[1]
library_indirect = bpy.data.libraries[2]
library_indirect_archive = bpy.data.libraries[3]
check_valid()
class TestBlendLibLibraryReload(TestBlendLibLinkHelper):
def __init__(self, args):
@@ -610,6 +806,34 @@ class TestBlendLibDataLibrariesLoadLink(TestBlendLibDataLibrariesLoad):
self.assertIsNotNone(bpy.data.collections[0].library)
class TestBlendLibDataLibrariesLoadPack(TestBlendLibDataLibrariesLoad):
def test_libload_pack(self):
output_lib_path = self.do_libload_init()
# Cannot create overrides on packed linked data currently.
self.assertRaises(ValueError,
self.do_libload, filepath=output_lib_path, link=True, pack=True, create_liboverrides=True)
self.do_libload(filepath=output_lib_path, link=True, pack=True, create_liboverrides=False)
self.assertEqual(len(bpy.data.meshes), 1)
self.assertEqual(len(bpy.data.objects), 1) # This code does no instantiation.
self.assertEqual(len(bpy.data.collections), 1)
# One archive library for the packed data-blocks and the reference library.
self.assertEqual(len(bpy.data.libraries), 2)
# Packed dat should be owned by archive library.
packed_mesh = bpy.data.meshes[0]
packed_object = bpy.data.objects[0]
packed_collection = bpy.data.collections[0]
link_library = bpy.data.libraries[0]
archive_library = bpy.data.libraries[1]
self.assertEqual(packed_mesh.library, archive_library)
self.assertEqual(packed_object.library, archive_library)
self.assertEqual(packed_collection.library, archive_library)
self.assertTrue(archive_library.is_archive)
self.assertFalse(link_library.is_archive)
class TestBlendLibDataLibrariesLoadLibOverride(TestBlendLibDataLibrariesLoad):
def test_libload_liboverride(self):
@@ -741,11 +965,14 @@ TESTS = (
TestBlendLibAppendBasic,
TestBlendLibAppendReuseID,
TestBlendLibPackedLinkedID,
TestBlendLibLibraryReload,
TestBlendLibLibraryRelocate,
TestBlendLibDataLibrariesLoadAppend,
TestBlendLibDataLibrariesLoadLink,
TestBlendLibDataLibrariesLoadPack,
TestBlendLibDataLibrariesLoadLibOverride,
)

View File

@@ -182,3 +182,52 @@ class TestBlendLibLinkHelper(TestHelper):
bpy.ops.wm.save_as_mainfile(filepath=output_lib_path, check_existing=False, compress=False)
return output_lib_path
def init_lib_data_packed_indirect_lib(self):
output_dir = self.args.output_dir
self.ensure_path(output_dir)
# Create an indirect library containing a material, and an image texture.
self.reset_blender()
self.gen_indirect_library_data_()
# Take care to keep the name unique so multiple test jobs can run at once.
output_lib_path = os.path.join(output_dir, self.unique_blendfile_name("blendlib_indirect_material"))
bpy.ops.wm.save_as_mainfile(filepath=output_lib_path, check_existing=False, compress=False)
# Create a main library containing object etc., and linking material from indirect library.
self.reset_blender()
self.gen_library_data_()
link_dir = os.path.join(output_lib_path, "Material")
bpy.ops.wm.link(directory=link_dir, filename="LibMaterial")
ma = bpy.data.pack_linked_ids_hierarchy(bpy.data.materials[0])
me = bpy.data.meshes[0]
me.materials.append(ma)
bpy.ops.outliner.orphans_purge()
self.assertEqual(len(bpy.data.materials), 1)
self.assertTrue(bpy.data.materials[0].is_linked_packed)
self.assertEqual(len(bpy.data.images), 1)
self.assertTrue(bpy.data.images[0].is_linked_packed)
self.assertEqual(len(bpy.data.libraries), 2)
self.assertFalse(bpy.data.libraries[0].is_archive)
self.assertTrue(bpy.data.libraries[1].is_archive)
self.assertIn(bpy.data.libraries[1].name, bpy.data.libraries[0].archive_libraries)
output_dir = self.args.output_dir
self.ensure_path(output_dir)
# Take care to keep the name unique so multiple test jobs can run at once.
output_lib_path = os.path.join(output_dir, self.unique_blendfile_name("blendlib_indirect_main"))
bpy.ops.wm.save_as_mainfile(filepath=output_lib_path, check_existing=False, compress=False)
return output_lib_path