393 lines
14 KiB
Python
393 lines
14 KiB
Python
# A purpose-built class to parse certain information from .blend files.
|
|
|
|
import collections
|
|
import json
|
|
import io
|
|
import re
|
|
import struct
|
|
|
|
class BlendDecodeError(Exception):
|
|
pass
|
|
|
|
class BlendStruct(collections.abc.Mapping):
|
|
"""
|
|
Read-only dict-like datatype representing a structure in a .blend file.
|
|
"""
|
|
|
|
def __init__(self, load_cb, type):
|
|
"""
|
|
Initialize the BlendStruct.
|
|
|
|
:param load_cb: A callback to load the structure.
|
|
:param type: The type of the structure.
|
|
"""
|
|
self._load_cb = load_cb
|
|
self._type = type
|
|
self._structure = None
|
|
|
|
def load(self):
|
|
"""
|
|
Force the structure to be loaded.
|
|
|
|
:return The loaded blend struct (self)
|
|
"""
|
|
if self._structure is None:
|
|
self._structure = self._load_cb()
|
|
return self
|
|
|
|
def __str__(self):
|
|
if self._structure is None:
|
|
return f"<Blender Structure {self._type} (unloaded)>"
|
|
else:
|
|
return str(self._structure)
|
|
|
|
def __repr__(self):
|
|
if self._structure is None:
|
|
return f"<Blender Structure {self._type} (unloaded)>"
|
|
else:
|
|
return f"<Blender Structure {self._type} (loaded)>"
|
|
|
|
def __getitem__(self, item):
|
|
if self._structure is None:
|
|
self._structure = self._load_cb()
|
|
return self._structure[item]
|
|
|
|
def __iter__(self):
|
|
if self._structure is None:
|
|
self._structure = self._load_cb()
|
|
return self._structure.__iter__()
|
|
|
|
def __len__(self):
|
|
if self._structure is None:
|
|
self._structure = self._load_cb()
|
|
return len(self._structure)
|
|
|
|
def inspect(self):
|
|
"""
|
|
Return a human readable representation of the struct.
|
|
"""
|
|
summary = {}
|
|
if self._structure is None:
|
|
self._structure = self._load_cb()
|
|
for field, value in self._structure.items():
|
|
summary[field] = repr(value)
|
|
return json.dumps(summary, indent=4)
|
|
|
|
# A class for opening and reading data from .blend files
|
|
class Blendfile(io.FileIO):
|
|
|
|
# The .blend format begins with a file header consisting of four fields.
|
|
# A 7 byte identifier string, which will always be "BLENDER"
|
|
# A single char representing pointer size: "-" for 8 byte, "_" for 4 byte.
|
|
# A single char representing endianness: "v" for little, "V" for big.
|
|
# A 3 byte version string. For example "293" indicates version 2.93.
|
|
_blend_header_struct = struct.Struct("7scc3s")
|
|
_BlendHeader = collections.namedtuple(
|
|
"FileHeader", ("identifier", "pointer_size", "endianness", "version"))
|
|
|
|
# The file header is followed by a series of file blocks. Each file block
|
|
# begins with a header that contains 5 fields.
|
|
# A 4 byte code string which is the name of the file block.
|
|
# A 4 byte integer representing the size in bytes of the file block body.
|
|
# A pointer_size byte memory address (string); the old location in memory.
|
|
# A 4 byte integer representing the index of the struct definition
|
|
# in the SDNA struct array.
|
|
# A 4 byte integer representing the number of structs in the file block.
|
|
# The struct object depends on the pointer size read during initialization,
|
|
# so it will be initialized per instance at that time.
|
|
# The code is called blockcode to distinguish it from the code module.
|
|
_BlockHeader = collections.namedtuple(
|
|
"BlockHeader", ("blockcode", "size", "address", "sdna_index", "count"))
|
|
|
|
def __init__(self, filename):
|
|
super().__init__(filename, "rb")
|
|
# Offset of first file block header.
|
|
self._block_start = 12
|
|
self._load_header()
|
|
|
|
# Endianess symbol for struct format string.
|
|
if self.endianness == "big":
|
|
endianness_format = ">"
|
|
else:
|
|
endianness_format = "<"
|
|
self._block_header_struct = struct.Struct(
|
|
f"{endianness_format}4si{self.pointer_size}sii")
|
|
|
|
# Offsets of each file block by code.
|
|
self._block_offsets = self._read_block_headers()
|
|
# SDNA structures by name.
|
|
self._sdna = self._load_sdna()
|
|
|
|
def __str__(self):
|
|
return f"Blender file version {self.version}"
|
|
|
|
def _read_c_string(self):
|
|
"""
|
|
Utility method to read a null-terminated C-string.
|
|
"""
|
|
res = bytes("", "utf-8")
|
|
null_char = bytes("\0", "utf-8")
|
|
while True:
|
|
char = self.read(1)
|
|
if char == null_char:
|
|
return res.decode("utf-8")
|
|
res += char
|
|
|
|
def _construct_value(self, type, is_ptr, length):
|
|
"""
|
|
Construct a value from a type.
|
|
|
|
This is gonna be messy; documentation can come later.
|
|
"""
|
|
if length > 1:
|
|
arr = []
|
|
# Skip for now.
|
|
size = self._sdna["tlen"][type]
|
|
for _ in range(length):
|
|
self.seek(size, io.SEEK_CUR)
|
|
#arr.append(self._construct_value(type, False, 1))
|
|
return arr
|
|
|
|
INT_TYPES = ("short", "int", "long", "long long")
|
|
if type in self._sdna["structs"]:
|
|
offset = self.tell()
|
|
self.seek(self._sdna["tlen"][type], io.SEEK_CUR)
|
|
return BlendStruct(self._struct_loader(type, offset), type)
|
|
elif type in INT_TYPES:
|
|
size = self._sdna["tlen"][type]
|
|
return int.from_bytes(self.read(size), self.endianness)
|
|
elif type == "char":
|
|
if is_ptr:
|
|
return self._read_c_string()
|
|
else:
|
|
return self.read(1)
|
|
else:
|
|
size = self._sdna["tlen"][type]
|
|
self.seek(size, io.SEEK_CUR)
|
|
return type
|
|
|
|
def _load_header(self):
|
|
"""
|
|
Unpack the file header.
|
|
|
|
Verifies the file identifier, and sets the pointer size, endianess, and
|
|
blender version.
|
|
|
|
:raises BlendDecodeError
|
|
"""
|
|
self.seek(0)
|
|
header_size = self._blend_header_struct.size
|
|
# Does not decode bytes. Decoding bytes could raise an exception, so
|
|
# by validating the fields ourselves by comparing bytes, we can raise
|
|
# an exception with more useful information.
|
|
header = self._BlendHeader(
|
|
*self._blend_header_struct.unpack_from(self.read(header_size)))
|
|
|
|
if header.identifier != bytes("BLENDER", "utf-8"):
|
|
raise BlendDecodeError("File identifier is not 'BLENDER'!")
|
|
if header.pointer_size == bytes("-", "utf-8"):
|
|
self.pointer_size = 8
|
|
elif header.pointer_size == bytes("_", "utf-8"):
|
|
self.pointer_size = 4
|
|
else:
|
|
raise BlendDecodeError(
|
|
f"Invalid pointer size character {header.pointer_size}; " \
|
|
f"must be {b'-'} or {b'_'}!")
|
|
if header.endianness == bytes("v", "utf-8"):
|
|
self.endianness = "little"
|
|
elif header.endianess == bytes("V", "utf-8"):
|
|
self.endianness = "big"
|
|
else:
|
|
raise BlendDecodeError(
|
|
f"Invalid endianness character {header.endianess}; "\
|
|
f"must be {b'v'} or {b'V'}")
|
|
if not header.version.isdigit():
|
|
raise BlendDecodeError(
|
|
f"Invalid version string {header.version}!")
|
|
self.version = "v{}.{}{}".format(*header.version.decode("utf-8"))
|
|
|
|
def _read_block_headers(self):
|
|
"""
|
|
Read the header of each file block and store the offset of its body.
|
|
|
|
:return A dict of file block codes mapped to tuples of the format
|
|
(_BlockHeader, byte_offset).
|
|
"""
|
|
|
|
block_offsets = {}
|
|
self.seek(self._block_start)
|
|
while True:
|
|
# If no bytes are read we are at EOF.
|
|
header_bytes = self.read(self._block_header_struct.size)
|
|
if len(header_bytes) == 0:
|
|
break
|
|
|
|
# Header will be read and then recreated with decoded values.
|
|
header = self._BlockHeader(
|
|
*self._block_header_struct.unpack_from(header_bytes))
|
|
try:
|
|
blockcode = header.blockcode.decode("utf-8")
|
|
except UnicodeDecodeError as e:
|
|
# Workaround to avoid having "During handling of..." error.
|
|
raise BlendDecodeError(
|
|
f"Can't decode block code {header.blockcode}!") from None
|
|
header_decoded = self._BlockHeader(
|
|
blockcode, header.size, header.address,
|
|
header.sdna_index, header.count)
|
|
|
|
# Beginning of file block body.
|
|
offset = self.tell()
|
|
block_offsets[blockcode] = (header_decoded, offset)
|
|
self.seek(header_decoded.size, io.SEEK_CUR)
|
|
|
|
return block_offsets
|
|
|
|
def _load_sdna(self):
|
|
"""
|
|
Load each SDNA structure.
|
|
"""
|
|
try:
|
|
offset = self._block_offsets["DNA1"][1]
|
|
except KeyError:
|
|
raise ValueError("Missing DNA1 file block.") from None
|
|
|
|
sdna = {}
|
|
# Skip file block header.
|
|
self.seek(offset)
|
|
|
|
# Identifier; should be "SDNA"
|
|
self.seek(4, io.SEEK_CUR)
|
|
# Name; should be "NAME"
|
|
self.seek(4, io.SEEK_CUR)
|
|
|
|
# List of structure names.
|
|
total_names = int.from_bytes(self.read(4), self.endianness)
|
|
names = []
|
|
for _ in range(total_names):
|
|
names.append(self._read_c_string())
|
|
sdna["names"] = names
|
|
|
|
# List of types
|
|
# Align at 4 bytes.
|
|
if self.tell() % 4 != 0:
|
|
self.seek(4 - (self.tell() % 4), io.SEEK_CUR)
|
|
# Type identifier; should be "TYPE"
|
|
type_identifier = self.read(4).decode("utf-8")
|
|
assert(type_identifier == "TYPE")
|
|
# Number of types follows.
|
|
total_types = int.from_bytes(self.read(4), self.endianness)
|
|
# Avoiding collision with builtin types module.
|
|
_types = []
|
|
for _ in range(total_types):
|
|
_types.append(self._read_c_string())
|
|
sdna["types"] = _types
|
|
|
|
# Length of each type.
|
|
# Align at 4 bytes.
|
|
if self.tell() % 4 != 0:
|
|
self.seek(4 - (self.tell() % 4), io.SEEK_CUR)
|
|
# Type length identifier; should be "TLEN"
|
|
len_identifier = self.read(4).decode("utf-8")
|
|
assert(len_identifier == "TLEN")
|
|
type_lengths = {}
|
|
for i in range(total_types):
|
|
length = int.from_bytes(self.read(2), self.endianness)
|
|
type_lengths[_types[i]] = length
|
|
sdna["tlen"] = type_lengths
|
|
|
|
# Align at 4 bytes.
|
|
if self.tell() % 4 != 0:
|
|
self.seek(4 - (self.tell() % 4), io.SEEK_CUR)
|
|
# Structure identifier; should be "STRC".
|
|
struct_identifier = self.read(4).decode("utf-8")
|
|
assert(struct_identifier == "STRC")
|
|
structs = {}
|
|
# Number of structures follows.
|
|
total_structs = int.from_bytes(self.read(4), self.endianness)
|
|
for _ in range(total_structs):
|
|
# Index in types containing the name of the structure.
|
|
type_index = int.from_bytes(self.read(2), self.endianness)
|
|
fields = {}
|
|
# Number of fields in this structure.
|
|
total_fields = int.from_bytes(self.read(2), self.endianness)
|
|
for _ in range(total_fields):
|
|
# Index in type
|
|
field_type = int.from_bytes(self.read(2), self.endianness)
|
|
# Index in name
|
|
field_name = int.from_bytes(self.read(2), self.endianness)
|
|
fields[names[field_name]] = _types[field_type]
|
|
structs[_types[type_index]] = fields
|
|
sdna["structs"] = structs
|
|
|
|
return sdna
|
|
|
|
def _load_struct(self, struct_name, offset):
|
|
"""
|
|
Load a struct according to the SDNA.
|
|
|
|
:param struct_name: The name of the structure type.
|
|
:param offset: The byte offset at which to begin loading.
|
|
:return The loaded structure.
|
|
"""
|
|
structure = {}
|
|
fields = self._sdna["structs"][struct_name]
|
|
for name, type in fields.items():
|
|
lengths = re.findall(r"\[([0-9]+)\]", name)
|
|
if len(lengths) > 1:
|
|
raise ValueError(f"Can't handle nested array {name}.")
|
|
elif len(lengths) == 1:
|
|
length = int(lengths[0])
|
|
else:
|
|
length = 1
|
|
is_ptr = name.startswith("*")
|
|
structure[name] = self._construct_value(type, is_ptr, length)
|
|
return structure
|
|
|
|
def get_blocks(self, match=""):
|
|
"""
|
|
Get file blocks from the blend file.
|
|
|
|
To be efficient, functions that load the file blocks will be returned.
|
|
|
|
:param match: Filter file blocks by matching the beginning of the name
|
|
against a string. Default is "" (matches everything). Case sensitive.
|
|
:return A dictionary mapping identifiers to a function to load the
|
|
block.
|
|
"""
|
|
# Invoking the load_block() closure will load the file block at offset.
|
|
def create_loader(header, at_offset):
|
|
def load_block():
|
|
return self._load_block(header, at_offset)
|
|
return load_block
|
|
|
|
matched_blocks = {}
|
|
for identifier, header in self._block_offsets.items():
|
|
if identifier.startswith(match):
|
|
matched_blocks[identifier] = create_loader(*header)
|
|
return matched_blocks
|
|
|
|
# Helper for creating a callback to load a given structure.
|
|
def _struct_loader(self, name, at_offset):
|
|
def load_struct():
|
|
return self._load_struct(name, at_offset)
|
|
return load_struct
|
|
|
|
def _load_block(self, header, offset):
|
|
"""
|
|
Load a file block at a given offset to the beginning of the blend file.
|
|
|
|
:param header: The file block header.
|
|
:param offset: The byte offset to the file block body.
|
|
:return Generator that yields dictionaries, each representing a struct.
|
|
"""
|
|
# We might allow loading cached blocks after the file is closed, so
|
|
# I'm leaving this here to be explicit. XD
|
|
if self.closed:
|
|
raise ValueError("I/O operation on a closed file.")
|
|
|
|
# The type of the structure.
|
|
name = list(self._sdna["structs"])[header.sdna_index]
|
|
for _ in range(header.count):
|
|
yield BlendStruct(self._struct_loader(name, self.tell()), name)
|
|
|
|
__all__ = ("BlendStruct", "Blendfile") |