landon 0.10.2

A collection of tools, data structures and methods for exporting Blender data (such as meshes and armatures) and preparing it for your rendering pipeline.
Documentation
# The goal of this addon is to export all of the actions for the active
# armature into a JSON file

import ast
import bpy
import collections
import json
import os
from mathutils import Vector

bl_info = {
    "name": "Export Mesh to JSON",
    "category": "Import-Export",
    "blender": (2, 80, 0)
}

# Write our JSON to stdout by default or to a file if specified.
# Stdout mesh JSON is wrapped in a start and end indicators
# to more easily distinguish it from other Blender output.
#
# START_MESH_JSON $BLENDER_FILEPATH $MESH_NAME
# ... mesh json ...
# END_MESH_JSON $BLENDER_FILEPATH $MESH_NAME
class MeshToJSON(bpy.types.Operator):
    """Given an active armature, export it's actions and keyframed bone
    pose information to a JSON file"""
    # Unique identifier for the addon
    bl_idname = 'import_export.mesh2json'
    # Display name in the interface
    bl_label = 'Export Mesh to JSON'
    bl_options = {'REGISTER'}
    bl_category = 'Import-Export'

    # The filepath to write out JSON to
    # filepath = bpy.props.StringProperty(name='filepath')

    def execute(self, context):
        bpy.ops.object.mode_set(mode='OBJECT')

        mesh = bpy.context.view_layer.objects.active

        mesh_json = {
            'name': mesh.name,
            'armature_name': None,
            # [x, y, z]
            'bounding_box': {
                'min_corner': [], 'max_corner': []
            },
            'materials': {},
            'custom_properties': {},
            'attribs': {
                'vertices_in_each_face': [],
                'positions': {
                    'indices': [],
                    'attribute': {
                        'data': [],
                        'attribute_size': 3
                    }
                },
                'normals': {
                    'indices': [],
                    'attribute': {
                        'data': [],
                        'attribute_size': 3
                    }
                },
                'uvs': {
                    'indices': [],
                    'attribute': {
                        'data': [],
                        'attribute_size': 2
                    }
                },
                'bone_influences': {
                    'bones_per_vertex': {
                        'NonUniform': []
                    },
                    'bone_indices': [],
                    'bone_weights': []
                }
            }
        }

        # We maintain a list of all of the parent armature's bone names so that when exporting bone indices / weights
        # we use the same order that our armature use.
        # i.e. vertex group 12 that we export is the same as the 12th bone in the parent armature.
        # Without this the 12th vertex group on the mesh might actually be referring the 8th bone in the armature.
        # This would be a problem since our export format is currently based on the order of the bones in the armature.
        allBoneNames = []

        if mesh.parent is not None and mesh.parent.type == 'ARMATURE':
            parentArmature = mesh.parent
            mesh_json['armature_name'] = parentArmature.name
            for poseBone in parentArmature.pose.bones:
                allBoneNames.append(poseBone.name)

        # TODO: Handle triangular polygons, not just quads
        # cube.data.polygons[1].vertices[0]. Check if length
        # of face is 4... Use a triangular face in Blender to unit test.
        index = 0
        for face in mesh.data.polygons:
            num_vertices_in_face = len(face.vertices)
            mesh_json['attribs']['vertices_in_each_face'].append(num_vertices_in_face)

            for i in range(num_vertices_in_face):
                mesh_json['attribs']['positions']['indices'].append(face.vertices[i])
                # TODO: Maintain a dictionary with (x, y, z) => normal index
                # for normals that we've already run into.
                # Re-use an existing normal index wherever possible.
                # Especially important for smoothed models that mostly re-use
                # the same normals. Test this by making a cube with to faces
                # that have the same normal
                mesh_json['attribs']['normals']['indices'].append(index)
                if mesh.data.uv_layers:
                    mesh_json['attribs']['uvs']['indices'].append(face.loop_indices[i])

            # TODO: Don't append normals if we've already encountered them
            mesh_json['attribs']['normals']['attribute']['data'].append(face.normal.x)
            mesh_json['attribs']['normals']['attribute']['data'].append(face.normal.y)
            mesh_json['attribs']['normals']['attribute']['data'].append(face.normal.z)

            index += 1

        for vert in mesh.data.vertices:
            mesh_json['attribs']['positions']['attribute']['data'].append(vert.co.x)
            mesh_json['attribs']['positions']['attribute']['data'].append(vert.co.y)
            mesh_json['attribs']['positions']['attribute']['data'].append(vert.co.z)

            num_groups = len(list(vert.groups))
            for group in vert.groups:
                groupName = mesh.vertex_groups[group.group].name

                if groupName not in allBoneNames:
                    continue

                boneIndex = allBoneNames.index(groupName)

                mesh_json['attribs']['bone_influences']['bone_indices'].append(boneIndex)
                mesh_json['attribs']['bone_influences']['bone_weights'].append(group.weight)

            if mesh_json['armature_name'] is not None:
                mesh_json['attribs']['bone_influences']['bones_per_vertex']['NonUniform'].append(num_groups)

        if mesh.data.uv_layers:
            for loop in mesh.data.uv_layers.active.data:
                mesh_json['attribs']['uvs']['attribute']['data'].append(loop.uv.x)
                mesh_json['attribs']['uvs']['attribute']['data'].append(loop.uv.y)

        if not mesh_json['armature_name']:
            mesh_json['attribs']['bone_influences'] = None

        if not mesh_json['attribs']['uvs']['indices']:
            mesh_json['attribs']['uvs'] = None

        # TODO: Add unit test for no mesh currently selected
        # if mesh == None or mesh.type != 'MESH':
        #     print("__NO_MESH_SELECTED__", file=sys.stderr)
        #     return {'FINISHED'}

        # We construct our bounding box by iterating over all of the corners of the
        # mesh and finding the smallest and largest x, y and z values. Remember that we
        # are in a z up coordinate system in Blender.

        index = 0
        min_corner = [float('inf'), float('inf'), float('inf')];
        max_corner = [-float('inf'), -float('inf'), -float('inf')];

        # By switching to EDIT mode we'll ensure that our mesh is in its bind position.
        # Otherwise we might get the bounding box of the mesh while it was in the middle of some keyframe
        # which could be different from its bounding box in bind position.
        bpy.ops.object.mode_set(mode = 'EDIT')

        for corner in mesh.bound_box:
            # Get the Blender world space (within Blender) coordinates for the corner of this mesh.
            # This gives us the actual (x, y, z) coordinates of the corner in Blender's coordinate space,
            # instead of relative to the model's origin.
            # Modified from - https://blender.stackexchange.com/a/8470
            corner = Vector(corner)
            corner = mesh.matrix_world @ corner

            # Min Corner
            min_corner[0] = min(min_corner[0], corner.x)
            min_corner[1] = min(min_corner[1], corner.y)
            min_corner[2] = min(min_corner[2], corner.z)
            # Max corner
            max_corner[0] = max(max_corner[0], corner.x)
            max_corner[1] = max(max_corner[1], corner.y)
            max_corner[2] = max(max_corner[2], corner.z)

        bpy.ops.object.mode_set(mode = 'OBJECT')
        mesh_json['bounding_box']['min_corner'] = min_corner
        mesh_json['bounding_box']['max_corner'] = max_corner

        for material in mesh.data.materials:
            if material.node_tree == None:
                continue;

            # Iterate over the nodes until we find the Principled BSDF node. Then
            # read its properties
            for node in material.node_tree.nodes:
                baseColor = {}
                roughness = {}
                metallic = {}
                normalMap = None

                if node.type == 'BSDF_PRINCIPLED':
                    if len(node.inputs['Base Color'].links) > 0:
                        link = node.inputs['Base Color'].links[0]

                        # If there is a node feeding into the base_color, use
                        # that node's output color or image

                        if link.from_node.type == 'TEX_IMAGE':
                            baseColor['ImageTexture'] = link.from_node.image.name
                        else:
                            color = link.from_node.outputs['Color'].default_value
                            baseColor['Uniform'] = [
                                color[0], color[1], color[2]
                            ]
                    else:
                        # Otherwise use the output color set in the principled
                        # nodes color selector
                        color = node.inputs['Base Color'].default_value
                        baseColor['Uniform'] = [
                            color[0], color[1], color[2]
                        ]

                    if len(node.inputs['Roughness'].links) > 0:
                        link = node.inputs['Roughness'].links[0]

                        # If there is a node feeding into the roughness, use
                        # that node's output color or image

                        if link.from_node.type == 'TEX_IMAGE':
                            # If the channels weren't split, default to red
                            # channel
                            roughness['ImageTexture'] = [
                                link.from_node.image.name,
                                "R"
                            ]
                        elif link.from_node.type == 'SEPRGB':
                            print(mesh.name)
                            # example: ["some-texture.png", "R"]
                            roughness['ImageTexture'] = [
                                link.from_node.inputs['Image'].links[0].from_node.image.name,
                                link.from_socket.name # R, G or B
                            ]
                        else:
                            roughness['Uniform'] = link.from_node.outputs['Value'].default_value
                    else:
                        # Otherwise use the output color set in the principled
                        # nodes color selector
                        roughness['Uniform'] = node.inputs['Roughness'].default_value

                    if len(node.inputs['Metallic'].links) > 0:
                        link = node.inputs['Metallic'].links[0]

                        # If there is a node feeding into the metallic, use
                        # that node's output color or image

                        if link.from_node.type == 'TEX_IMAGE':
                            metallic['ImageTexture'] = [
                                # If the channels weren't split, default to
                                # green channel
                                link.from_node.image.name,
                                "G"
                            ]
                        elif link.from_node.type == 'SEPRGB':
                            # example: ["some-texture.png", "G"]
                            metallic['ImageTexture'] = [
                                link.from_node.inputs['Image'].links[0].from_node.image.name,
                                link.from_socket.name # R, G or B
                            ]
                        else:
                            metallic['Uniform'] = link.from_node.outputs['Value'].default_value

                    else:
                        # Otherwise use the output color set in the principled
                        # nodes color selector
                        metallic['Uniform'] = node.inputs['Metallic'].default_value

                    # Work backwards up to the normal map's image texture.
                    # Principled Node -> Normal Map -> Image Texture
                    if len(node.inputs['Normal'].links) > 0:
                        link = node.inputs['Normal'].links[0]

                        if link.from_node.type == 'NORMAL_MAP':
                            normalMapNode = link.from_node
                            normalMap = normalMapNode.inputs['Color'].links[0].from_node.image.name

                    mesh_json['materials'][material.name] = {
                        'base_color': baseColor,
                        'roughness': roughness,
                        'metallic': metallic,
                        'normal_map': normalMap
                    }

        for property in mesh.keys():
            # Not sure what this is but it gets automatically added into the properties. So we ignore it
            if property == '_RNA_UI':
                continue

            # Some properties such as 'cycles_visibility' are automatically inserted by Blender, but can't be
            # serialized.
            # Here we test if a property can be serialized, and if it can't we just skip it
            try:
                value = mesh.get(property)
                json.dumps(value)

                typed_value = {}
                try:
                    maybe_list = json.loads(value)
                    if isinstance(maybe_list, list):
                        typed_value = {"Vec": []}
                        for item in maybe_list:
                            if isinstance(item, float):
                                typed_value["Vec"].append({"Float": item})
                            elif isinstance(item, int):
                                typed_value["Vec"].append({"Int": item})
                            elif isinstance(item, str):
                                typed_value["Vec"].append({"String": item})
                        mesh_json['custom_properties'][property] = typed_value
                        continue
                except:
                    if isinstance(value, float):
                        typed_value = {"Float": value}
                    elif isinstance(value, int):
                        typed_value = {"Int": value}
                    elif isinstance(value, str):
                        typed_value = {"String": value}

                    mesh_json['custom_properties'][property] = typed_value
            except:
                pass

        # START_MESH_JSON $BLENDER_FILEPATH $MESH_NAME
        # ... mesh json ...
        # END_MESH_JSON $BLENDER_FILEPATH $MESH_NAME
        #
        # NOTE: Intentionally done in one print statement to get around
        # a bug where other Blender output (in this case from bpy.ops.anim.keyframe_delete(override, type='LocRotScale')
        # calls in blender-iks-to-fks) was getting mixed in with our JSON output
        output = "START_MESH_JSON " + bpy.data.filepath + " " + mesh.name
        output += "\n"
        output += json.dumps(mesh_json)
        output += "\n"
        output += "END_MESH_JSON " + bpy.data.filepath + " " + mesh.name
        print(output)

        return {'FINISHED'}

def register():
    bpy.utils.register_class(MeshToJSON)

def unregister():
    bpy.utils.unregister_class(MeshToJSON)

if __name__ == "__main__":
    register()