Skip to content
Snippets Groups Projects
fbx2json.py 9.92 KiB
Newer Older
  • Learn to ignore specific revisions
  • #!/usr/bin/env python3
    # ##### BEGIN GPL LICENSE BLOCK #####
    #
    #  This program is free software; you can redistribute it and/or
    #  modify it under the terms of the GNU General Public License
    #  as published by the Free Software Foundation; either version 2
    #  of the License, or (at your option) any later version.
    #
    #  This program is distributed in the hope that it will be useful,
    #  but WITHOUT ANY WARRANTY; without even the implied warranty of
    #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    #  GNU General Public License for more details.
    #
    #  You should have received a copy of the GNU General Public License
    #  along with this program; if not, write to the Free Software Foundation,
    #  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
    #
    # ##### END GPL LICENSE BLOCK #####
    
    # <pep8 compliant>
    
    # Script copyright (C) 2006-2012, assimp team
    # Script copyright (C) 2013 Blender Foundation
    
    """
    Usage
    =====
    
       fbx2json [FILES]...
    
    This script will write a JSON file for each FBX argument given.
    
    
    Output
    ======
    
    The JSON data is formatted into a list of nested lists of 4 items:
    
       ``[id, [data, ...], "data_types", [subtree, ...]]``
    
    Where each list may be empty, and the items in
    the subtree are formatted the same way.
    
    data_types is a string, aligned with data that spesifies a type
    for each property.
    
    The types are as follows:
    
    * 'Y': - INT16
    * 'C': - BOOL
    * 'I': - INT32
    * 'F': - FLOAT32
    * 'D': - FLOAT64
    * 'L': - INT64
    * 'R': - BYTES
    * 'S': - STRING
    * 'f': - FLOAT32_ARRAY
    * 'i': - INT32_ARRAY
    * 'd': - FLOAT64_ARRAY
    * 'l': - INT64_ARRAY
    * 'b': - BOOL ARRAY
    * 'c': - BYTE ARRAY
    
    Note that key:value pairs aren't used since the id's are not
    ensured to be unique.
    """
    
    
    # ----------------------------------------------------------------------------
    # FBX Binary Parser
    
    from struct import unpack
    import array
    import zlib
    
    # at the end of each nested block, there is a NUL record to indicate
    # that the sub-scope exists (i.e. to distinguish between P: and P : {})
    
    _BLOCK_SENTINEL_LENGTH = ...
    _BLOCK_SENTINEL_DATA = ...
    read_fbx_elem_uint = ...
    
    _IS_BIG_ENDIAN = (__import__("sys").byteorder != 'little')
    _HEAD_MAGIC = b'Kaydara FBX Binary\x20\x20\x00\x1a\x00'
    from collections import namedtuple
    FBXElem = namedtuple("FBXElem", ("id", "props", "props_type", "elems"))
    del namedtuple
    
    
    def read_uint(read):
        return unpack(b'<I', read(4))[0]
    
    
    def read_uint64(read):
        return unpack(b'<Q', read(8))[0]
    
    def read_ubyte(read):
        return unpack(b'B', read(1))[0]
    
    
    def read_string_ubyte(read):
        size = read_ubyte(read)
        data = read(size)
        return data
    
    
    def unpack_array(read, array_type, array_stride, array_byteswap):
        length = read_uint(read)
        encoding = read_uint(read)
        comp_len = read_uint(read)
    
        data = read(comp_len)
    
        if encoding == 0:
            pass
        elif encoding == 1:
            data = zlib.decompress(data)
    
        assert(length * array_stride == len(data))
    
        data_array = array.array(array_type, data)
        if array_byteswap and _IS_BIG_ENDIAN:
            data_array.byteswap()
        return data_array
    
    
    read_data_dict = {
        b'Y'[0]: lambda read: unpack(b'<h', read(2))[0],  # 16 bit int
        b'C'[0]: lambda read: unpack(b'?', read(1))[0],   # 1 bit bool (yes/no)
        b'I'[0]: lambda read: unpack(b'<i', read(4))[0],  # 32 bit int
        b'F'[0]: lambda read: unpack(b'<f', read(4))[0],  # 32 bit float
        b'D'[0]: lambda read: unpack(b'<d', read(8))[0],  # 64 bit float
        b'L'[0]: lambda read: unpack(b'<q', read(8))[0],  # 64 bit int
        b'R'[0]: lambda read: read(read_uint(read)),      # binary data
        b'S'[0]: lambda read: read(read_uint(read)),      # string data
        b'f'[0]: lambda read: unpack_array(read, 'f', 4, False),  # array (float)
        b'i'[0]: lambda read: unpack_array(read, 'i', 4, True),   # array (int)
        b'd'[0]: lambda read: unpack_array(read, 'd', 8, False),  # array (double)
        b'l'[0]: lambda read: unpack_array(read, 'q', 8, True),   # array (long)
        b'b'[0]: lambda read: unpack_array(read, 'b', 1, False),  # array (bool)
        b'c'[0]: lambda read: unpack_array(read, 'B', 1, False),  # array (ubyte)
        }
    
    
    
    # FBX 7500 (aka FBX2016) introduces incompatible changes at binary level:
    #   * The NULL block marking end of nested stuff switches from 13 bytes long to 25 bytes long.
    #   * The FBX element metadata (end_offset, prop_count and prop_length) switch from uint32 to uint64.
    def init_version(fbx_version):
        global _BLOCK_SENTINEL_LENGTH, _BLOCK_SENTINEL_DATA, read_fbx_elem_uint
    
        assert(_BLOCK_SENTINEL_LENGTH == ...)
        assert(_BLOCK_SENTINEL_DATA == ...)
    
        if fbx_version < 7500:
            _BLOCK_SENTINEL_LENGTH = 13
            read_fbx_elem_uint = read_uint
        else:
            _BLOCK_SENTINEL_LENGTH = 25
            read_fbx_elem_uint = read_uint64
        _BLOCK_SENTINEL_DATA = (b'\0' * _BLOCK_SENTINEL_LENGTH)
    
    
    
    def read_elem(read, tell, use_namedtuple):
        # [0] the offset at which this block ends
        # [1] the number of properties in the scope
        # [2] the length of the property list
    
        end_offset = read_fbx_elem_uint(read)
    
        prop_count = read_fbx_elem_uint(read)
        prop_length = read_fbx_elem_uint(read)
    
    
        elem_id = read_string_ubyte(read)        # elem name of the scope/key
        elem_props_type = bytearray(prop_count)  # elem property types
        elem_props_data = [None] * prop_count    # elem properties (if any)
        elem_subtree = []                        # elem children (if any)
    
        for i in range(prop_count):
            data_type = read(1)[0]
            elem_props_data[i] = read_data_dict[data_type](read)
            elem_props_type[i] = data_type
    
        if tell() < end_offset:
            while tell() < (end_offset - _BLOCK_SENTINEL_LENGTH):
                elem_subtree.append(read_elem(read, tell, use_namedtuple))
    
            if read(_BLOCK_SENTINEL_LENGTH) != _BLOCK_SENTINEL_DATA:
                raise IOError("failed to read nested block sentinel, "
                              "expected all bytes to be 0")
    
        if tell() != end_offset:
            raise IOError("scope length not reached, something is wrong")
    
        args = (elem_id, elem_props_data, elem_props_type, elem_subtree)
        return FBXElem(*args) if use_namedtuple else args
    
    
    def parse_version(fn):
        """
        Return the FBX version,
        if the file isn't a binary FBX return zero.
        """
        with open(fn, 'rb') as f:
            read = f.read
    
            if read(len(_HEAD_MAGIC)) != _HEAD_MAGIC:
                return 0
    
            return read_uint(read)
    
    
    def parse(fn, use_namedtuple=True):
        root_elems = []
    
        with open(fn, 'rb') as f:
            read = f.read
            tell = f.tell
    
            if read(len(_HEAD_MAGIC)) != _HEAD_MAGIC:
                raise IOError("Invalid header")
    
            fbx_version = read_uint(read)
    
            init_version(fbx_version)
    
    
            while True:
                elem = read_elem(read, tell, use_namedtuple)
                if elem is None:
                    break
                root_elems.append(elem)
    
        args = (b'', [], bytearray(0), root_elems)
        return FBXElem(*args) if use_namedtuple else args, fbx_version
    
    
    # ----------------------------------------------------------------------------
    # Inline Modules
    
    # pyfbx.data_types
    data_types = type(array)("data_types")
    data_types.__dict__.update(
    dict(
    INT16 = b'Y'[0],
    BOOL = b'C'[0],
    INT32 = b'I'[0],
    FLOAT32 = b'F'[0],
    FLOAT64 = b'D'[0],
    INT64 = b'L'[0],
    BYTES = b'R'[0],
    STRING = b'S'[0],
    FLOAT32_ARRAY = b'f'[0],
    INT32_ARRAY = b'i'[0],
    FLOAT64_ARRAY = b'd'[0],
    INT64_ARRAY = b'l'[0],
    BOOL_ARRAY = b'b'[0],
    BYTE_ARRAY = b'c'[0],
    ))
    
    # pyfbx.parse_bin
    parse_bin = type(array)("parse_bin")
    parse_bin.__dict__.update(
    dict(
    parse = parse
    ))
    
    
    # ----------------------------------------------------------------------------
    # JSON Converter
    # from pyfbx import parse_bin, data_types
    import json
    import array
    
    
    def fbx2json_property_as_string(prop, prop_type):
        if prop_type == data_types.STRING:
            prop_str = prop.decode('utf-8')
            prop_str = prop_str.replace('\x00\x01', '::')
            return json.dumps(prop_str)
        else:
            prop_py_type = type(prop)
            if prop_py_type == bytes:
                return json.dumps(repr(prop)[2:-1])
            elif prop_py_type == bool:
                return json.dumps(prop)
            elif prop_py_type == array.array:
                return repr(list(prop))
    
        return repr(prop)
    
    
    def fbx2json_properties_as_string(fbx_elem):
        return ", ".join(fbx2json_property_as_string(*prop_item)
                         for prop_item in zip(fbx_elem.props,
                                              fbx_elem.props_type))
    
    
    def fbx2json_recurse(fw, fbx_elem, ident, is_last):
        fbx_elem_id = fbx_elem.id.decode('utf-8')
        fw('%s["%s", ' % (ident, fbx_elem_id))
        fw('[%s], ' % fbx2json_properties_as_string(fbx_elem))
        fw('"%s", ' % (fbx_elem.props_type.decode('ascii')))
    
        fw('[')
        if fbx_elem.elems:
            fw('\n')
            ident_sub = ident + "    "
            for fbx_elem_sub in fbx_elem.elems:
                fbx2json_recurse(fw, fbx_elem_sub, ident_sub,
                                 fbx_elem_sub is fbx_elem.elems[-1])
        fw(']')
    
        fw(']%s' % ('' if is_last else ',\n'))
    
    
    def fbx2json(fn):
        import os
    
        fn_json = "%s.json" % os.path.splitext(fn)[0]
        print("Writing: %r " % fn_json, end="")
        fbx_root_elem, fbx_version = parse(fn, use_namedtuple=True)
        print("(Version %d) ..." % fbx_version)
    
        with open(fn_json, 'w', encoding="ascii", errors='xmlcharrefreplace') as f:
            fw = f.write
            fw('[\n')
            ident_sub = "    "
            for fbx_elem_sub in fbx_root_elem.elems:
                fbx2json_recurse(f.write, fbx_elem_sub, ident_sub,
                                 fbx_elem_sub is fbx_root_elem.elems[-1])
            fw(']\n')
    
    
    # ----------------------------------------------------------------------------
    # Command Line
    
    def main():
        import sys
    
        if "--help" in sys.argv:
            print(__doc__)
            return
    
        for arg in sys.argv[1:]:
            try:
                fbx2json(arg)
            except:
                print("Failed to convert %r, error:" % arg)
    
                import traceback
                traceback.print_exc()
    
    
    if __name__ == "__main__":
        main()