update BAT module to 1.15 version

This commit is contained in:
pullusb
2023-01-10 11:41:55 +01:00
parent 85037c027a
commit fc32ebd453
34 changed files with 1878 additions and 1042 deletions
+14 -10
View File
@@ -28,28 +28,31 @@ log = logging.getLogger(__name__)
codes_to_skip = {
# These blocks never have external assets:
b'ID', b'WM', b'SN',
b"ID",
b"WM",
b"SN",
# These blocks are skipped for now, until we have proof they point to
# assets otherwise missed:
b'GR', b'WO', b'BR', b'LS',
b"GR",
b"WO",
b"BR",
b"LS",
}
def deps(bfilepath: pathlib.Path, progress_cb: typing.Optional[progress.Callback] = None) \
-> typing.Iterator[result.BlockUsage]:
def deps(
bfilepath: pathlib.Path, progress_cb: typing.Optional[progress.Callback] = None
) -> typing.Iterator[result.BlockUsage]:
"""Open the blend file and report its dependencies.
:param bfilepath: File to open.
:param progress_cb: Progress callback object.
"""
log.info('opening: %s', bfilepath)
bfile = blendfile.open_cached(bfilepath)
bi = file2blocks.BlockIterator()
if progress_cb:
bi.progress_cb = progress_cb
bfile = bi.open_blendfile(bfilepath)
# Remember which block usages we've reported already, without keeping the
# blocks themselves in memory.
@@ -64,8 +67,9 @@ def deps(bfilepath: pathlib.Path, progress_cb: typing.Optional[progress.Callback
yield block_usage
def asset_holding_blocks(blocks: typing.Iterable[blendfile.BlendFileBlock]) \
-> typing.Iterator[blendfile.BlendFileBlock]:
def asset_holding_blocks(
blocks: typing.Iterable[blendfile.BlendFileBlock],
) -> typing.Iterator[blendfile.BlendFileBlock]:
"""Generator, yield data blocks that could reference external assets."""
for block in blocks:
assert isinstance(block, blendfile.BlendFileBlock)
+53 -48
View File
@@ -39,17 +39,17 @@ _funcs_for_code = {} # type: typing.Dict[bytes, typing.Callable]
def iter_assets(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Generator, yield the assets used by this data block."""
assert block.code != b'DATA'
assert block.code != b"DATA"
try:
block_reader = _funcs_for_code[block.code]
except KeyError:
if block.code not in _warned_about_types:
log.debug('No reader implemented for block type %r', block.code.decode())
log.debug("No reader implemented for block type %r", block.code.decode())
_warned_about_types.add(block.code)
return
log.debug('Tracing block %r', block)
log.debug("Tracing block %r", block)
yield from block_reader(block)
@@ -70,8 +70,8 @@ def skip_packed(wrapped):
@functools.wraps(wrapped)
def wrapper(block: blendfile.BlendFileBlock, *args, **kwargs):
if block.get(b'packedfile', default=False):
log.debug('Datablock %r is packed; skipping', block.id_name)
if block.get(b"packedfile", default=False):
log.debug("Datablock %r is packed; skipping", block.id_name)
return
yield from wrapped(block, *args, **kwargs)
@@ -79,40 +79,38 @@ def skip_packed(wrapped):
return wrapper
@dna_code('CF')
@dna_code("CF")
def cache_file(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Cache file data blocks."""
path, field = block.get(b'filepath', return_field=True)
path, field = block.get(b"filepath", return_field=True)
yield result.BlockUsage(block, path, path_full_field=field)
@dna_code('IM')
@dna_code("IM")
@skip_packed
def image(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Image data blocks."""
# old files miss this
image_source = block.get(b'source', default=cdefs.IMA_SRC_FILE)
#print('------image_source: ', image_source)
#if image_source not in {cdefs.IMA_SRC_FILE, cdefs.IMA_SRC_SEQUENCE, cdefs.IMA_SRC_MOVIE}:
# return
if image_source not in {cdefs.IMA_SRC_FILE, cdefs.IMA_SRC_SEQUENCE, cdefs.IMA_SRC_MOVIE, cdefs.IMA_SRC_TILED}:
image_source = block.get(b"source", default=cdefs.IMA_SRC_FILE)
if image_source not in {
cdefs.IMA_SRC_FILE,
cdefs.IMA_SRC_SEQUENCE,
cdefs.IMA_SRC_MOVIE,
cdefs.IMA_SRC_TILED,
}:
log.debug("skiping image source type %s", image_source)
return
pathname, field = block.get(b'name', return_field=True)
#is_sequence = image_source == cdefs.IMA_SRC_SEQUENCE
if image_source in {cdefs.IMA_SRC_SEQUENCE, cdefs.IMA_SRC_TILED}:
is_sequence = True
else:
is_sequence = False
pathname, field = block.get(b"name", return_field=True)
is_sequence = image_source in {cdefs.IMA_SRC_SEQUENCE, cdefs.IMA_SRC_TILED}
#print('is_sequence: ', is_sequence)
yield result.BlockUsage(block, pathname, is_sequence, path_full_field=field)
@dna_code('LI')
@dna_code("LI")
def library(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Library data blocks."""
path, field = block.get(b'name', return_field=True)
path, field = block.get(b"name", return_field=True)
yield result.BlockUsage(block, path, path_full_field=field)
# The 'filepath' also points to the blend file. However, this is set to the
@@ -120,37 +118,37 @@ def library(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsag
# is thus not a property we have to report or rewrite.
@dna_code('ME')
@dna_code("ME")
def mesh(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Mesh data blocks."""
block_external = block.get_pointer((b'ldata', b'external'), None)
block_external = block.get_pointer((b"ldata", b"external"), None)
if block_external is None:
block_external = block.get_pointer((b'fdata', b'external'), None)
block_external = block.get_pointer((b"fdata", b"external"), None)
if block_external is None:
return
path, field = block_external.get(b'filename', return_field=True)
path, field = block_external.get(b"filename", return_field=True)
yield result.BlockUsage(block, path, path_full_field=field)
@dna_code('MC')
@dna_code("MC")
def movie_clip(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""MovieClip data blocks."""
path, field = block.get(b'name', return_field=True)
path, field = block.get(b"name", return_field=True)
# TODO: The assumption that this is not a sequence may not be true for all modifiers.
yield result.BlockUsage(block, path, is_sequence=False, path_full_field=field)
@dna_code('OB')
@dna_code("OB")
def object_block(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Object data blocks."""
ctx = modifier_walkers.ModifierContext(owner=block)
# 'ob->modifiers[...].filepath'
for mod_idx, block_mod in enumerate(iterators.modifiers(block)):
block_name = b'%s.modifiers[%d]' % (block.id_name, mod_idx)
mod_type = block_mod[b'modifier', b'type']
log.debug('Tracing modifier %s, type=%d', block_name.decode(), mod_type)
block_name = b"%s.modifiers[%d]" % (block.id_name, mod_idx)
mod_type = block_mod[b"modifier", b"type"]
log.debug("Tracing modifier %s, type=%d", block_name.decode(), mod_type)
try:
mod_handler = modifier_walkers.modifier_handlers[mod_type]
@@ -159,52 +157,59 @@ def object_block(block: blendfile.BlendFileBlock) -> typing.Iterator[result.Bloc
yield from mod_handler(ctx, block_mod, block_name)
@dna_code('SC')
@dna_code("SC")
def scene(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Scene data blocks."""
# Sequence editor is the only interesting bit.
block_ed = block.get_pointer(b'ed')
block_ed = block.get_pointer(b"ed")
if block_ed is None:
return
single_asset_types = {cdefs.SEQ_TYPE_MOVIE, cdefs.SEQ_TYPE_SOUND_RAM, cdefs.SEQ_TYPE_SOUND_HD}
single_asset_types = {
cdefs.SEQ_TYPE_MOVIE,
cdefs.SEQ_TYPE_SOUND_RAM,
cdefs.SEQ_TYPE_SOUND_HD,
}
asset_types = single_asset_types.union({cdefs.SEQ_TYPE_IMAGE})
for seq, seq_type in iterators.sequencer_strips(block_ed):
if seq_type not in asset_types:
continue
seq_strip = seq.get_pointer(b'strip')
seq_strip = seq.get_pointer(b"strip")
if seq_strip is None:
continue
seq_stripdata = seq_strip.get_pointer(b'stripdata')
seq_stripdata = seq_strip.get_pointer(b"stripdata")
if seq_stripdata is None:
continue
dirname, dn_field = seq_strip.get(b'dir', return_field=True)
basename, bn_field = seq_stripdata.get(b'name', return_field=True)
dirname, dn_field = seq_strip.get(b"dir", return_field=True)
basename, bn_field = seq_stripdata.get(b"name", return_field=True)
asset_path = bpathlib.BlendPath(dirname) / basename
is_sequence = seq_type not in single_asset_types
yield result.BlockUsage(seq_strip, asset_path,
is_sequence=is_sequence,
path_dir_field=dn_field,
path_base_field=bn_field)
yield result.BlockUsage(
seq_strip,
asset_path,
is_sequence=is_sequence,
path_dir_field=dn_field,
path_base_field=bn_field,
)
@dna_code('SO')
@dna_code("SO")
@skip_packed
def sound(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Sound data blocks."""
path, field = block.get(b'name', return_field=True)
path, field = block.get(b"name", return_field=True)
yield result.BlockUsage(block, path, path_full_field=field)
@dna_code('VF')
@dna_code("VF")
@skip_packed
def vector_font(block: blendfile.BlendFileBlock) -> typing.Iterator[result.BlockUsage]:
"""Vector Font data blocks."""
path, field = block.get(b'name', return_field=True)
if path == b'<builtin>': # builtin font
path, field = block.get(b"name", return_field=True)
if path == b"<builtin>": # builtin font
return
yield result.BlockUsage(block, path, path_full_field=field)
+137 -74
View File
@@ -30,26 +30,36 @@ from blender_asset_tracer import blendfile, cdefs
from blender_asset_tracer.blendfile import iterators
# Don't warn about these types at all.
_warned_about_types = {b'LI', b'DATA'}
_warned_about_types = {b"LI", b"DATA"}
_funcs_for_code = {} # type: typing.Dict[bytes, typing.Callable]
log = logging.getLogger(__name__)
def expand_block(block: blendfile.BlendFileBlock) -> typing.Iterator[blendfile.BlendFileBlock]:
def expand_block(
block: blendfile.BlendFileBlock,
) -> typing.Iterator[blendfile.BlendFileBlock]:
"""Generator, yield the data blocks used by this data block."""
try:
expander = _funcs_for_code[block.code]
except KeyError:
if block.code not in _warned_about_types:
log.debug('No expander implemented for block type %r', block.code.decode())
log.debug("No expander implemented for block type %r", block.code.decode())
_warned_about_types.add(block.code)
return
log.debug('Expanding block %r', block)
# Filter out falsy blocks, i.e. None values.
# Allowing expanders to yield None makes them more consise.
yield from filter(None, expander(block))
log.debug("Expanding block %r", block)
for dependency in expander(block):
if not dependency:
# Filter out falsy blocks, i.e. None values.
# Allowing expanders to yield None makes them more consise.
continue
if dependency.code == b"DATA":
log.warn(
"expander yielded block %s which will be ignored in later iteration",
dependency,
)
yield dependency
def dna_code(block_code: str):
@@ -65,83 +75,131 @@ def dna_code(block_code: str):
def _expand_generic_material(block: blendfile.BlendFileBlock):
array_len = block.get(b'totcol')
yield from block.iter_array_of_pointers(b'mat', array_len)
array_len = block.get(b"totcol")
yield from block.iter_array_of_pointers(b"mat", array_len)
def _expand_generic_mtex(block: blendfile.BlendFileBlock):
if not block.dna_type.has_field(b'mtex'):
if not block.dna_type.has_field(b"mtex"):
# mtex was removed in Blender 2.8
return
for mtex in block.iter_fixed_array_of_pointers(b'mtex'):
yield mtex.get_pointer(b'tex')
yield mtex.get_pointer(b'object')
for mtex in block.iter_fixed_array_of_pointers(b"mtex"):
yield mtex.get_pointer(b"tex")
yield mtex.get_pointer(b"object")
def _expand_generic_nodetree(block: blendfile.BlendFileBlock):
assert block.dna_type.dna_type_id == b'bNodeTree'
assert block.dna_type.dna_type_id == b"bNodeTree"
nodes = block.get_pointer((b"nodes", b"first"))
# See DNA_node_types.h
socket_types_with_value_pointer = {
cdefs.SOCK_OBJECT, # bNodeSocketValueObject
cdefs.SOCK_IMAGE, # bNodeSocketValueImage
cdefs.SOCK_COLLECTION, # bNodeSocketValueCollection
cdefs.SOCK_TEXTURE, # bNodeSocketValueTexture
cdefs.SOCK_MATERIAL, # bNodeSocketValueMaterial
}
nodes = block.get_pointer((b'nodes', b'first'))
for node in iterators.listbase(nodes):
if node[b'type'] == cdefs.CMP_NODE_R_LAYERS:
if node[b"type"] == cdefs.CMP_NODE_R_LAYERS:
continue
yield node
# The 'id' property points to whatever is used by the node
# (like the image in an image texture node).
yield node.get_pointer(b'id')
yield node.get_pointer(b"id")
# Default values of inputs can also point to ID datablocks.
inputs = node.get_pointer((b"inputs", b"first"))
for input in iterators.listbase(inputs):
if input[b"type"] not in socket_types_with_value_pointer:
continue
value_container = input.get_pointer(b"default_value")
if not value_container:
continue
value = value_container.get_pointer(b"value")
yield value
def _expand_generic_idprops(block: blendfile.BlendFileBlock):
"""Yield ID datablocks and their libraries referenced from ID properties."""
# TODO(@sybren): this code is very crude, and happens to work on ID
# properties of Geometry Nodes modifiers, which is what it was written for.
# It should probably be rewritten to properly iterate over & recurse into
# all groups.
settings_props = block.get_pointer((b"settings", b"properties"))
if not settings_props:
return
subprops = settings_props.get_pointer((b"data", b"group", b"first"))
for idprop in iterators.listbase(subprops):
if idprop[b"type"] != cdefs.IDP_ID:
continue
id_datablock = idprop.get_pointer((b"data", b"pointer"))
if not id_datablock:
continue
yield id_datablock
def _expand_generic_nodetree_id(block: blendfile.BlendFileBlock):
block_ntree = block.get_pointer(b'nodetree', None)
block_ntree = block.get_pointer(b"nodetree", None)
if block_ntree is not None:
yield from _expand_generic_nodetree(block_ntree)
def _expand_generic_animdata(block: blendfile.BlendFileBlock):
block_adt = block.get_pointer(b'adt')
block_adt = block.get_pointer(b"adt")
if block_adt:
yield block_adt.get_pointer(b'action')
yield block_adt.get_pointer(b"action")
# TODO, NLA
@dna_code('AR')
@dna_code("AR")
def _expand_armature(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
@dna_code('CU')
@dna_code("CU")
def _expand_curve(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_material(block)
for fieldname in (b'vfont', b'vfontb', b'vfonti', b'vfontbi',
b'bevobj', b'taperobj', b'textoncurve'):
for fieldname in (
b"vfont",
b"vfontb",
b"vfonti",
b"vfontbi",
b"bevobj",
b"taperobj",
b"textoncurve",
):
yield block.get_pointer(fieldname)
@dna_code('GR')
@dna_code("GR")
def _expand_group(block: blendfile.BlendFileBlock):
log.debug('Collection/group Block: %s (name=%s)', block, block.id_name)
log.debug("Collection/group Block: %s (name=%s)", block, block.id_name)
objects = block.get_pointer((b'gobject', b'first'))
objects = block.get_pointer((b"gobject", b"first"))
for item in iterators.listbase(objects):
yield item.get_pointer(b'ob')
yield item.get_pointer(b"ob")
# Recurse through child collections.
try:
children = block.get_pointer((b'children', b'first'))
children = block.get_pointer((b"children", b"first"))
except KeyError:
# 'children' was introduced in Blender 2.8 collections
pass
else:
for child in iterators.listbase(children):
subcoll = child.get_pointer(b'collection')
subcoll = child.get_pointer(b"collection")
if subcoll is None:
continue
if subcoll.dna_type_id == b'ID':
if subcoll.dna_type_id == b"ID":
# This issue happened while recursing a linked-in 'Hidden'
# collection in the Chimes set of the Spring project. Such
# collections named 'Hidden' were apparently created while
@@ -150,127 +208,132 @@ def _expand_group(block: blendfile.BlendFileBlock):
yield subcoll
continue
log.debug('recursing into child collection %s (name=%r, type=%r)',
subcoll, subcoll.id_name, subcoll.dna_type_name)
log.debug(
"recursing into child collection %s (name=%r, type=%r)",
subcoll,
subcoll.id_name,
subcoll.dna_type_name,
)
yield from _expand_group(subcoll)
@dna_code('LA')
@dna_code("LA")
def _expand_lamp(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree_id(block)
yield from _expand_generic_mtex(block)
@dna_code('MA')
@dna_code("MA")
def _expand_material(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree_id(block)
yield from _expand_generic_mtex(block)
try:
yield block.get_pointer(b'group')
yield block.get_pointer(b"group")
except KeyError:
# Groups were removed from Blender 2.8
pass
@dna_code('MB')
@dna_code("MB")
def _expand_metaball(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_material(block)
@dna_code('ME')
@dna_code("ME")
def _expand_mesh(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_material(block)
yield block.get_pointer(b'texcomesh')
yield block.get_pointer(b"texcomesh")
# TODO, TexFace? - it will be slow, we could simply ignore :S
@dna_code('NT')
@dna_code("NT")
def _expand_node_tree(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree(block)
@dna_code('OB')
@dna_code("OB")
def _expand_object(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_material(block)
yield block.get_pointer(b'data')
yield block.get_pointer(b"data")
if block[b'transflag'] & cdefs.OB_DUPLIGROUP:
yield block.get_pointer(b'dup_group')
if block[b"transflag"] & cdefs.OB_DUPLIGROUP:
yield block.get_pointer(b"dup_group")
yield block.get_pointer(b'proxy')
yield block.get_pointer(b'proxy_group')
yield block.get_pointer(b"proxy")
yield block.get_pointer(b"proxy_group")
# 'ob->pose->chanbase[...].custom'
block_pose = block.get_pointer(b'pose')
block_pose = block.get_pointer(b"pose")
if block_pose:
assert block_pose.dna_type.dna_type_id == b'bPose'
assert block_pose.dna_type.dna_type_id == b"bPose"
# sdna_index_bPoseChannel = block_pose.file.sdna_index_from_id[b'bPoseChannel']
channels = block_pose.get_pointer((b'chanbase', b'first'))
channels = block_pose.get_pointer((b"chanbase", b"first"))
for pose_chan in iterators.listbase(channels):
yield pose_chan.get_pointer(b'custom')
yield pose_chan.get_pointer(b"custom")
# Expand the objects 'ParticleSettings' via 'ob->particlesystem[...].part'
# sdna_index_ParticleSystem = block.file.sdna_index_from_id.get(b'ParticleSystem')
# if sdna_index_ParticleSystem is not None:
psystems = block.get_pointer((b'particlesystem', b'first'))
psystems = block.get_pointer((b"particlesystem", b"first"))
for psystem in iterators.listbase(psystems):
yield psystem.get_pointer(b'part')
yield psystem.get_pointer(b"part")
# Modifiers can also refer to other datablocks, which should also get expanded.
for block_mod in iterators.modifiers(block):
mod_type = block_mod[b'modifier', b'type']
mod_type = block_mod[b"modifier", b"type"]
# Currently only node groups are supported. If the support should expand
# to more types, something more intelligent than this should be made.
if mod_type == cdefs.eModifierType_Nodes:
yield block_mod.get_pointer(b'node_group')
yield from _expand_generic_idprops(block_mod)
yield block_mod.get_pointer(b"node_group")
@dna_code('PA')
@dna_code("PA")
def _expand_particle_settings(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_mtex(block)
block_ren_as = block[b'ren_as']
block_ren_as = block[b"ren_as"]
if block_ren_as == cdefs.PART_DRAW_GR:
yield block.get_pointer(b'dup_group')
yield block.get_pointer(b"dup_group")
elif block_ren_as == cdefs.PART_DRAW_OB:
yield block.get_pointer(b'dup_ob')
yield block.get_pointer(b"dup_ob")
@dna_code('SC')
@dna_code("SC")
def _expand_scene(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree_id(block)
yield block.get_pointer(b'camera')
yield block.get_pointer(b'world')
yield block.get_pointer(b'set', default=None)
yield block.get_pointer(b'clip', default=None)
yield block.get_pointer(b"camera")
yield block.get_pointer(b"world")
yield block.get_pointer(b"set", default=None)
yield block.get_pointer(b"clip", default=None)
# sdna_index_Base = block.file.sdna_index_from_id[b'Base']
# for item in bf_utils.iter_ListBase(block.get_pointer((b'base', b'first'))):
# yield item.get_pointer(b'object', sdna_index_refine=sdna_index_Base)
bases = block.get_pointer((b'base', b'first'))
bases = block.get_pointer((b"base", b"first"))
for base in iterators.listbase(bases):
yield base.get_pointer(b'object')
yield base.get_pointer(b"object")
# Sequence Editor
block_ed = block.get_pointer(b'ed')
block_ed = block.get_pointer(b"ed")
if not block_ed:
return
strip_type_to_field = {
cdefs.SEQ_TYPE_SCENE: b'scene',
cdefs.SEQ_TYPE_MOVIECLIP: b'clip',
cdefs.SEQ_TYPE_MASK: b'mask',
cdefs.SEQ_TYPE_SOUND_RAM: b'sound',
cdefs.SEQ_TYPE_SCENE: b"scene",
cdefs.SEQ_TYPE_MOVIECLIP: b"clip",
cdefs.SEQ_TYPE_MASK: b"mask",
cdefs.SEQ_TYPE_SOUND_RAM: b"sound",
}
for strip, strip_type in iterators.sequencer_strips(block_ed):
try:
@@ -280,14 +343,14 @@ def _expand_scene(block: blendfile.BlendFileBlock):
yield strip.get_pointer(field_name)
@dna_code('TE')
@dna_code("TE")
def _expand_texture(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree_id(block)
yield block.get_pointer(b'ima')
yield block.get_pointer(b"ima")
@dna_code('WO')
@dna_code("WO")
def _expand_world(block: blendfile.BlendFileBlock):
yield from _expand_generic_animdata(block)
yield from _expand_generic_nodetree_id(block)
+33 -28
View File
@@ -65,14 +65,21 @@ class BlockIterator:
self.progress_cb = progress.Callback()
def iter_blocks(self,
bfile: blendfile.BlendFile,
limit_to: typing.Set[blendfile.BlendFileBlock] = set(),
) -> typing.Iterator[blendfile.BlendFileBlock]:
def open_blendfile(self, bfilepath: pathlib.Path) -> blendfile.BlendFile:
"""Open a blend file, sending notification about this to the progress callback."""
log.info("opening: %s", bfilepath)
self.progress_cb.trace_blendfile(bfilepath)
return blendfile.open_cached(bfilepath)
def iter_blocks(
self,
bfile: blendfile.BlendFile,
limit_to: typing.Set[blendfile.BlendFileBlock] = set(),
) -> typing.Iterator[blendfile.BlendFileBlock]:
"""Expand blocks with dependencies from other libraries."""
self.progress_cb.trace_blendfile(bfile.filepath)
log.info('inspecting: %s', bfile.filepath)
log.info("inspecting: %s", bfile.filepath)
if limit_to:
self._queue_named_blocks(bfile, limit_to)
else:
@@ -94,14 +101,14 @@ class BlockIterator:
if (bpath, block.addr_old) in self.blocks_yielded:
continue
if block.code == b'ID':
if block.code == b"ID":
# ID blocks represent linked-in assets. Those are the ones that
# should be loaded from their own blend file and "expanded" to
# the entire set of data blocks required to render them. We
# defer the handling of those so that we can work with one
# blend file at a time.
lib = block.get_pointer(b'lib')
lib_bpath = bpathlib.BlendPath(lib[b'name']).absolute(root_dir)
lib = block.get_pointer(b"lib")
lib_bpath = bpathlib.BlendPath(lib[b"name"]).absolute(root_dir)
blocks_per_lib[lib_bpath].add(block)
# The library block itself should also be reported, because it
@@ -109,11 +116,7 @@ class BlockIterator:
self.to_visit.put(lib)
continue
if limit_to:
# We're limiting the blocks, so we have to expand them to make
# sure we don't miss anything. Otherwise we're yielding the
# entire file anyway, and no expansion is necessary.
self._queue_dependencies(block)
self._queue_dependencies(block)
self.blocks_yielded.add((bpath, block.addr_old))
yield block
@@ -125,27 +128,26 @@ class BlockIterator:
for lib_bpath, idblocks in blocks_per_lib.items():
lib_path = bpathlib.make_absolute(lib_bpath.to_path())
#assert lib_path.exists()
if not lib_path.exists():
log.warning('Library %s does not exist', lib_path)
log.warning("Library %s does not exist", lib_path)
continue
log.debug('Expanding %d blocks in %s', len(idblocks), lib_path)
libfile = blendfile.open_cached(lib_path)
log.debug("Expanding %d blocks in %s", len(idblocks), lib_path)
libfile = self.open_blendfile(lib_path)
yield from self.iter_blocks(libfile, idblocks)
def _queue_all_blocks(self, bfile: blendfile.BlendFile):
log.debug('Queueing all blocks from file %s', bfile.filepath)
log.debug("Queueing all blocks from file %s", bfile.filepath)
for block in bfile.blocks:
# Don't bother visiting DATA blocks, as we won't know what
# to do with them anyway.
if block.code == b'DATA':
if block.code == b"DATA":
continue
self.to_visit.put(block)
def _queue_named_blocks(self,
bfile: blendfile.BlendFile,
limit_to: typing.Set[blendfile.BlendFileBlock]):
def _queue_named_blocks(
self, bfile: blendfile.BlendFile, limit_to: typing.Set[blendfile.BlendFileBlock]
):
"""Queue only the blocks referred to in limit_to.
:param bfile:
@@ -155,22 +157,25 @@ class BlockIterator:
"""
for to_find in limit_to:
assert to_find.code == b'ID'
name_to_find = to_find[b'name']
assert to_find.code == b"ID"
name_to_find = to_find[b"name"]
code = name_to_find[:2]
log.debug('Finding block %r with code %r', name_to_find, code)
log.debug("Finding block %r with code %r", name_to_find, code)
same_code = bfile.find_blocks_from_code(code)
for block in same_code:
if block.id_name == name_to_find:
log.debug('Queueing %r from file %s', block, bfile.filepath)
log.debug("Queueing %r from file %s", block, bfile.filepath)
self.to_visit.put(block)
def _queue_dependencies(self, block: blendfile.BlendFileBlock):
for block in expanders.expand_block(block):
assert isinstance(block, blendfile.BlendFileBlock), "unexpected %r" % block
self.to_visit.put(block)
def iter_blocks(bfile: blendfile.BlendFile) -> typing.Iterator[blendfile.BlendFileBlock]:
def iter_blocks(
bfile: blendfile.BlendFile,
) -> typing.Iterator[blendfile.BlendFileBlock]:
"""Generator, yield all blocks in this file + required blocks in libs."""
bi = BlockIterator()
yield from bi.iter_blocks(bfile)
+12 -5
View File
@@ -39,13 +39,19 @@ def expand_sequence(path: pathlib.Path) -> typing.Iterator[pathlib.Path]:
or the path of the first file in the sequence.
"""
if '*' in str(path): # assume it is a glob
if "<UDIM>" in path.name: # UDIM tiles
# Change <UDIM> marker to a glob pattern, then let the glob case handle it.
# This assumes that all files that match the glob are actually UDIM
# tiles; this could cause some false-positives.
path = path.with_name(path.name.replace("<UDIM>", "*"))
if "*" in str(path): # assume it is a glob
import glob
log.debug('expanding glob %s', path)
log.debug("expanding glob %s", path)
for fname in sorted(glob.glob(str(path), recursive=True)):
yield pathlib.Path(fname)
return
if not path.exists():
raise DoesNotExist(path)
@@ -53,9 +59,10 @@ def expand_sequence(path: pathlib.Path) -> typing.Iterator[pathlib.Path]:
yield path
return
log.debug('expanding file sequence %s', path)
log.debug("expanding file sequence %s", path)
import string
stem_no_digits = path.stem.rstrip(string.digits)
if stem_no_digits == path.stem:
# Just a single file, no digits here.
@@ -65,5 +72,5 @@ def expand_sequence(path: pathlib.Path) -> typing.Iterator[pathlib.Path]:
# Return everything start starts with 'stem_no_digits' and ends with the
# same suffix as the first file. This may result in more files than used
# by Blender, but at least it shouldn't miss any.
pattern = '%s*%s' % (stem_no_digits, path.suffix)
pattern = "%s*%s" % (stem_no_digits, path.suffix)
yield from sorted(path.parent.glob(pattern))
+152 -84
View File
@@ -37,8 +37,9 @@ class ModifierContext:
Currently just contains the object on which the modifier is defined.
"""
def __init__(self, owner: blendfile.BlendFileBlock) -> None:
assert owner.dna_type_name == 'Object'
assert owner.dna_type_name == "Object"
self.owner = owner
@@ -55,45 +56,56 @@ def mod_handler(dna_num: int):
@mod_handler(cdefs.eModifierType_MeshCache)
def modifier_filepath(ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
def modifier_filepath(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
"""Just yield the 'filepath' field."""
path, field = modifier.get(b'filepath', return_field=True)
yield result.BlockUsage(modifier, path, path_full_field=field, block_name=block_name)
path, field = modifier.get(b"filepath", return_field=True)
yield result.BlockUsage(
modifier, path, path_full_field=field, block_name=block_name
)
@mod_handler(cdefs.eModifierType_MeshSequenceCache)
def modifier_mesh_sequence_cache(ctx: ModifierContext, modifier: blendfile.BlendFileBlock,
block_name: bytes) -> typing.Iterator[result.BlockUsage]:
def modifier_mesh_sequence_cache(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
"""Yield the Alembic file(s) used by this modifier"""
cache_file = modifier.get_pointer(b'cache_file')
cache_file = modifier.get_pointer(b"cache_file")
if cache_file is None:
return
is_sequence = bool(cache_file[b'is_sequence'])
is_sequence = bool(cache_file[b"is_sequence"])
cache_block_name = cache_file.id_name
assert cache_block_name is not None
path, field = cache_file.get(b'filepath', return_field=True)
yield result.BlockUsage(cache_file, path, path_full_field=field,
is_sequence=is_sequence,
block_name=cache_block_name)
path, field = cache_file.get(b"filepath", return_field=True)
yield result.BlockUsage(
cache_file,
path,
path_full_field=field,
is_sequence=is_sequence,
block_name=cache_block_name,
)
@mod_handler(cdefs.eModifierType_Ocean)
def modifier_ocean(ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
if not modifier[b'cached']:
def modifier_ocean(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
if not modifier[b"cached"]:
return
path, field = modifier.get(b'cachepath', return_field=True)
path, field = modifier.get(b"cachepath", return_field=True)
# The path indicates the directory containing the cached files.
yield result.BlockUsage(modifier, path, is_sequence=True, path_full_field=field,
block_name=block_name)
yield result.BlockUsage(
modifier, path, is_sequence=True, path_full_field=field, block_name=block_name
)
def _get_texture(prop_name: bytes, dblock: blendfile.BlendFileBlock, block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
def _get_texture(
prop_name: bytes, dblock: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
"""Yield block usages from a texture propery.
Assumes dblock[prop_name] is a texture data block.
@@ -102,13 +114,14 @@ def _get_texture(prop_name: bytes, dblock: blendfile.BlendFileBlock, block_name:
return
tx = dblock.get_pointer(prop_name)
yield from _get_image(b'ima', tx, block_name)
yield from _get_image(b"ima", tx, block_name)
def _get_image(prop_name: bytes,
dblock: typing.Optional[blendfile.BlendFileBlock],
block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
def _get_image(
prop_name: bytes,
dblock: typing.Optional[blendfile.BlendFileBlock],
block_name: bytes,
) -> typing.Iterator[result.BlockUsage]:
"""Yield block usages from an image propery.
Assumes dblock[prop_name] is an image data block.
@@ -120,132 +133,187 @@ def _get_image(prop_name: bytes,
ima = dblock.get_pointer(prop_name)
except KeyError as ex:
# No such property, just return.
log.debug('_get_image() called with non-existing property name: %s', ex)
log.debug("_get_image() called with non-existing property name: %s", ex)
return
if not ima:
return
path, field = ima.get(b'name', return_field=True)
path, field = ima.get(b"name", return_field=True)
yield result.BlockUsage(ima, path, path_full_field=field, block_name=block_name)
@mod_handler(cdefs.eModifierType_Displace)
@mod_handler(cdefs.eModifierType_Wave)
def modifier_texture(ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
return _get_texture(b'texture', modifier, block_name)
def modifier_texture(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
return _get_texture(b"texture", modifier, block_name)
@mod_handler(cdefs.eModifierType_WeightVGEdit)
@mod_handler(cdefs.eModifierType_WeightVGMix)
@mod_handler(cdefs.eModifierType_WeightVGProximity)
def modifier_mask_texture(ctx: ModifierContext, modifier: blendfile.BlendFileBlock,
block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
return _get_texture(b'mask_texture', modifier, block_name)
def modifier_mask_texture(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
return _get_texture(b"mask_texture", modifier, block_name)
@mod_handler(cdefs.eModifierType_UVProject)
def modifier_image(ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
yield from _get_image(b'image', modifier, block_name)
def modifier_image(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
yield from _get_image(b"image", modifier, block_name)
def _walk_point_cache(ctx: ModifierContext,
block_name: bytes,
bfile: blendfile.BlendFile,
pointcache: blendfile.BlendFileBlock,
extension: bytes):
flag = pointcache[b'flag']
def _walk_point_cache(
ctx: ModifierContext,
block_name: bytes,
bfile: blendfile.BlendFile,
pointcache: blendfile.BlendFileBlock,
extension: bytes,
):
flag = pointcache[b"flag"]
if flag & cdefs.PTCACHE_EXTERNAL:
path, field = pointcache.get(b'path', return_field=True)
log.info(' external cache at %s', path)
path, field = pointcache.get(b"path", return_field=True)
log.info(" external cache at %s", path)
bpath = bpathlib.BlendPath(path)
yield result.BlockUsage(pointcache, bpath, path_full_field=field,
is_sequence=True, block_name=block_name)
yield result.BlockUsage(
pointcache,
bpath,
path_full_field=field,
is_sequence=True,
block_name=block_name,
)
elif flag & cdefs.PTCACHE_DISK_CACHE:
# See ptcache_path() in pointcache.c
name, field = pointcache.get(b'name', return_field=True)
name, field = pointcache.get(b"name", return_field=True)
if not name:
# See ptcache_filename() in pointcache.c
idname = ctx.owner[b'id', b'name']
idname = ctx.owner[b"id", b"name"]
name = idname[2:].hex().upper().encode()
path = b'//%b%b/%b_*%b' % (
path = b"//%b%b/%b_*%b" % (
cdefs.PTCACHE_PATH,
bfile.filepath.stem.encode(),
name,
extension)
log.info(' disk cache at %s', path)
extension,
)
log.info(" disk cache at %s", path)
bpath = bpathlib.BlendPath(path)
yield result.BlockUsage(pointcache, bpath, path_full_field=field,
is_sequence=True, block_name=block_name)
yield result.BlockUsage(
pointcache,
bpath,
path_full_field=field,
is_sequence=True,
block_name=block_name,
)
@mod_handler(cdefs.eModifierType_ParticleSystem)
def modifier_particle_system(ctx: ModifierContext, modifier: blendfile.BlendFileBlock,
block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
psys = modifier.get_pointer(b'psys')
def modifier_particle_system(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
psys = modifier.get_pointer(b"psys")
if psys is None:
return
pointcache = psys.get_pointer(b'pointcache')
pointcache = psys.get_pointer(b"pointcache")
if pointcache is None:
return
yield from _walk_point_cache(ctx, block_name, modifier.bfile, pointcache, cdefs.PTCACHE_EXT)
yield from _walk_point_cache(
ctx, block_name, modifier.bfile, pointcache, cdefs.PTCACHE_EXT
)
@mod_handler(cdefs.eModifierType_Fluidsim)
def modifier_fluid_sim(ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
my_log = log.getChild('modifier_fluid_sim')
def modifier_fluid_sim(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
my_log = log.getChild("modifier_fluid_sim")
fss = modifier.get_pointer(b'fss')
fss = modifier.get_pointer(b"fss")
if fss is None:
my_log.debug('Modifier %r (%r) has no fss',
modifier[b'modifier', b'name'], block_name)
my_log.debug(
"Modifier %r (%r) has no fss", modifier[b"modifier", b"name"], block_name
)
return
path, field = fss.get(b'surfdataPath', return_field=True)
path, field = fss.get(b"surfdataPath", return_field=True)
# This may match more than is used by Blender, but at least it shouldn't
# miss any files.
# The 'fluidsurface' prefix is defined in source/blender/makesdna/DNA_object_fluidsim_types.h
bpath = bpathlib.BlendPath(path)
yield result.BlockUsage(fss, bpath, path_full_field=field,
is_sequence=True, block_name=block_name)
yield result.BlockUsage(
fss, bpath, path_full_field=field, is_sequence=True, block_name=block_name
)
@mod_handler(cdefs.eModifierType_Smokesim)
def modifier_smoke_sim(ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
my_log = log.getChild('modifier_smoke_sim')
def modifier_smoke_sim(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
my_log = log.getChild("modifier_smoke_sim")
domain = modifier.get_pointer(b'domain')
domain = modifier.get_pointer(b"domain")
if domain is None:
my_log.debug('Modifier %r (%r) has no domain',
modifier[b'modifier', b'name'], block_name)
my_log.debug(
"Modifier %r (%r) has no domain", modifier[b"modifier", b"name"], block_name
)
return
pointcache = domain.get_pointer(b'point_cache')
pointcache = domain.get_pointer(b"point_cache")
if pointcache is None:
return
format = domain.get(b'cache_file_format')
format = domain.get(b"cache_file_format")
extensions = {
cdefs.PTCACHE_FILE_PTCACHE: cdefs.PTCACHE_EXT,
cdefs.PTCACHE_FILE_OPENVDB: cdefs.PTCACHE_EXT_VDB
cdefs.PTCACHE_FILE_OPENVDB: cdefs.PTCACHE_EXT_VDB,
}
yield from _walk_point_cache(ctx, block_name, modifier.bfile, pointcache, extensions[format])
yield from _walk_point_cache(
ctx, block_name, modifier.bfile, pointcache, extensions[format]
)
@mod_handler(cdefs.eModifierType_Fluid)
def modifier_fluid(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
my_log = log.getChild("modifier_fluid")
domain = modifier.get_pointer(b"domain")
if domain is None:
my_log.debug(
"Modifier %r (%r) has no domain", modifier[b"modifier", b"name"], block_name
)
return
# See fluid_bake_startjob() in physics_fluid.c
path = domain[b"cache_directory"]
path, field = domain.get(b"cache_directory", return_field=True)
log.info(" fluid cache at %s", path)
bpath = bpathlib.BlendPath(path)
yield result.BlockUsage(
domain,
bpath,
path_full_field=field,
is_sequence=True,
block_name=block_name,
)
@mod_handler(cdefs.eModifierType_Cloth)
def modifier_cloth(ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes) \
-> typing.Iterator[result.BlockUsage]:
pointcache = modifier.get_pointer(b'point_cache')
def modifier_cloth(
ctx: ModifierContext, modifier: blendfile.BlendFileBlock, block_name: bytes
) -> typing.Iterator[result.BlockUsage]:
pointcache = modifier.get_pointer(b"point_cache")
if pointcache is None:
return
yield from _walk_point_cache(ctx, block_name, modifier.bfile, pointcache, cdefs.PTCACHE_EXT)
yield from _walk_point_cache(
ctx, block_name, modifier.bfile, pointcache, cdefs.PTCACHE_EXT
)
+52 -35
View File
@@ -52,32 +52,37 @@ class BlockUsage:
:ivar path_base_field: field containing the basename of this asset.
"""
def __init__(self,
block: blendfile.BlendFileBlock,
asset_path: bpathlib.BlendPath,
is_sequence: bool = False,
path_full_field: dna.Field = None,
path_dir_field: dna.Field = None,
path_base_field: dna.Field = None,
block_name: bytes = b'',
) -> None:
def __init__(
self,
block: blendfile.BlendFileBlock,
asset_path: bpathlib.BlendPath,
is_sequence: bool = False,
path_full_field: dna.Field = None,
path_dir_field: dna.Field = None,
path_base_field: dna.Field = None,
block_name: bytes = b"",
) -> None:
if block_name:
self.block_name = block_name
else:
self.block_name = self.guess_block_name(block)
assert isinstance(block, blendfile.BlendFileBlock)
assert isinstance(asset_path, (bytes, bpathlib.BlendPath)), \
'asset_path should be BlendPath, not %r' % type(asset_path)
assert isinstance(
asset_path, (bytes, bpathlib.BlendPath)
), "asset_path should be BlendPath, not %r" % type(asset_path)
if path_full_field is None:
assert isinstance(path_dir_field, dna.Field), \
'path_dir_field should be dna.Field, not %r' % type(path_dir_field)
assert isinstance(path_base_field, dna.Field), \
'path_base_field should be dna.Field, not %r' % type(path_base_field)
assert isinstance(
path_dir_field, dna.Field
), "path_dir_field should be dna.Field, not %r" % type(path_dir_field)
assert isinstance(
path_base_field, dna.Field
), "path_base_field should be dna.Field, not %r" % type(path_base_field)
else:
assert isinstance(path_full_field, dna.Field), \
'path_full_field should be dna.Field, not %r' % type(path_full_field)
assert isinstance(
path_full_field, dna.Field
), "path_full_field should be dna.Field, not %r" % type(path_full_field)
if isinstance(asset_path, bytes):
asset_path = bpathlib.BlendPath(asset_path)
@@ -95,26 +100,30 @@ class BlockUsage:
@staticmethod
def guess_block_name(block: blendfile.BlendFileBlock) -> bytes:
try:
return block[b'id', b'name']
return block[b"id", b"name"]
except KeyError:
pass
try:
return block[b'name']
return block[b"name"]
except KeyError:
pass
return b'-unnamed-'
return b"-unnamed-"
def __repr__(self):
if self.path_full_field is None:
field_name = self.path_dir_field.name.name_full.decode() + \
'/' + \
self.path_base_field.name.name_full.decode()
field_name = (
self.path_dir_field.name.name_full.decode()
+ "/"
+ self.path_base_field.name.name_full.decode()
)
else:
field_name = self.path_full_field.name.name_full.decode()
return '<BlockUsage name=%r type=%r field=%r asset=%r%s>' % (
self.block_name, self.block.dna_type_name,
field_name, self.asset_path,
' sequence' if self.is_sequence else ''
return "<BlockUsage name=%r type=%r field=%r asset=%r%s>" % (
self.block_name,
self.block.dna_type_name,
field_name,
self.asset_path,
" sequence" if self.is_sequence else "",
)
def files(self) -> typing.Iterator[pathlib.Path]:
@@ -130,7 +139,7 @@ class BlockUsage:
path = self.__fspath__()
if not self.is_sequence:
if not path.exists():
log.warning('Path %s does not exist for %s', path, self)
log.warning("Path %s does not exist for %s", path, self)
return
yield path
return
@@ -138,14 +147,18 @@ class BlockUsage:
try:
yield from file_sequence.expand_sequence(path)
except file_sequence.DoesNotExist:
log.warning('Path %s does not exist for %s', path, self)
log.warning("Path %s does not exist for %s", path, self)
def __fspath__(self) -> pathlib.Path:
"""Determine the absolute path of the asset on the filesystem."""
if self._abspath is None:
bpath = self.block.bfile.abspath(self.asset_path)
log.info('Resolved %s rel to %s -> %s',
self.asset_path, self.block.bfile.filepath, bpath)
log.info(
"Resolved %s rel to %s -> %s",
self.asset_path,
self.block.bfile.filepath,
bpath,
)
as_path = pathlib.Path(bpath.to_path())
@@ -159,15 +172,19 @@ class BlockUsage:
else:
self._abspath = abs_parent / as_path.name
log.info('Resolving %s rel to %s -> %s',
self.asset_path, self.block.bfile.filepath, self._abspath)
log.info(
"Resolving %s rel to %s -> %s",
self.asset_path,
self.block.bfile.filepath,
self._abspath,
)
else:
log.info('Reusing abspath %s', self._abspath)
log.info("Reusing abspath %s", self._abspath)
return self._abspath
abspath = property(__fspath__)
def __lt__(self, other: 'BlockUsage'):
def __lt__(self, other: "BlockUsage"):
"""Allow sorting for repeatable and predictable unit tests."""
if not isinstance(other, BlockUsage):
raise NotImplemented()