Преглед изворни кода

Blender 5.0

- split into multiple source files & .ZIP file distribution
jeff пре 9 година
родитељ
комит
a810c16b03

BIN
Exporters/Blender/Blender2Babylon-5.0.zip


+ 2 - 0
Exporters/Blender/readme.md

@@ -27,6 +27,7 @@ For a discussion of Tower of Babel exporter, along with the difference this expo
  * Energy
  * Diffuse color
  * Specular color
+ * Include only meshes in same Blender layer
  * Shadow maps, all types (For directional lights)
  * Actions exported as AnimationRanges
 * **Materials**
@@ -48,6 +49,7 @@ For a discussion of Tower of Babel exporter, along with the difference this expo
  * Procedural Texture Baking
  * Cycles Render Baking
  * Check Ready Only Once
+ * Maximum Simultaneous Lights
 * **Multi-materials**
  * Name
  * Child materials

+ 89 - 0
Exporters/Blender/src/__init__.py

@@ -0,0 +1,89 @@
+bl_info = {
+    'name': 'Babylon.js',
+    'author': 'David Catuhe, Jeff Palmer',
+    'version': (5, 0, 0),
+    'blender': (2, 76, 0),
+    'location': 'File > Export > Babylon.js (.babylon)',
+    'description': 'Export Babylon.js scenes (.babylon)',
+    'wiki_url': 'https://github.com/BabylonJS/Babylon.js/tree/master/Exporters/Blender',
+    'tracker_url': '',
+    'category': 'Babylon.JS'}
+
+# allow module to be changed during a session (dev purposes)
+if "bpy" in locals():
+    print('Reloading TOB exporter')
+    import imp
+    imp.reload(animation)
+    imp.reload(armature)
+    imp.reload(camera)
+    imp.reload(exporter_settings_panel)
+    imp.reload(f_curve_animatable)
+    imp.reload(json_exporter)
+    imp.reload(light_shadow)
+    imp.reload(logger)
+    imp.reload(material)
+    imp.reload(mesh)
+    imp.reload(package_level)
+    imp.reload(sound)
+    imp.reload(world)
+else:
+    from . import animation
+    from . import armature
+    from . import camera
+    from . import exporter_settings_panel
+    from . import f_curve_animatable
+    from . import json_exporter
+    from . import light_shadow
+    from . import logger
+    from . import material
+    from . import mesh
+    from . import package_level
+    from . import sound
+    from . import world
+
+import bpy
+from bpy_extras.io_utils import ExportHelper, ImportHelper
+#===============================================================================
+def register():
+    bpy.utils.register_module(__name__)
+    bpy.types.INFO_MT_file_export.append(menu_func)
+    
+def unregister():
+    bpy.utils.unregister_module(__name__)
+    bpy.types.INFO_MT_file_export.remove(menu_func)
+
+# Registration the calling of the INFO_MT_file_export file selector
+def menu_func(self, context):
+    from .package_level import get_title
+    # the info for get_title is in this file, but getting it the same way as others
+    self.layout.operator(JsonMain.bl_idname, get_title())
+
+if __name__ == '__main__':
+    unregister()
+    register()
+#===============================================================================
+class JsonMain(bpy.types.Operator, ExportHelper):
+    bl_idname = 'bjs.main'
+    bl_label = 'Export Babylon.js scene' # used on the label of the actual 'save' button
+    filename_ext = '.babylon'            # used as the extension on file selector
+
+    filepath = bpy.props.StringProperty(subtype = 'FILE_PATH') # assigned once the file selector returns
+    
+    def execute(self, context):
+        from .json_exporter import JsonExporter
+        from .package_level import get_title, verify_min_blender_version
+        
+        if not verify_min_blender_version():
+            self.report({'ERROR'}, 'version of Blender too old.')
+            return {'FINISHED'}
+            
+        exporter = JsonExporter()
+        exporter.execute(context, self.filepath)
+        
+        if (exporter.fatalError):
+            self.report({'ERROR'}, exporter.fatalError)
+
+        elif (exporter.nWarnings > 0):
+            self.report({'WARNING'}, 'Processing completed, but ' + str(exporter.nWarnings) + ' WARNINGS were raised,  see log file.')
+            
+        return {'FINISHED'}

+ 174 - 0
Exporters/Blender/src/animation.py

@@ -0,0 +1,174 @@
+from .logger import *
+from .package_level import *
+
+import bpy
+
+FRAME_BASED_ANIMATION = True # turn off for diagnostics; only actual keyframes will be written for skeleton animation
+
+# passed to Animation constructor from animatable objects, defined in BABYLON.Animation
+#ANIMATIONTYPE_FLOAT = 0
+ANIMATIONTYPE_VECTOR3 = 1
+ANIMATIONTYPE_QUATERNION = 2
+ANIMATIONTYPE_MATRIX = 3
+#ANIMATIONTYPE_COLOR3 = 4
+
+# passed to Animation constructor from animatable objects, defined in BABYLON.Animation
+#ANIMATIONLOOPMODE_RELATIVE = 0
+ANIMATIONLOOPMODE_CYCLE = 1
+#ANIMATIONLOOPMODE_CONSTANT = 2
+#===============================================================================
+class AnimationRange:
+    # constructor called by the static actionPrep method
+    def __init__(self, name, frames, frameOffset):
+        # process input args to members
+        self.name = name
+        self.frames_in = frames
+        self.frame_start = AnimationRange.nextStartingFrame(frameOffset)
+
+        self.frames_out = []
+        for frame in self.frames_in:
+            self.frames_out.append(self.frame_start + frame)
+
+        highest_idx = len(self.frames_in) - 1
+        self.highest_frame_in = self.frames_in [highest_idx]
+        self.frame_end        = self.frames_out[highest_idx]
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_string(self):
+        return self.name + ': ' + ' in[' + format_int(self.frames_in[0]) + ' - ' + format_int(self.highest_frame_in) + '], out[' + format_int(self.frame_start) + ' - ' + format_int(self.frame_end) + ']'
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_string(file_handler, 'name', self.name, True)
+        write_int(file_handler, 'from', self.frame_start)
+        write_int(file_handler, 'to', self.frame_end)
+        file_handler.write('}')
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    @staticmethod
+    def actionPrep(object, action, includeAllFrames, frameOffset):
+        # when name in format of object-action, verify object's name matches
+        if action.name.find('-') > 0:
+            split = action.name.partition('-')
+            if split[0] != object.name: return None
+            actionName = split[2]
+        else:
+            actionName = action.name
+
+        # assign the action to the object
+        object.animation_data.action = action
+
+        if includeAllFrames:
+            frame_start = int(action.frame_range[0])
+            frame_end   = int(action.frame_range[1])
+            frames = range(frame_start, frame_end + 1) # range is not inclusive with 2nd arg
+
+        else:
+            # capture built up from fcurves
+            frames = dict()
+            for fcurve in object.animation_data.action.fcurves:
+                for key in fcurve.keyframe_points:
+                    frame = key.co.x
+                    frames[frame] = True
+
+            frames = sorted(frames)
+
+        return AnimationRange(actionName, frames, frameOffset)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    @staticmethod
+    def nextStartingFrame(frameOffset):
+        if frameOffset == 0: return 0
+
+        # ensure a gap of at least 5 frames, starting on an even multiple of 10
+        frameOffset += 4
+        remainder = frameOffset % 10
+        return frameOffset + 10 - remainder
+
+#===============================================================================
+class Animation:
+    def __init__(self, dataType, loopBehavior, name, propertyInBabylon, attrInBlender = None, mult = 1, xOffset = 0):
+        self.dataType = dataType
+        self.framePerSecond = bpy.context.scene.render.fps
+        self.loopBehavior = loopBehavior
+        self.name = name
+        self.propertyInBabylon = propertyInBabylon
+
+        # these never get used by Bones, so optional in contructor args
+        self.attrInBlender = attrInBlender
+        self.mult = mult
+        self.xOffset = xOffset
+
+        #keys
+        self.frames = []
+        self.values = [] # vector3 for ANIMATIONTYPE_VECTOR3 & matrices for ANIMATIONTYPE_MATRIX
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    # a separate method outside of constructor, so can be called once for each Blender Action object participates in
+    def append_range(self, object, animationRange):
+        # action already assigned, always using poses, not every frame, build up again filtering by attrInBlender
+        for idx in range(len(animationRange.frames_in)):
+            bpy.context.scene.frame_set(animationRange.frames_in[idx])
+
+            self.frames.append(animationRange.frames_out[idx])
+            self.values.append(self.get_attr(object))
+
+        return len(animationRange.frames_in) > 0
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    # for auto animate
+    def get_first_frame(self):
+        return self.frames[0] if len(self.frames) > 0 else -1
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    # for auto animate
+    def get_last_frame(self):
+        return self.frames[len(self.frames) - 1] if len(self.frames) > 0 else -1
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_int(file_handler, 'dataType', self.dataType, True)
+        write_int(file_handler, 'framePerSecond', self.framePerSecond)
+
+        file_handler.write(',"keys":[')
+        first = True
+        for frame_idx in range(len(self.frames)):
+            if first != True:
+                file_handler.write(',')
+            first = False
+            file_handler.write('\n{')
+            write_int(file_handler, 'frame', self.frames[frame_idx], True)
+            value_idx = self.values[frame_idx]
+            if self.dataType == ANIMATIONTYPE_MATRIX:
+                write_matrix4(file_handler, 'values', value_idx)
+            elif self.dataType == ANIMATIONTYPE_QUATERNION:
+                write_quaternion(file_handler, 'values', value_idx)
+            else:
+                write_vector(file_handler, 'values', value_idx)
+            file_handler.write('}')
+
+        file_handler.write(']')   # close keys
+
+        # put this at the end to make less crazy looking ]}]]]}}}}}}}]]]],
+        # since animation is also at the end of the bone, mesh, camera, or light
+        write_int(file_handler, 'loopBehavior', self.loopBehavior)
+        write_string(file_handler, 'name', self.name)
+        write_string(file_handler, 'property', self.propertyInBabylon)
+        file_handler.write('}')
+#===============================================================================
+class VectorAnimation(Animation):
+    def __init__(self, object, propertyInBabylon, attrInBlender, mult = 1, xOffset = 0):
+        super().__init__(ANIMATIONTYPE_VECTOR3, ANIMATIONLOOPMODE_CYCLE, propertyInBabylon + ' animation', propertyInBabylon, attrInBlender, mult, xOffset)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def get_attr(self, object):
+        return scale_vector(getattr(object, self.attrInBlender), self.mult, self.xOffset)
+#===============================================================================
+class QuaternionAnimation(Animation):
+    def __init__(self, object, propertyInBabylon, attrInBlender, mult = 1, xOffset = 0):
+        super().__init__(ANIMATIONTYPE_QUATERNION, ANIMATIONLOOPMODE_CYCLE, propertyInBabylon + ' animation', propertyInBabylon, attrInBlender, mult, xOffset)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def get_attr(self, object):
+        return post_rotate_quaternion(getattr(object, self.attrInBlender), self.xOffset)
+#===============================================================================
+class QuaternionToEulerAnimation(Animation):
+    def __init__(self, propertyInBabylon, attrInBlender, mult = 1, xOffset = 0):
+        super().__init__(ANIMATIONTYPE_VECTOR3, ANIMATIONLOOPMODE_CYCLE, propertyInBabylon + ' animation', propertyInBabylon, attrInBlender, mult, Offset)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def get_attr(self, object):
+        quat = getattr(object, self.attrInBlender)
+        eul  = quat.to_euler("XYZ")
+        return scale_vector(eul, self.mult, self.xOffset)

+ 194 - 0
Exporters/Blender/src/armature.py

@@ -0,0 +1,194 @@
+from .animation import *
+from .logger import *
+from .package_level import *
+
+import bpy
+from math import radians
+from mathutils import Vector, Matrix
+
+DEFAULT_LIB_NAME = 'Same as filename'
+#===============================================================================
+class Bone:
+    def __init__(self, bpyBone, bpySkeleton, bonesSoFar):
+        self.index = len(bonesSoFar)
+        Logger.log('processing begun of bone:  ' + bpyBone.name + ', index:  '+ str(self.index), 2)
+        self.name = bpyBone.name
+        self.length = bpyBone.length
+        self.posedBone = bpyBone # record so can be used by get_matrix, called by append_animation_pose
+        self.parentBone = bpyBone.parent
+
+        self.matrix_world = bpySkeleton.matrix_world
+        self.matrix = self.get_bone_matrix()
+
+        self.parentBoneIndex = Skeleton.get_bone(bpyBone.parent.name, bonesSoFar).index if bpyBone.parent else -1
+
+        #animation
+        if (bpySkeleton.animation_data):
+            self.animation = Animation(ANIMATIONTYPE_MATRIX, ANIMATIONLOOPMODE_CYCLE, 'anim', '_matrix')
+            self.previousBoneMatrix = None
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def append_animation_pose(self, frame, force = False):
+        currentBoneMatrix = self.get_bone_matrix()
+
+        if (force or not same_matrix4(currentBoneMatrix, self.previousBoneMatrix)):
+            self.animation.frames.append(frame)
+            self.animation.values.append(currentBoneMatrix)
+            self.previousBoneMatrix = currentBoneMatrix
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def set_rest_pose(self, editBone):
+        self.rest = Bone.get_matrix(editBone, self.matrix_world, True)
+        # used to calc skeleton restDimensions
+        self.restHead = editBone.head
+        self.restTail = editBone.tail
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def get_bone_matrix(self, doParentMult = True):
+        return Bone.get_matrix(self.posedBone, self.matrix_world, doParentMult)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    @staticmethod
+    def get_matrix(bpyBone, matrix_world, doParentMult):
+        SystemMatrix = Matrix.Scale(-1, 4, Vector((0, 0, 1))) * Matrix.Rotation(radians(-90), 4, 'X')
+
+        if (bpyBone.parent and doParentMult):
+            return (SystemMatrix * matrix_world * bpyBone.parent.matrix).inverted() * (SystemMatrix * matrix_world * bpyBone.matrix)
+        else:
+            return SystemMatrix * matrix_world * bpyBone.matrix
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('\n{')
+        write_string(file_handler, 'name', self.name, True)
+        write_int(file_handler, 'index', self.index)
+        write_matrix4(file_handler, 'matrix', self.matrix)
+        write_matrix4(file_handler, 'rest', self.rest)
+        write_int(file_handler, 'parentBoneIndex', self.parentBoneIndex)
+        write_float(file_handler, 'length', self.length)
+
+        #animation
+        if hasattr(self, 'animation'):
+            file_handler.write('\n,"animation":')
+            self.animation.to_scene_file(file_handler)
+
+        file_handler.write('}')
+#===============================================================================
+class Skeleton:
+    # skipAnimations argument only used when exporting QI.SkeletonPoseLibrary
+    def __init__(self, bpySkeleton, scene, id, ignoreIKBones, skipAnimations = False):
+        Logger.log('processing begun of skeleton:  ' + bpySkeleton.name + ', id:  '+ str(id))
+        self.name = bpySkeleton.name
+        self.id = id
+        self.bones = []
+
+        for bone in bpySkeleton.pose.bones:
+            if ignoreIKBones and Skeleton.isIkName(bone.name):
+                Logger.log('Ignoring IK bone:  ' + bone.name, 2)
+                continue
+
+            self.bones.append(Bone(bone, bpySkeleton, self.bones))
+
+        if (bpySkeleton.animation_data and not skipAnimations):
+            self.ranges = []
+            frameOffset = 0
+            for action in bpy.data.actions:
+                # get the range / assigning the action to the object
+                animationRange = AnimationRange.actionPrep(bpySkeleton, action, FRAME_BASED_ANIMATION, frameOffset)
+                if animationRange is None:
+                    continue
+
+                Logger.log('processing action ' + animationRange.to_string(), 2)
+                self.ranges.append(animationRange)
+
+                nFrames = len(animationRange.frames_in)
+                for idx in range(nFrames):
+                    bpy.context.scene.frame_set(animationRange.frames_in[idx])
+                    firstOrLast = idx == 0 or idx == nFrames - 1
+
+                    for bone in self.bones:
+                        bone.append_animation_pose(animationRange.frames_out[idx], firstOrLast)
+
+                frameOffset = animationRange.frame_end
+
+        # mode_set's only work when there is an active object, switch bones to edit mode to rest position
+        scene.objects.active = bpySkeleton
+        bpy.ops.object.mode_set(mode='EDIT')
+
+        # you need to access edit_bones from skeleton.data not skeleton.pose when in edit mode
+        for editBone in bpySkeleton.data.edit_bones:
+            for myBoneObj in self.bones:
+                if editBone.name == myBoneObj.name:
+                    myBoneObj.set_rest_pose(editBone)
+                    break
+
+        self.dimensions = self.getDimensions()
+
+        bpy.ops.object.mode_set(mode='POSE')
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    # do not use .dimensions from blender, it might be including IK bones
+    def getDimensions(self):
+        highest = Vector((-10000, -10000, -10000))
+        lowest  = Vector(( 10000,  10000,  10000))
+
+        for bone in self.bones:
+            if highest.x < bone.restHead.x: highest.x = bone.restHead.x
+            if highest.y < bone.restHead.y: highest.y = bone.restHead.y
+            if highest.z < bone.restHead.z: highest.z = bone.restHead.z
+
+            if highest.x < bone.restTail.x: highest.x = bone.restTail.x
+            if highest.y < bone.restTail.y: highest.y = bone.restTail.y
+            if highest.z < bone.restTail.z: highest.z = bone.restTail.z
+
+            if lowest .x > bone.restHead.x: lowest .x = bone.restHead.x
+            if lowest .y > bone.restHead.y: lowest .y = bone.restHead.y
+            if lowest .z > bone.restHead.z: lowest .z = bone.restHead.z
+
+            if lowest .x > bone.restTail.x: lowest .x = bone.restTail.x
+            if lowest .y > bone.restTail.y: lowest .y = bone.restTail.y
+            if lowest .z > bone.restTail.z: lowest .z = bone.restTail.z
+
+        return Vector((highest.x - lowest.x, highest.y - lowest.y, highest.z - lowest.z))
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    @staticmethod
+    def isIkName(boneName):
+        return '.ik' in boneName.lower() or 'ik.' in boneName.lower()
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    # Since IK bones could be being skipped, looking up index of bone in second pass of mesh required
+    def get_index_of_bone(self, boneName):
+        return Skeleton.get_bone(boneName, self.bones).index
+
+    @staticmethod
+    def get_bone(boneName, bones):
+        for bone in bones:
+            if boneName == bone.name:
+                return bone
+
+        # should not happen, but if it does clearly a bug, so terminate
+        raise Exception('bone name "' + boneName + '" not found in skeleton')
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_string(file_handler, 'name', self.name, True)
+        write_int(file_handler, 'id', self.id)  # keep int for legacy of original exporter
+        write_vector(file_handler, 'dimensionsAtRest', self.dimensions)
+
+        file_handler.write(',"bones":[')
+        first = True
+        for bone in self.bones:
+            if first != True:
+                file_handler.write(',')
+            first = False
+
+            bone.to_scene_file(file_handler)
+
+        file_handler.write(']')
+
+        if hasattr(self, 'ranges'):
+            file_handler.write('\n,"ranges":[')
+            first = True
+            for range in self.ranges:
+                if first != True:
+                    file_handler.write(',')
+                first = False
+
+                range.to_scene_file(file_handler)
+
+            file_handler.write(']')
+
+        file_handler.write('}')

+ 216 - 0
Exporters/Blender/src/camera.py

@@ -0,0 +1,216 @@
+from .logger import *
+from .package_level import *
+
+from .f_curve_animatable import *
+
+import bpy
+import math
+import mathutils
+
+# camera class names, never formally defined in Babylon, but used in babylonFileLoader
+ARC_ROTATE_CAM = 'ArcRotateCamera'
+DEV_ORIENT_CAM = 'DeviceOrientationCamera'
+FOLLOW_CAM = 'FollowCamera'
+FREE_CAM = 'FreeCamera'
+GAMEPAD_CAM = 'GamepadCamera'
+TOUCH_CAM = 'TouchCamera'
+V_JOYSTICKS_CAM = 'VirtualJoysticksCamera'
+VR_DEV_ORIENT_FREE_CAM ='VRDeviceOrientationFreeCamera'
+WEB_VR_FREE_CAM = 'WebVRFreeCamera'
+
+# 3D camera rigs, defined in BABYLON.Camera, must be strings to be in 'dropdown'
+RIG_MODE_NONE = '0'
+RIG_MODE_STEREOSCOPIC_ANAGLYPH = '10'
+RIG_MODE_STEREOSCOPIC_SIDEBYSIDE_PARALLEL = '11'
+RIG_MODE_STEREOSCOPIC_SIDEBYSIDE_CROSSEYED = '12'
+RIG_MODE_STEREOSCOPIC_OVERUNDER = '13'
+RIG_MODE_VR = '20'
+#===============================================================================
+class Camera(FCurveAnimatable):
+    def __init__(self, camera):
+        if camera.parent and camera.parent.type != 'ARMATURE':
+            self.parentId = camera.parent.name
+
+        self.CameraType = camera.data.CameraType
+        self.name = camera.name
+        Logger.log('processing begun of camera (' + self.CameraType + '):  ' + self.name)
+        self.define_animations(camera, True, True, False, math.pi / 2)
+        self.position = camera.location
+
+        # for quaternions, convert to euler XYZ, otherwise, use the default rotation_euler
+        eul = camera.rotation_quaternion.to_euler("XYZ") if camera.rotation_mode == 'QUATERNION' else camera.rotation_euler
+        self.rotation = mathutils.Vector((-eul[0] + math.pi / 2, eul[1], -eul[2]))
+
+        self.fov = camera.data.angle
+        self.minZ = camera.data.clip_start
+        self.maxZ = camera.data.clip_end
+        self.speed = 1.0
+        self.inertia = 0.9
+        self.checkCollisions = camera.data.checkCollisions
+        self.applyGravity = camera.data.applyGravity
+        self.ellipsoid = camera.data.ellipsoid
+
+        self.Camera3DRig = camera.data.Camera3DRig
+        self.interaxialDistance = camera.data.interaxialDistance
+
+        for constraint in camera.constraints:
+            if constraint.type == 'TRACK_TO':
+                self.lockedTargetId = constraint.target.name
+                break
+
+
+        if self.CameraType == ARC_ROTATE_CAM or self.CameraType == FOLLOW_CAM:
+            if not hasattr(self, 'lockedTargetId'):
+                Logger.warn('Camera type with manditory target specified, but no target to track set.  Ignored', 2)
+                self.fatalProblem = True
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def update_for_target_attributes(self, meshesAndNodes):
+        if not hasattr(self, 'lockedTargetId'): return
+
+        # find the actual mesh tracking, so properties can be derrived
+        targetFound = False
+        for mesh in meshesAndNodes:
+            if mesh.name == self.lockedTargetId:
+                targetMesh = mesh
+                targetFound = True
+                break;
+
+        xApart = 3 if not targetFound else self.position.x - targetMesh.position.x
+        yApart = 3 if not targetFound else self.position.y - targetMesh.position.y
+        zApart = 3 if not targetFound else self.position.z - targetMesh.position.z
+
+        distance3D = math.sqrt(xApart * xApart + yApart * yApart + zApart * zApart)
+
+        alpha = math.atan2(yApart, xApart);
+        beta  = math.atan2(yApart, zApart);
+
+        if self.CameraType == FOLLOW_CAM:
+            self.followHeight   =  zApart
+            self.followDistance = distance3D
+            self.followRotation =  90 + (alpha * 180 / math.pi)
+
+        elif self.CameraType == self.CameraType == ARC_ROTATE_CAM:
+            self.arcRotAlpha  = alpha
+            self.arcRotBeta   = beta
+            self.arcRotRadius = distance3D
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_string(file_handler, 'name', self.name, True)
+        write_string(file_handler, 'id', self.name)
+        write_vector(file_handler, 'position', self.position)
+        write_vector(file_handler, 'rotation', self.rotation)
+        write_float(file_handler, 'fov', self.fov)
+        write_float(file_handler, 'minZ', self.minZ)
+        write_float(file_handler, 'maxZ', self.maxZ)
+        write_float(file_handler, 'speed', self.speed)
+        write_float(file_handler, 'inertia', self.inertia)
+        write_bool(file_handler, 'checkCollisions', self.checkCollisions)
+        write_bool(file_handler, 'applyGravity', self.applyGravity)
+        write_array3(file_handler, 'ellipsoid', self.ellipsoid)
+
+        # always assign rig, even when none, Reason:  Could have VR camera with different Rig than default
+        write_int(file_handler, 'cameraRigMode', self.Camera3DRig)
+        write_float(file_handler, 'interaxial_distance', self.interaxialDistance)
+
+        write_string(file_handler, 'type', self.CameraType)
+
+        if hasattr(self, 'parentId'): write_string(file_handler, 'parentId', self.parentId)
+
+        if self.CameraType == FOLLOW_CAM:
+            write_float(file_handler, 'heightOffset',  self.followHeight)
+            write_float(file_handler, 'radius',  self.followDistance)
+            write_float(file_handler, 'rotationOffset',  self.followRotation)
+
+        elif self.CameraType == ARC_ROTATE_CAM:
+            write_float(file_handler, 'alpha', self.arcRotAlpha)
+            write_float(file_handler, 'beta', self.arcRotBeta)
+            write_float(file_handler, 'radius',  self.arcRotRadius)
+
+        if hasattr(self, 'lockedTargetId'):
+            write_string(file_handler, 'lockedTargetId', self.lockedTargetId)
+
+        super().to_scene_file(file_handler) # Animations
+        file_handler.write('}')
+#===============================================================================
+bpy.types.Camera.autoAnimate = bpy.props.BoolProperty(
+    name='Auto launch animations',
+    description='',
+    default = False
+)
+bpy.types.Camera.CameraType = bpy.props.EnumProperty(
+    name='Camera Type',
+    description='',
+    # ONLY Append, or existing .blends will have their camera changed
+    items = (
+             (V_JOYSTICKS_CAM        , 'Virtual Joysticks'       , 'Use Virtual Joysticks Camera'),
+             (TOUCH_CAM              , 'Touch'                   , 'Use Touch Camera'),
+             (GAMEPAD_CAM            , 'Gamepad'                 , 'Use Gamepad Camera'),
+             (FREE_CAM               , 'Free'                    , 'Use Free Camera'),
+             (FOLLOW_CAM             , 'Follow'                  , 'Use Follow Camera'),
+             (DEV_ORIENT_CAM         , 'Device Orientation'      , 'Use Device Orientation Camera'),
+             (ARC_ROTATE_CAM         , 'Arc Rotate'              , 'Use Arc Rotate Camera'),
+             (VR_DEV_ORIENT_FREE_CAM , 'VR Dev Orientation Free' , 'Use VR Dev Orientation Free Camera'),
+             (WEB_VR_FREE_CAM        , 'Web VR Free'             , 'Use Web VR Free Camera')
+            ),
+    default = FREE_CAM
+)
+bpy.types.Camera.checkCollisions = bpy.props.BoolProperty(
+    name='Check Collisions',
+    description='',
+    default = False
+)
+bpy.types.Camera.applyGravity = bpy.props.BoolProperty(
+    name='Apply Gravity',
+    description='',
+    default = False
+)
+bpy.types.Camera.ellipsoid = bpy.props.FloatVectorProperty(
+    name='Ellipsoid',
+    description='',
+    default = mathutils.Vector((0.2, 0.9, 0.2))
+)
+bpy.types.Camera.Camera3DRig = bpy.props.EnumProperty(
+    name='Rig',
+    description='',
+    items = (
+             (RIG_MODE_NONE                             , 'None'                  , 'No 3D effects'),
+             (RIG_MODE_STEREOSCOPIC_ANAGLYPH            , 'Anaaglph'              , 'Stereoscopic Anagylph'),
+             (RIG_MODE_STEREOSCOPIC_SIDEBYSIDE_PARALLEL , 'side-by-side Parallel' , 'Stereoscopic side-by-side parallel'),
+             (RIG_MODE_STEREOSCOPIC_SIDEBYSIDE_CROSSEYED, 'side-by-side crosseyed', 'Stereoscopic side-by-side crosseyed'),
+             (RIG_MODE_STEREOSCOPIC_OVERUNDER           , 'over-under'            , 'Stereoscopic over-under'),
+             (RIG_MODE_VR                               , 'VR distortion'         , 'Use Web VR Free Camera')
+            ),
+    default = RIG_MODE_NONE
+)
+bpy.types.Camera.interaxialDistance = bpy.props.FloatProperty(
+    name='Interaxial Distance',
+    description='Distance between cameras.  Used by all but VR 3D rigs.',
+    default = 0.0637
+)
+#===============================================================================
+class CameraPanel(bpy.types.Panel):
+    bl_label = get_title()
+    bl_space_type = 'PROPERTIES'
+    bl_region_type = 'WINDOW'
+    bl_context = 'data'
+
+    @classmethod
+    def poll(cls, context):
+        ob = context.object
+        return ob is not None and isinstance(ob.data, bpy.types.Camera)
+
+    def draw(self, context):
+        ob = context.object
+        layout = self.layout
+        layout.prop(ob.data, 'CameraType')
+        layout.prop(ob.data, 'checkCollisions')
+        layout.prop(ob.data, 'applyGravity')
+        layout.prop(ob.data, 'ellipsoid')
+
+        box = layout.box()
+        box.label(text="3D Camera Rigs")
+        box.prop(ob.data, 'Camera3DRig')
+        box.prop(ob.data, 'interaxialDistance')
+
+        layout.prop(ob.data, 'autoAnimate')

+ 70 - 0
Exporters/Blender/src/exporter_settings_panel.py

@@ -0,0 +1,70 @@
+from .package_level import *
+
+import bpy
+# Panel displayed in Scene Tab of properties, so settings can be saved in a .blend file
+class ExporterSettingsPanel(bpy.types.Panel):
+    bl_label = get_title()
+    bl_space_type = 'PROPERTIES'
+    bl_region_type = 'WINDOW'
+    bl_context = 'scene'
+
+    bpy.types.Scene.export_onlySelectedLayer = bpy.props.BoolProperty(
+        name="Export only selected layers",
+        description="Export only selected layers",
+        default = False,
+        )
+    bpy.types.Scene.export_flatshadeScene = bpy.props.BoolProperty(
+        name="Flat shade entire scene",
+        description="Use face normals on all meshes.  Increases vertices.",
+        default = False,
+        )
+    bpy.types.Scene.attachedSound = bpy.props.StringProperty(
+        name='Sound',
+        description='',
+        default = ''
+        )
+    bpy.types.Scene.loopSound = bpy.props.BoolProperty(
+        name='Loop sound',
+        description='',
+        default = True
+        )
+    bpy.types.Scene.autoPlaySound = bpy.props.BoolProperty(
+        name='Auto play sound',
+        description='',
+        default = True
+        )
+    bpy.types.Scene.inlineTextures = bpy.props.BoolProperty(
+        name="inline",
+        description="turn textures into encoded strings, for direct inclusion into source code",
+        default = False,
+        )
+    bpy.types.Scene.textureDir = bpy.props.StringProperty(
+        name='sub-directory',
+        description='The path below the output directory to write texture files (any separators OS dependent)',
+        default = ''
+        )
+    bpy.types.Scene.ignoreIKBones = bpy.props.BoolProperty(
+        name="Ignore IK Bones",
+        description="Do not export bones with either '.ik' or 'ik.'(not case sensitive) in the name",
+        default = False,
+        )
+
+    def draw(self, context):
+        layout = self.layout
+
+        scene = context.scene
+        layout.prop(scene, "export_onlySelectedLayer")
+        layout.prop(scene, "export_flatshadeScene")
+        layout.prop(scene, "ignoreIKBones")
+
+        box = layout.box()
+        box.label(text='Texture Location:')
+        box.prop(scene, "inlineTextures")
+        row = box.row()
+        row.enabled = not scene.inlineTextures
+        row.prop(scene, "textureDir")
+
+        box = layout.box()
+        box.prop(scene, 'attachedSound')
+        box.prop(scene, 'autoPlaySound')
+        box.prop(scene, 'loopSound')

+ 105 - 0
Exporters/Blender/src/f_curve_animatable.py

@@ -0,0 +1,105 @@
+from .animation import *
+from .logger import *
+from .package_level import *
+
+import bpy
+#===============================================================================
+class FCurveAnimatable:
+    def define_animations(self, object, supportsRotation, supportsPosition, supportsScaling, xOffsetForRotation = 0):
+
+        # just because a sub-class can be animatable does not mean it is
+        self.animationsPresent = object.animation_data and object.animation_data.action
+
+        if (self.animationsPresent):
+            Logger.log('animation processing begun', 2)
+            # instance each type of animation support regardless of whether there is any data for it
+            if supportsRotation:
+                if object.rotation_mode == 'QUATERNION':
+                    if object.type == 'CAMERA':
+                        # if it's a camera, convert quaternions to euler XYZ
+                        rotAnimation = QuaternionToEulerAnimation(object, 'rotation', 'rotation_quaternion', -1, xOffsetForRotation)
+                    else:
+                        rotAnimation = QuaternionAnimation(object, 'rotationQuaternion', 'rotation_quaternion', 1, xOffsetForRotation)
+                else:
+                    rotAnimation = VectorAnimation(object, 'rotation', 'rotation_euler', -1, xOffsetForRotation)
+
+            if supportsPosition:
+                posAnimation = VectorAnimation(object, 'position', 'location')
+
+            if supportsScaling:
+                scaleAnimation = VectorAnimation(object, 'scaling', 'scale')
+
+            self.ranges = []
+            frameOffset = 0
+
+            currentAction = object.animation_data.action
+            for action in bpy.data.actions:
+                # get the range / assigning the action to the object
+                animationRange = AnimationRange.actionPrep(object, action, False, frameOffset)
+                if animationRange is None:
+                    continue
+
+                if supportsRotation:
+                    hasData = rotAnimation.append_range(object, animationRange)
+
+                if supportsPosition:
+                    hasData |= posAnimation.append_range(object, animationRange)
+
+                if supportsScaling:
+                    hasData |= scaleAnimation.append_range(object, animationRange)
+
+                if hasData:
+                    Logger.log('processing action ' + animationRange.to_string(), 3)
+                    self.ranges.append(animationRange)
+                    frameOffset = animationRange.frame_end
+
+            object.animation_data.action = currentAction
+            #Set Animations
+            self.animations = []
+            if supportsRotation and len(rotAnimation.frames) > 0:
+                 self.animations.append(rotAnimation)
+
+            if supportsPosition and len(posAnimation.frames) > 0:
+                 self.animations.append(posAnimation)
+
+            if supportsScaling and len(scaleAnimation.frames) > 0:
+                 self.animations.append(scaleAnimation)
+
+            if (hasattr(object.data, "autoAnimate") and object.data.autoAnimate):
+                self.autoAnimate = True
+                self.autoAnimateFrom = bpy.context.scene.frame_end
+                self.autoAnimateTo =  0
+                for animation in self.animations:
+                    if self.autoAnimateFrom > animation.get_first_frame():
+                        self.autoAnimateFrom = animation.get_first_frame()
+                    if self.autoAnimateTo < animation.get_last_frame():
+                        self.autoAnimateTo = animation.get_last_frame()
+                self.autoAnimateLoop = True
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        if (self.animationsPresent):
+            file_handler.write('\n,"animations":[')
+            first = True
+            for animation in self.animations:
+                if first == False:
+                    file_handler.write(',')
+                animation.to_scene_file(file_handler)
+                first = False
+            file_handler.write(']')
+
+            file_handler.write(',"ranges":[')
+            first = True
+            for range in self.ranges:
+                if first != True:
+                    file_handler.write(',')
+                first = False
+
+                range.to_scene_file(file_handler)
+
+            file_handler.write(']')
+
+            if (hasattr(self, "autoAnimate") and self.autoAnimate):
+                write_bool(file_handler, 'autoAnimate', self.autoAnimate)
+                write_int(file_handler, 'autoAnimateFrom', self.autoAnimateFrom)
+                write_int(file_handler, 'autoAnimateTo', self.autoAnimateTo)
+                write_bool(file_handler, 'autoAnimateLoop', self.autoAnimateLoop)

+ 297 - 0
Exporters/Blender/src/json_exporter.py

@@ -0,0 +1,297 @@
+from .animation import *
+from .armature import *
+from .camera import *
+from .exporter_settings_panel import *
+from .light_shadow import *
+from .logger import *
+from .material import *
+from .mesh import *
+from .package_level import *
+from .sound import *
+from .world import *
+
+import bpy
+from io import open
+from os import path, makedirs
+#===============================================================================
+class JsonExporter:
+    nameSpace   = None  # assigned in execute
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def execute(self, context, filepath):
+        scene = context.scene
+        self.scene = scene # reference for passing
+        self.fatalError = None
+        try:
+            self.filepathMinusExtension = filepath.rpartition('.')[0]
+            JsonExporter.nameSpace = getNameSpace(self.filepathMinusExtension)
+
+            log = Logger(self.filepathMinusExtension + '.log')
+
+            if bpy.ops.object.mode_set.poll():
+                bpy.ops.object.mode_set(mode = 'OBJECT')
+
+            # assign texture location, purely temporary if inlining
+            self.textureDir = path.dirname(filepath)
+            if not scene.inlineTextures:
+                self.textureDir = path.join(self.textureDir, scene.textureDir)
+                if not path.isdir(self.textureDir):
+                    makedirs(self.textureDir)
+                    Logger.warn("Texture sub-directory did not already exist, created: " + self.textureDir)
+
+            Logger.log('========= Conversion from Blender to Babylon.js =========', 0)
+            Logger.log('Scene settings used:', 1)
+            Logger.log('selected layers only:  ' + format_bool(scene.export_onlySelectedLayer), 2)
+            Logger.log('flat shading entire scene:  ' + format_bool(scene.export_flatshadeScene), 2)
+            Logger.log('inline textures:  ' + format_bool(scene.inlineTextures), 2)
+            if not scene.inlineTextures:
+                Logger.log('texture directory:  ' + self.textureDir, 2)
+
+            self.world = World(scene)
+
+            bpy.ops.screen.animation_cancel()
+            currentFrame = bpy.context.scene.frame_current
+
+            # Active camera
+            if scene.camera != None:
+                self.activeCamera = scene.camera.name
+            else:
+                Logger.warn('No active camera has been assigned, or is not in a currently selected Blender layer')
+
+            self.cameras = []
+            self.lights = []
+            self.shadowGenerators = []
+            self.skeletons = []
+            skeletonId = 0
+            self.meshesAndNodes = []
+            self.materials = []
+            self.multiMaterials = []
+            self.sounds = []
+
+            # Scene level sound
+            if scene.attachedSound != '':
+                self.sounds.append(Sound(scene.attachedSound, scene.autoPlaySound, scene.loopSound))
+
+            # separate loop doing all skeletons, so available in Mesh to make skipping IK bones possible
+            for object in [object for object in scene.objects]:
+                scene.frame_set(currentFrame)
+                if object.type == 'ARMATURE':  #skeleton.pose.bones
+                    if object.is_visible(scene):
+                        self.skeletons.append(Skeleton(object, scene, skeletonId, scene.ignoreIKBones))
+                        skeletonId += 1
+                    else:
+                        Logger.warn('The following armature not visible in scene thus ignored: ' + object.name)
+
+            # exclude lamps in this pass, so ShadowGenerator constructor can be passed meshesAnNodes
+            for object in [object for object in scene.objects]:
+                scene.frame_set(currentFrame)
+                if object.type == 'CAMERA':
+                    if object.is_visible(scene): # no isInSelectedLayer() required, is_visible() handles this for them
+                        self.cameras.append(Camera(object))
+                    else:
+                        Logger.warn('The following camera not visible in scene thus ignored: ' + object.name)
+
+                elif object.type == 'MESH':
+                    forcedParent = None
+                    nameID = ''
+                    nextStartFace = 0
+
+                    while True and self.isInSelectedLayer(object, scene):
+                        mesh = Mesh(object, scene, nextStartFace, forcedParent, nameID, self)
+                        if mesh.hasUnappliedTransforms and hasattr(mesh, 'skeletonWeights'):
+                            self.fatalError = 'Mesh: ' + mesh.name + ' has unapplied transformations.  This will never work for a mesh with an armature.  Export cancelled'
+                            Logger.log(self.fatalError)
+                            return
+
+                        if hasattr(mesh, 'instances'):
+                            self.meshesAndNodes.append(mesh)
+                        else:
+                            break
+
+                        if object.data.attachedSound != '':
+                            self.sounds.append(Sound(object.data.attachedSound, object.data.autoPlaySound, object.data.loopSound, object))
+
+                        nextStartFace = mesh.offsetFace
+                        if nextStartFace == 0:
+                            break
+
+                        if forcedParent is None:
+                            nameID = 0
+                            forcedParent = object
+                            Logger.warn('The following mesh has exceeded the maximum # of vertex elements & will be broken into multiple Babylon meshes: ' + object.name)
+
+                        nameID = nameID + 1
+
+                elif object.type == 'EMPTY':
+                    self.meshesAndNodes.append(Node(object, scene.includeMeshFactory))
+
+                elif object.type != 'LAMP' and object.type != 'ARMATURE':
+                    Logger.warn('The following object (type - ' +  object.type + ') is not currently exportable thus ignored: ' + object.name)
+
+            # Lamp / shadow Generator pass; meshesAnNodes complete & forceParents included
+            for object in [object for object in scene.objects]:
+                if object.type == 'LAMP':
+                    if object.is_visible(scene): # no isInSelectedLayer() required, is_visible() handles this for them
+                        bulb = Light(object, self.meshesAndNodes)
+                        self.lights.append(bulb)
+                        if object.data.shadowMap != 'NONE':
+                            if bulb.light_type == DIRECTIONAL_LIGHT or bulb.light_type == SPOT_LIGHT:
+                                self.shadowGenerators.append(ShadowGenerator(object, self.meshesAndNodes, scene))
+                            else:
+                                Logger.warn('Only directional (sun) and spot types of lamp are valid for shadows thus ignored: ' + object.name)
+                    else:
+                        Logger.warn('The following lamp not visible in scene thus ignored: ' + object.name)
+
+            bpy.context.scene.frame_set(currentFrame)
+
+            # output file
+            self.to_scene_file()
+
+        except:# catch *all* exceptions
+            log.log_error_stack()
+            raise
+
+        finally:
+            log.close()
+
+        self.nWarnings = log.nWarnings
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self):
+        Logger.log('========= Writing of scene file started =========', 0)
+        # Open file
+        file_handler = open(self.filepathMinusExtension + '.babylon', 'w', encoding='utf8')
+        file_handler.write('{')
+        file_handler.write('"producer":{"name":"Blender","version":"' + bpy.app.version_string + '","exporter_version":"' + format_exporter_version() + '","file":"' + JsonExporter.nameSpace + '.babylon"},\n')
+        self.world.to_scene_file(file_handler)
+
+        # Materials
+        file_handler.write(',\n"materials":[')
+        first = True
+        for material in self.materials:
+            if first != True:
+                file_handler.write(',\n')
+
+            first = False
+            material.to_scene_file(file_handler)
+        file_handler.write(']')
+
+        # Multi-materials
+        file_handler.write(',\n"multiMaterials":[')
+        first = True
+        for multimaterial in self.multiMaterials:
+            if first != True:
+                file_handler.write(',')
+
+            first = False
+            multimaterial.to_scene_file(file_handler)
+        file_handler.write(']')
+
+        # Armatures/Bones
+        file_handler.write(',\n"skeletons":[')
+        first = True
+        for skeleton in self.skeletons:
+            if first != True:
+                file_handler.write(',')
+
+            first = False
+            skeleton.to_scene_file(file_handler)
+        file_handler.write(']')
+
+        # Meshes
+        file_handler.write(',\n"meshes":[')
+        first = True
+        for mesh in self.meshesAndNodes:
+            if first != True:
+                file_handler.write(',')
+
+            first = False
+            mesh.to_scene_file(file_handler)
+        file_handler.write(']')
+
+        # Cameras
+        file_handler.write(',\n"cameras":[')
+        first = True
+        for camera in self.cameras:
+            if hasattr(camera, 'fatalProblem'): continue
+            if first != True:
+                file_handler.write(',')
+
+            first = False
+            camera.update_for_target_attributes(self.meshesAndNodes)
+            camera.to_scene_file(file_handler)
+        file_handler.write(']')
+
+        # Active camera
+        if hasattr(self, 'activeCamera'):
+            write_string(file_handler, 'activeCamera', self.activeCamera)
+
+        # Lights
+        file_handler.write(',\n"lights":[')
+        first = True
+        for light in self.lights:
+            if first != True:
+                file_handler.write(',')
+
+            first = False
+            light.to_scene_file(file_handler)
+        file_handler.write(']')
+
+        # Shadow generators
+        file_handler.write(',\n"shadowGenerators":[')
+        first = True
+        for shadowGen in self.shadowGenerators:
+            if first != True:
+                file_handler.write(',')
+
+            first = False
+            shadowGen.to_scene_file(file_handler)
+        file_handler.write(']')
+
+        # Sounds
+        if len(self.sounds) > 0:
+            file_handler.write('\n,"sounds":[')
+            first = True
+            for sound in self.sounds:
+                if first != True:
+                    file_handler.write(',')
+
+                first = False
+                sound.to_scene_file(file_handler)
+
+            file_handler.write(']')
+
+        # Closing
+        file_handler.write('\n}')
+        file_handler.close()
+        Logger.log('========= Writing of scene file completed =========', 0)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def getMaterial(self, baseMaterialId):
+        fullName = JsonExporter.nameSpace + '.' + baseMaterialId
+        for material in self.materials:
+            if material.name == fullName:
+                return material
+
+        return None
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def getSourceMeshInstance(self, dataName):
+        for mesh in self.meshesAndNodes:
+            # nodes have no 'dataName', cannot be instanced in any case
+            if hasattr(mesh, 'dataName') and mesh.dataName == dataName:
+                return mesh
+
+        return None
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def isInSelectedLayer(self, obj, scene):
+        if not scene.export_onlySelectedLayer:
+            return True
+
+        for l in range(0, len(scene.layers)):
+            if obj.layers[l] and scene.layers[l]:
+                return True
+        return False
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def get_skeleton(self, name):
+        for skeleton in self.skeletons:
+            if skeleton.name == name:
+                return skeleton
+        #really cannot happen, will cause exception in caller
+        return None

+ 222 - 0
Exporters/Blender/src/light_shadow.py

@@ -0,0 +1,222 @@
+from .logger import *
+from .package_level import *
+
+from .f_curve_animatable import *
+
+import bpy
+from mathutils import Color, Vector
+
+# used in Light constructor, never formally defined in Babylon, but used in babylonFileLoader
+POINT_LIGHT = 0
+DIRECTIONAL_LIGHT = 1
+SPOT_LIGHT = 2
+HEMI_LIGHT = 3
+
+#used in ShadowGenerators
+NO_SHADOWS = 'NONE'
+STD_SHADOWS = 'STD'
+POISSON_SHADOWS = 'POISSON'
+VARIANCE_SHADOWS = 'VARIANCE'
+BLUR_VARIANCE_SHADOWS = 'BLUR_VARIANCE'
+#===============================================================================
+class Light(FCurveAnimatable):
+    def __init__(self, light, meshesAndNodes):
+        if light.parent and light.parent.type != 'ARMATURE':
+            self.parentId = light.parent.name
+
+        self.name = light.name
+        Logger.log('processing begun of light (' + light.data.type + '):  ' + self.name)
+        self.define_animations(light, False, True, False)
+
+        light_type_items = {'POINT': POINT_LIGHT, 'SUN': DIRECTIONAL_LIGHT, 'SPOT': SPOT_LIGHT, 'HEMI': HEMI_LIGHT, 'AREA': POINT_LIGHT}
+        self.light_type = light_type_items[light.data.type]
+
+        if self.light_type == POINT_LIGHT:
+            self.position = light.location
+            if hasattr(light.data, 'use_sphere'):
+                if light.data.use_sphere:
+                    self.range = light.data.distance
+
+        elif self.light_type == DIRECTIONAL_LIGHT:
+            self.position = light.location
+            self.direction = Light.get_direction(light.matrix_local)
+
+        elif self.light_type == SPOT_LIGHT:
+            self.position = light.location
+            self.direction = Light.get_direction(light.matrix_local)
+            self.angle = light.data.spot_size
+            self.exponent = light.data.spot_blend * 2
+            if light.data.use_sphere:
+                self.range = light.data.distance
+
+        else:
+            # Hemi
+            matrix_local = light.matrix_local.copy()
+            matrix_local.translation = Vector((0, 0, 0))
+            self.direction = (Vector((0, 0, -1)) * matrix_local)
+            self.direction = scale_vector(self.direction, -1)
+            self.groundColor = Color((0, 0, 0))
+
+        self.intensity = light.data.energy
+        self.diffuse   = light.data.color if light.data.use_diffuse  else Color((0, 0, 0))
+        self.specular  = light.data.color if light.data.use_specular else Color((0, 0, 0))
+
+        # inclusion section
+        if light.data.use_own_layer:
+            lampLayer = getLayer(light)
+            self.includedOnlyMeshesIds = []
+            for mesh in meshesAndNodes:
+                if mesh.layer == lampLayer:
+                    self.includedOnlyMeshesIds.append(mesh.name)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    @staticmethod
+    def get_direction(matrix):
+        return (matrix.to_3x3() * Vector((0.0, 0.0, -1.0))).normalized()
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_string(file_handler, 'name', self.name, True)
+        write_string(file_handler, 'id', self.name)
+        write_float(file_handler, 'type', self.light_type)
+
+        if hasattr(self, 'parentId'   ): write_string(file_handler, 'parentId'   , self.parentId   )
+        if hasattr(self, 'position'   ): write_vector(file_handler, 'position'   , self.position   )
+        if hasattr(self, 'direction'  ): write_vector(file_handler, 'direction'  , self.direction  )
+        if hasattr(self, 'angle'      ): write_float (file_handler, 'angle'      , self.angle      )
+        if hasattr(self, 'exponent'   ): write_float (file_handler, 'exponent'   , self.exponent   )
+        if hasattr(self, 'groundColor'): write_color (file_handler, 'groundColor', self.groundColor)
+        if hasattr(self, 'range'      ): write_float (file_handler, 'range'      , self.range      )
+
+        write_float(file_handler, 'intensity', self.intensity)
+        write_color(file_handler, 'diffuse', self.diffuse)
+        write_color(file_handler, 'specular', self.specular)
+
+        if hasattr(self, 'includedOnlyMeshesIds'):
+            file_handler.write(',"includedOnlyMeshesIds":[')
+            first = True
+            for meshId in self.includedOnlyMeshesIds:
+                if first != True:
+                    file_handler.write(',')
+                first = False
+
+                file_handler.write('"' + meshId + '"')
+
+            file_handler.write(']')
+
+
+        super().to_scene_file(file_handler) # Animations
+        file_handler.write('}')
+#===============================================================================
+class ShadowGenerator:
+    def __init__(self, lamp, meshesAndNodes, scene):
+        Logger.log('processing begun of shadows for light:  ' + lamp.name)
+        self.lightId = lamp.name
+        self.mapSize = lamp.data.shadowMapSize
+        self.shadowBias = lamp.data.shadowBias
+
+        if lamp.data.shadowMap == VARIANCE_SHADOWS:
+            self.useVarianceShadowMap = True
+        elif lamp.data.shadowMap == POISSON_SHADOWS:
+            self.usePoissonSampling = True
+        elif lamp.data.shadowMap == BLUR_VARIANCE_SHADOWS:
+            self.useBlurVarianceShadowMap = True
+            self.shadowBlurScale = lamp.data.shadowBlurScale
+            self.shadowBlurBoxOffset = lamp.data.shadowBlurBoxOffset
+
+        # .babylon specific section
+        self.shadowCasters = []
+        for mesh in meshesAndNodes:
+            if (mesh.castShadows):
+                self.shadowCasters.append(mesh.name)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_int(file_handler, 'mapSize', self.mapSize, True)
+        write_string(file_handler, 'lightId', self.lightId)
+        write_float(file_handler, 'bias', self.shadowBias)
+
+        if hasattr(self, 'useVarianceShadowMap') :
+            write_bool(file_handler, 'useVarianceShadowMap', self.useVarianceShadowMap)
+        elif hasattr(self, 'usePoissonSampling'):
+            write_bool(file_handler, 'usePoissonSampling', self.usePoissonSampling)
+        elif hasattr(self, 'useBlurVarianceShadowMap'):
+            write_bool(file_handler, 'useBlurVarianceShadowMap', self.useBlurVarianceShadowMap)
+            write_int(file_handler, 'blurScale', self.shadowBlurScale)
+            write_int(file_handler, 'blurBoxOffset', self.shadowBlurBoxOffset)
+
+        file_handler.write(',"renderList":[')
+        first = True
+        for caster in self.shadowCasters:
+            if first != True:
+                file_handler.write(',')
+            first = False
+
+            file_handler.write('"' + caster + '"')
+
+        file_handler.write(']')
+        file_handler.write('}')
+#===============================================================================
+bpy.types.Lamp.autoAnimate = bpy.props.BoolProperty(
+    name='Auto launch animations',
+    description='',
+    default = False
+)
+bpy.types.Lamp.shadowMap = bpy.props.EnumProperty(
+    name='Shadow Map',
+    description='',
+    items = ((NO_SHADOWS           , 'None'         , 'No Shadow Maps'),
+             (STD_SHADOWS          , 'Standard'     , 'Use Standard Shadow Maps'),
+             (POISSON_SHADOWS      , 'Poisson'      , 'Use Poisson Sampling'),
+             (VARIANCE_SHADOWS     , 'Variance'     , 'Use Variance Shadow Maps'),
+             (BLUR_VARIANCE_SHADOWS, 'Blur Variance', 'Use Blur Variance Shadow Maps')
+            ),
+    default = NO_SHADOWS
+)
+
+bpy.types.Lamp.shadowMapSize = bpy.props.IntProperty(
+    name='Shadow Map Size',
+    description='',
+    default = 512
+)
+bpy.types.Lamp.shadowBias = bpy.props.FloatProperty(
+    name='Shadow Bias',
+    description='',
+    default = 0.00005
+)
+
+bpy.types.Lamp.shadowBlurScale = bpy.props.IntProperty(
+    name='Blur Scale',
+    description='',
+    default = 2
+)
+
+bpy.types.Lamp.shadowBlurBoxOffset = bpy.props.IntProperty(
+    name='Blur Box Offset',
+    description='',
+    default = 0
+)
+#===============================================================================
+class LightPanel(bpy.types.Panel):
+    bl_label = get_title()
+    bl_space_type = 'PROPERTIES'
+    bl_region_type = 'WINDOW'
+    bl_context = 'data'
+
+    @classmethod
+    def poll(cls, context):
+        ob = context.object
+        return ob is not None and isinstance(ob.data, bpy.types.Lamp)
+
+    def draw(self, context):
+        ob = context.object
+        layout = self.layout
+        layout.prop(ob.data, 'shadowMap')
+        layout.prop(ob.data, 'shadowMapSize')
+        layout.prop(ob.data, 'shadowBias')
+
+        box = layout.box()
+        box.label(text="Blur Variance Shadows")
+        box.prop(ob.data, 'shadowBlurScale')
+        box.prop(ob.data, 'shadowBlurBoxOffset')
+
+        layout.prop(ob.data, 'autoAnimate')

+ 56 - 0
Exporters/Blender/src/logger.py

@@ -0,0 +1,56 @@
+from .package_level import format_f, format_exporter_version
+
+from bpy import app
+from io import open
+from math import floor
+from time import time
+from sys import exc_info
+from traceback import format_tb
+
+class Logger:
+    instance = None
+
+    def __init__(self, filename):
+        self.start_time = time()
+        self.nWarnings = 0
+
+        self.log_handler = open(filename, 'w', encoding='utf8')
+        self.log_handler.write('Exporter version: ' + format_exporter_version() + ', Blender version: ' + app.version_string + '\n')
+
+        # allow the static methods to log, so instance does not need to be passed everywhere
+        Logger.instance = self
+
+    def log_error_stack(self):
+        ex = exc_info()
+        Logger.log('========= An error was encountered =========', 0)
+        stack = format_tb(ex[2])
+        for line in stack:
+           self.log_handler.write(line) # avoid tabs & extra newlines by not calling log() inside catch
+
+        self.log_handler.write('ERROR:  ' + str(ex[1]) + '\n')
+
+    def close(self):
+        Logger.log('========= end of processing =========', 0)
+        elapsed_time = time() - self.start_time
+        minutes = floor(elapsed_time / 60)
+        seconds = elapsed_time - (minutes * 60)
+        Logger.log('elapsed time:  ' + str(minutes) + ' min, ' + format_f(seconds) + ' secs', 0)
+
+        self.log_handler.close()
+        Logger.instance = None
+
+    @staticmethod
+    def warn(msg, numTabIndent = 1, noNewLine = False):
+        Logger.log('WARNING: ' + msg, numTabIndent, noNewLine)
+        Logger.instance.nWarnings += 1
+
+    @staticmethod
+    def log(msg, numTabIndent = 1, noNewLine = False):
+        # allow code that calls Logger run successfully when not logging
+        if Logger.instance is None: return
+
+        for i in range(numTabIndent):
+            Logger.instance.log_handler.write('\t')
+
+        Logger.instance.log_handler.write(msg)
+        if not noNewLine: Logger.instance.log_handler.write('\n')

+ 578 - 0
Exporters/Blender/src/material.py

@@ -0,0 +1,578 @@
+from .logger import *
+from .package_level import *
+
+import bpy
+from base64 import b64encode
+from mathutils import Color
+from os import path, remove
+from shutil import copy
+from sys import exc_info # for writing errors to log file
+
+# used in Texture constructor, defined in BABYLON.Texture
+CLAMP_ADDRESSMODE = 0
+WRAP_ADDRESSMODE = 1
+MIRROR_ADDRESSMODE = 2
+
+# used in Texture constructor, defined in BABYLON.Texture
+EXPLICIT_MODE = 0
+SPHERICAL_MODE = 1
+#PLANAR_MODE = 2
+CUBIC_MODE = 3
+#PROJECTION_MODE = 4
+#SKYBOX_MODE = 5
+
+DEFAULT_MATERIAL_NAMESPACE = 'Same as Filename'
+#===============================================================================
+class MultiMaterial:
+    def __init__(self, material_slots, idx, nameSpace):
+        self.name = nameSpace + '.' + 'Multimaterial#' + str(idx)
+        Logger.log('processing begun of multimaterial:  ' + self.name, 2)
+        self.material_slots = material_slots
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_string(file_handler, 'name', self.name, True)
+        write_string(file_handler, 'id', self.name)
+
+        file_handler.write(',"materials":[')
+        first = True
+        for material in self.material_slots:
+            if first != True:
+                file_handler.write(',')
+            file_handler.write('"' + material.name +'"')
+            first = False
+        file_handler.write(']')
+        file_handler.write('}')
+#===============================================================================
+class Texture:
+    def __init__(self, slot, level, textureOrImage, mesh, exporter):
+        wasBaked = not hasattr(textureOrImage, 'uv_layer')
+        if wasBaked:
+            image = textureOrImage
+            texture = None
+
+            repeat = False
+            self.hasAlpha = False
+            self.coordinatesIndex = 0
+        else:
+            texture = textureOrImage
+            image = texture.texture.image
+
+            repeat = texture.texture.extension == 'REPEAT'
+            self.hasAlpha = texture.texture.use_alpha
+
+            usingMap = texture.uv_layer
+            if len(usingMap) == 0:
+                usingMap = mesh.data.uv_textures[0].name
+
+            Logger.log('Image texture found, type:  ' + slot + ', mapped using: "' + usingMap + '"', 4)
+            if mesh.data.uv_textures[0].name == usingMap:
+                self.coordinatesIndex = 0
+            elif mesh.data.uv_textures[1].name == usingMap:
+                self.coordinatesIndex = 1
+            else:
+                Logger.warn('Texture is not mapped as UV or UV2, assigned 1', 5)
+                self.coordinatesIndex = 0
+
+        # always write the file out, since base64 encoding is easiest from a file
+        try:
+            imageFilepath = path.normpath(bpy.path.abspath(image.filepath))
+            basename = path.basename(imageFilepath)
+
+            internalImage = image.packed_file or wasBaked
+
+            # when coming from either a packed image or a baked image, then save_render
+            if internalImage:
+                if exporter.scene.inlineTextures:
+                    textureFile = path.join(exporter.textureDir, basename + "temp")
+                else:
+                    textureFile = path.join(exporter.textureDir, basename)
+
+                image.save_render(textureFile)
+
+            # when backed by an actual file, copy to target dir, unless inlining
+            else:
+                textureFile = bpy.path.abspath(image.filepath)
+                if not exporter.scene.inlineTextures:
+                    copy(textureFile, exporter.textureDir)
+        except:
+            ex = exc_info()
+            Logger.warn('Error encountered processing image file:  ' + ', Error:  '+ str(ex[1]))
+
+        if exporter.scene.inlineTextures:
+            # base64 is easiest from a file, so sometimes a temp file was made above;  need to delete those
+            with open(textureFile, "rb") as image_file:
+                asString = b64encode(image_file.read()).decode()
+            self.encoded_URI = 'data:image/' + image.file_format + ';base64,' + asString
+
+            if internalImage:
+                remove(textureFile)
+
+        # capture texture attributes
+        self.slot = slot
+        self.name = basename
+        self.level = level
+
+        if (texture and texture.mapping == 'CUBE'):
+            self.coordinatesMode = CUBIC_MODE
+        if (texture and texture.mapping == 'SPHERE'):
+            self.coordinatesMode = SPHERICAL_MODE
+        else:
+            self.coordinatesMode = EXPLICIT_MODE
+
+        self.uOffset = texture.offset.x if texture else 0.0
+        self.vOffset = texture.offset.y if texture else 0.0
+        self.uScale  = texture.scale.x  if texture else 1.0
+        self.vScale  = texture.scale.y  if texture else 1.0
+        self.uAng = 0
+        self.vAng = 0
+        self.wAng = 0
+
+        if (repeat):
+            if (texture.texture.use_mirror_x):
+                self.wrapU = MIRROR_ADDRESSMODE
+            else:
+                self.wrapU = WRAP_ADDRESSMODE
+
+            if (texture.texture.use_mirror_y):
+                self.wrapV = MIRROR_ADDRESSMODE
+            else:
+                self.wrapV = WRAP_ADDRESSMODE
+        else:
+            self.wrapU = CLAMP_ADDRESSMODE
+            self.wrapV = CLAMP_ADDRESSMODE
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write(', \n"' + self.slot + '":{')
+        write_string(file_handler, 'name', self.name, True)
+        write_float(file_handler, 'level', self.level)
+        write_float(file_handler, 'hasAlpha', self.hasAlpha)
+        write_int(file_handler, 'coordinatesMode', self.coordinatesMode)
+        write_float(file_handler, 'uOffset', self.uOffset)
+        write_float(file_handler, 'vOffset', self.vOffset)
+        write_float(file_handler, 'uScale', self.uScale)
+        write_float(file_handler, 'vScale', self.vScale)
+        write_float(file_handler, 'uAng', self.uAng)
+        write_float(file_handler, 'vAng', self.vAng)
+        write_float(file_handler, 'wAng', self.wAng)
+        write_int(file_handler, 'wrapU', self.wrapU)
+        write_int(file_handler, 'wrapV', self.wrapV)
+        write_int(file_handler, 'coordinatesIndex', self.coordinatesIndex)
+        if hasattr(self,'encoded_URI'):
+            write_string(file_handler, 'base64String', self.encoded_URI)
+        file_handler.write('}')
+#===============================================================================
+# need to evaluate the need to bake a mesh before even starting; class also stores specific types of bakes
+class BakingRecipe:
+    def __init__(self, mesh):
+        # transfer from Mesh custom properties
+        self.bakeSize    = mesh.data.bakeSize
+        self.bakeQuality = mesh.data.bakeQuality # for lossy compression formats
+        self.forceBaking = mesh.data.forceBaking # in mesh, but not currently exposed
+        self.usePNG      = mesh.data.usePNG      # in mesh, but not currently exposed
+        
+        # initialize all members
+        self.needsBaking      = self.forceBaking
+        self.diffuseBaking    = self.forceBaking
+        self.ambientBaking    = False
+        self.opacityBaking    = False
+        self.reflectionBaking = False
+        self.emissiveBaking   = False
+        self.bumpBaking       = False
+        self.specularBaking   = False
+
+        # need to make sure a single render
+        self.cyclesRender     = False
+        blenderRender         = False
+
+        # accumulators set by Blender Game
+        self.backFaceCulling = True  # used only when baking
+        self.isBillboard = len(mesh.material_slots) == 1 and mesh.material_slots[0].material.game_settings.face_orientation == 'BILLBOARD'
+
+        # Cycles specific, need to get the node trees of each material
+        self.nodeTrees = []
+
+        for material_slot in mesh.material_slots:
+            # a material slot is not a reference to an actual material; need to look up
+            material = material_slot.material
+
+            self.backFaceCulling &= material.game_settings.use_backface_culling
+
+            # testing for Cycles renderer has to be different
+            if material.use_nodes == True:
+                self.needsBaking = True
+                self.cyclesRender = True
+                self.nodeTrees.append(material.node_tree)
+
+                for node in material.node_tree.nodes:
+                    id = node.bl_idname
+                    if id == 'ShaderNodeBsdfDiffuse':
+                        self.diffuseBaking = True
+
+                    if id == 'ShaderNodeAmbientOcclusion':
+                        self.ambientBaking = True
+
+                    # there is no opacity baking for Cycles AFAIK
+                    if id == '':
+                        self.opacityBaking = True
+
+                    if id == 'ShaderNodeEmission':
+                        self.emissiveBaking = True
+
+                    if id == 'ShaderNodeNormal' or id == 'ShaderNodeNormalMap':
+                        self.bumpBaking = True
+
+                    if id == '':
+                        self.specularBaking = True
+
+            else:
+                blenderRender = True
+                nDiffuseImages = 0
+                nReflectionImages = 0
+                nAmbientImages = 0
+                nOpacityImages = 0
+                nEmissiveImages = 0
+                nBumpImages = 0
+                nSpecularImages = 0
+
+                textures = [mtex for mtex in material.texture_slots if mtex and mtex.texture]
+                for mtex in textures:
+                    # ignore empty slots
+                    if mtex.texture.type == 'NONE':
+                        continue
+
+                    # for images, just need to make sure there is only 1 per type
+                    if mtex.texture.type == 'IMAGE' and not self.forceBaking:
+                        if mtex.use_map_diffuse or mtex.use_map_color_diffuse:
+                            if mtex.texture_coords == 'REFLECTION':
+                                nReflectionImages += 1
+                            else:
+                                nDiffuseImages += 1
+
+                        if mtex.use_map_ambient:
+                            nAmbientImages += 1
+
+                        if mtex.use_map_alpha:
+                            nOpacityImages += 1
+
+                        if mtex.use_map_emit:
+                            nEmissiveImages += 1
+
+                        if mtex.use_map_normal:
+                            nBumpImages += 1
+
+                        if mtex.use_map_color_spec:
+                            nSpecularImages += 1
+
+                    else:
+                        self.needsBaking = True
+
+                        if mtex.use_map_diffuse or mtex.use_map_color_diffuse:
+                            if mtex.texture_coords == 'REFLECTION':
+                                self.reflectionBaking = True
+                            else:
+                                self.diffuseBaking = True
+
+                        if mtex.use_map_ambient:
+                            self.ambientBaking = True
+
+                        if mtex.use_map_alpha and material.alpha > 0:
+                            self.opacityBaking = True
+
+                        if mtex.use_map_emit:
+                            self.emissiveBaking = True
+
+                        if mtex.use_map_normal:
+                            self.bumpBaking = True
+
+                        if mtex.use_map_color_spec:
+                            self.specularBaking = True
+
+                # 2nd pass 2 check for multiples of a given image type
+                if nDiffuseImages > 1:
+                    self.needsBaking = self.diffuseBaking = True
+                if nReflectionImages > 1:
+                    self.needsBaking = self.nReflectionImages = True
+                if nAmbientImages > 1:
+                    self.needsBaking = self.ambientBaking = True
+                if nOpacityImages > 1:
+                    self.needsBaking = self.opacityBaking = True
+                if nEmissiveImages > 1:
+                    self.needsBaking = self.emissiveBaking = True
+                if nBumpImages > 1:
+                    self.needsBaking = self.bumpBaking = True
+                if nSpecularImages > 1:
+                    self.needsBaking = self.specularBaking = True
+
+        self.multipleRenders = blenderRender and self.cyclesRender
+
+        # check for really old .blend file, eg. 2.49, to ensure that everything requires exists
+        if self.needsBaking and bpy.data.screens.find('UV Editing') == -1:
+            Logger.warn('Contains material requiring baking, but resources not available.  Probably .blend very old', 2)
+            self.needsBaking = False
+#===============================================================================
+# Not intended to be instanced directly
+class Material:
+    def __init__(self, checkReadyOnlyOnce, maxSimultaneousLights):
+        self.checkReadyOnlyOnce = checkReadyOnlyOnce
+        self.maxSimultaneousLights = maxSimultaneousLights
+        # first pass of textures, either appending image type or recording types of bakes to do
+        self.textures = []
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_string(file_handler, 'name', self.name, True)
+        write_string(file_handler, 'id', self.name)
+        write_color(file_handler, 'ambient', self.ambient)
+        write_color(file_handler, 'diffuse', self.diffuse)
+        write_color(file_handler, 'specular', self.specular)
+        write_color(file_handler, 'emissive', self.emissive)
+        write_float(file_handler, 'specularPower', self.specularPower)
+        write_float(file_handler, 'alpha', self.alpha)
+        write_bool(file_handler, 'backFaceCulling', self.backFaceCulling)
+        write_bool(file_handler, 'checkReadyOnlyOnce', self.checkReadyOnlyOnce)
+        write_int(file_handler, 'maxSimultaneousLights', self.maxSimultaneousLights)
+        for texSlot in self.textures:
+            texSlot.to_scene_file(file_handler)
+
+        file_handler.write('}')
+#===============================================================================
+class StdMaterial(Material):
+    def __init__(self, material_slot, exporter, mesh):
+        super().__init__(mesh.data.checkReadyOnlyOnce, mesh.data.maxSimultaneousLights)
+        nameSpace = exporter.nameSpace if mesh.data.materialNameSpace == DEFAULT_MATERIAL_NAMESPACE or len(mesh.data.materialNameSpace) == 0 else mesh.data.materialNameSpace
+        self.name = nameSpace + '.' + material_slot.name
+
+        Logger.log('processing begun of Standard material:  ' +  material_slot.name, 2)
+
+        # a material slot is not a reference to an actual material; need to look up
+        material = material_slot.material
+
+        self.ambient = material.ambient * material.diffuse_color
+        self.diffuse = material.diffuse_intensity * material.diffuse_color
+        self.specular = material.specular_intensity * material.specular_color
+        self.emissive = material.emit * material.diffuse_color
+        self.specularPower = material.specular_hardness
+        self.alpha = material.alpha
+
+        self.backFaceCulling = material.game_settings.use_backface_culling
+
+        textures = [mtex for mtex in material.texture_slots if mtex and mtex.texture]
+        for mtex in textures:
+            # test should be un-neccessary, since should be a BakedMaterial; just for completeness
+            if (mtex.texture.type != 'IMAGE'):
+                continue
+            elif not mtex.texture.image:
+                Logger.warn('Material has un-assigned image texture:  "' + mtex.name + '" ignored', 3)
+                continue
+            elif len(mesh.data.uv_textures) == 0:
+                Logger.warn('Mesh has no UV maps, material:  "' + mtex.name + '" ignored', 3)
+                continue
+
+            if mtex.use_map_diffuse or mtex.use_map_color_diffuse:
+                if mtex.texture_coords == 'REFLECTION':
+                    Logger.log('Reflection texture found "' + mtex.name + '"', 3)
+                    self.textures.append(Texture('reflectionTexture', mtex.diffuse_color_factor, mtex, mesh, exporter))
+                else:
+                    Logger.log('Diffuse texture found "' + mtex.name + '"', 3)
+                    self.textures.append(Texture('diffuseTexture', mtex.diffuse_color_factor, mtex, mesh, exporter))
+
+            if mtex.use_map_ambient:
+                Logger.log('Ambient texture found "' + mtex.name + '"', 3)
+                self.textures.append(Texture('ambientTexture', mtex.ambient_factor, mtex, mesh, exporter))
+
+            if mtex.use_map_alpha:
+                if self.alpha > 0:
+                    Logger.log('Opacity texture found "' + mtex.name + '"', 3)
+                    self.textures.append(Texture('opacityTexture', mtex.alpha_factor, mtex, mesh, exporter))
+                else:
+                    Logger.warn('Opacity non-std way to indicate opacity, use material alpha to also use Opacity texture', 4)
+                    self.alpha = 1
+
+            if mtex.use_map_emit:
+                Logger.log('Emissive texture found "' + mtex.name + '"', 3)
+                self.textures.append(Texture('emissiveTexture', mtex.emit_factor, mtex, mesh, exporter))
+
+            if mtex.use_map_normal:
+                Logger.log('Bump texture found "' + mtex.name + '"', 3)
+                self.textures.append(Texture('bumpTexture', 1.0 / mtex.normal_factor, mtex, mesh, exporter))
+
+            if mtex.use_map_color_spec:
+                Logger.log('Specular texture found "' + mtex.name + '"', 3)
+                self.textures.append(Texture('specularTexture', mtex.specular_color_factor, mtex, mesh, exporter))
+#===============================================================================
+class BakedMaterial(Material):
+    def __init__(self, exporter, mesh, recipe):
+        super().__init__(mesh.data.checkReadyOnlyOnce, mesh.data.maxSimultaneousLights)
+        nameSpace = exporter.nameSpace if mesh.data.materialNameSpace == DEFAULT_MATERIAL_NAMESPACE or len(mesh.data.materialNameSpace) == 0 else mesh.data.materialNameSpace
+        self.name = nameSpace + '.' + mesh.name
+        Logger.log('processing begun of baked material:  ' +  mesh.name, 2)
+
+        # changes to cycles & smart_project occurred in 2.77; need to know what we are running
+        bVersion = blenderMajorMinorVersion()
+        
+        # any baking already took in the values. Do not want to apply them again, but want shadows to show.
+        # These are the default values from StandardMaterials
+        self.ambient = Color((0, 0, 0))
+        self.diffuse = Color((0.8, 0.8, 0.8)) # needed for shadows, but not change anything else
+        self.specular = Color((1, 1, 1))
+        self.emissive = Color((0, 0, 0))
+        self.specularPower = 64
+        self.alpha = 1.0
+
+        self.backFaceCulling = recipe.backFaceCulling
+
+        # texture is baked from selected mesh(es), need to insure this mesh is only one selected
+        bpy.ops.object.select_all(action='DESELECT')
+        mesh.select = True
+
+        # store setting to restore
+        engine = exporter.scene.render.engine
+
+        # mode_set's only work when there is an active object
+        exporter.scene.objects.active = mesh
+
+         # UV unwrap operates on mesh in only edit mode, procedurals can also give error of 'no images to be found' when not done
+         # select all verticies of mesh, since smart_project works only with selected verticies
+        bpy.ops.object.mode_set(mode='EDIT')
+        bpy.ops.mesh.select_all(action='SELECT')
+
+        # you need UV on a mesh in order to bake image.  This is not reqd for procedural textures, so may not exist
+        # need to look if it might already be created, if so use the first one
+        uv = mesh.data.uv_textures[0] if len(mesh.data.uv_textures) > 0 else None
+
+        if uv == None or recipe.forceBaking:
+            mesh.data.uv_textures.new('BakingUV')
+            uv = mesh.data.uv_textures['BakingUV']
+            uv.active = True
+            uv.active_render = not recipe.forceBaking # want the other uv's for the source when combining
+
+            if bVersion <= 2.76:
+                bpy.ops.uv.smart_project(angle_limit = 66.0, island_margin = 0.0, user_area_weight = 1.0, use_aspect = True)
+            else:
+                bpy.ops.uv.smart_project(angle_limit = 66.0, island_margin = 0.0, user_area_weight = 1.0, use_aspect = True, stretch_to_bounds = True)
+            
+            # syntax for using unwrap enstead of smar project    
+#            bpy.ops.uv.unwrap(margin = 1.0) # defaulting on all 
+            uvName = 'BakingUV'  # issues with cycles when not done this way
+        else:
+            uvName = uv.name
+
+        format = 'PNG' if recipe.usePNG else 'JPEG'
+        
+        # create a temporary image & link it to the UV/Image Editor so bake_image works
+        bpy.data.images.new(name = mesh.name + '_BJS_BAKE', width = recipe.bakeSize, height = recipe.bakeSize, alpha = False, float_buffer = False)
+        image = bpy.data.images[mesh.name + '_BJS_BAKE']
+        image.file_format = format
+        image.mapping = 'UV' # default value
+
+        image_settings = exporter.scene.render.image_settings
+        image_settings.file_format = format
+        image_settings.color_mode = 'RGBA' if recipe.usePNG else 'RGB'
+        image_settings.quality = recipe.bakeQuality # for lossy compression formats
+        image_settings.compression = recipe.bakeQuality  # Amount of time to determine best compression: 0 = no compression with fast file output, 100 = maximum lossless compression with slow file output
+
+        # now go thru all the textures that need to be baked
+        if recipe.diffuseBaking:
+            cycles_type = 'DIFFUSE_COLOR' if bVersion <= 2.76 else 'DIFFUSE'
+            self.bake('diffuseTexture', cycles_type, 'TEXTURE', image, mesh, uvName, exporter, recipe)
+
+        if recipe.ambientBaking:
+            self.bake('ambientTexture', 'AO', 'AO', image, mesh, uvName, exporter, recipe)
+
+        if recipe.opacityBaking:  # no eqivalent found for cycles
+            self.bake('opacityTexture', None, 'ALPHA', image, mesh, uvName, exporter, recipe)
+
+        if recipe.reflectionBaking:  # no eqivalent found for cycles
+            self.bake('reflectionTexture', None, 'MIRROR_COLOR', image, mesh, uvName, exporter, recipe)
+
+        if recipe.emissiveBaking:
+            self.bake('emissiveTexture', 'EMIT', 'EMIT', image, mesh, uvName, exporter, recipe)
+
+        if recipe.bumpBaking:
+            self.bake('bumpTexture', 'NORMAL', 'NORMALS', image, mesh, uvName, exporter, recipe)
+
+        if recipe.specularBaking:
+            cycles_type = 'SPECULAR' if bVersion <= 2.76 else 'GLOSSY'
+            self.bake('specularTexture', cycles_type, 'SPEC_COLOR', image, mesh, uvName, exporter, recipe)
+
+        # Toggle vertex selection & mode, if setting changed their value
+        bpy.ops.mesh.select_all(action='TOGGLE')  # still in edit mode toggle select back to previous
+        bpy.ops.object.mode_set(toggle=True)      # change back to Object
+
+        bpy.ops.object.select_all(action='TOGGLE') # change scene selection back, not seeming to work
+
+        exporter.scene.render.engine = engine
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def bake(self, bjs_type, cycles_type, internal_type, image, mesh, uvName, exporter, recipe):
+        extension = '.png' if recipe.usePNG else '.jpg'
+
+        if recipe.cyclesRender:
+            if cycles_type is None:
+                return
+            self.bakeCycles(cycles_type, image, uvName, recipe.nodeTrees, extension)
+        else:
+            self.bakeInternal(internal_type, image, uvName, extension)
+
+        self.textures.append(Texture(bjs_type, 1.0, image, mesh, exporter))
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def bakeInternal(self, bake_type, image, uvName, extension):
+        Logger.log('Internal baking texture, type: ' + bake_type + ', mapped using: ' + uvName, 3)
+        # need to use the legal name, since this will become the file name, chars like ':' not legal
+        legalName = legal_js_identifier(self.name)
+        image.filepath = legalName + '_' + bake_type + extension
+
+        scene = bpy.context.scene
+        scene.render.engine = 'BLENDER_RENDER'
+
+        scene.render.bake_type = bake_type
+
+        # assign the image to the UV Editor, which does not have to shown
+        bpy.data.screens['UV Editing'].areas[1].spaces[0].image = image
+
+        renderer = scene.render
+        renderer.use_bake_selected_to_active = False
+        renderer.use_bake_to_vertex_color = False
+        renderer.use_bake_clear = False
+        renderer.bake_quad_split = 'AUTO'
+        renderer.bake_margin = 5
+        renderer.use_file_extension = True
+
+        renderer.use_bake_normalize = True
+        renderer.use_bake_antialiasing = True
+
+        bpy.ops.object.bake_image()
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def bakeCycles(self, bake_type, image, uvName, nodeTrees, extension):
+        Logger.log('Cycles baking texture, type: ' + bake_type + ', mapped using: ' + uvName, 3)
+        legalName = legal_js_identifier(self.name)
+        image.filepath = legalName + '_' + bake_type + extension
+
+        scene = bpy.context.scene
+        scene.render.engine = 'CYCLES'
+
+        # create an unlinked temporary node to bake to for each material
+        for tree in nodeTrees:
+            bakeNode = tree.nodes.new(type='ShaderNodeTexImage')
+            bakeNode.image = image
+            bakeNode.select = True
+            tree.nodes.active = bakeNode
+
+        bpy.ops.object.bake(type = bake_type, use_clear = True, margin = 5, use_selected_to_active = False)
+
+        for tree in nodeTrees:
+            tree.nodes.remove(tree.nodes.active)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    @staticmethod
+    def meshBakingClean(mesh):
+        for uvMap in mesh.data.uv_textures:
+            if uvMap.name == 'BakingUV':
+                mesh.data.uv_textures.remove(uvMap)
+                break
+
+        # remove an image if it was baked
+        for image in bpy.data.images:
+            if image.name == mesh.name + '_BJS_BAKE':
+                image.user_clear() # cannot remove image unless 0 references
+                bpy.data.images.remove(image)
+                break

+ 848 - 0
Exporters/Blender/src/mesh.py

@@ -0,0 +1,848 @@
+from .logger import *
+from .package_level import *
+
+from .f_curve_animatable import *
+from .armature import *
+from .material import *
+
+import bpy
+import math
+from mathutils import Vector
+import shutil
+
+# output related constants
+MAX_VERTEX_ELEMENTS = 65535
+MAX_VERTEX_ELEMENTS_32Bit = 16777216
+COMPRESS_MATRIX_INDICES = True # this is True for .babylon exporter & False for TOB
+
+# used in Mesh & Node constructors, defined in BABYLON.AbstractMesh
+BILLBOARDMODE_NONE = 0
+#BILLBOARDMODE_X = 1
+#BILLBOARDMODE_Y = 2
+#BILLBOARDMODE_Z = 4
+BILLBOARDMODE_ALL = 7
+
+# used in Mesh constructor, defined in BABYLON.PhysicsEngine
+SPHERE_IMPOSTER = 1
+BOX_IMPOSTER = 2
+#PLANE_IMPOSTER = 3
+MESH_IMPOSTER = 4
+CAPSULE_IMPOSTER = 5
+CONE_IMPOSTER = 6
+CYLINDER_IMPOSTER = 7
+CONVEX_HULL_IMPOSTER = 8
+#===============================================================================
+class Mesh(FCurveAnimatable):
+    def __init__(self, object, scene, startFace, forcedParent, nameID, exporter):
+        self.name = object.name + str(nameID)
+        Logger.log('processing begun of mesh:  ' + self.name)
+        self.define_animations(object, True, True, True)  #Should animations be done when forcedParent
+
+        self.isVisible = not object.hide_render
+        self.isEnabled = not object.data.loadDisabled
+        useFlatShading = scene.export_flatshadeScene or object.data.useFlatShading
+        self.checkCollisions = object.data.checkCollisions
+        self.receiveShadows = object.data.receiveShadows
+        self.castShadows = object.data.castShadows
+        self.freezeWorldMatrix = object.data.freezeWorldMatrix
+        self.layer = getLayer(object) # used only for lights with 'This Layer Only' checked, not exported
+
+        # hasSkeleton detection & skeletonID determination
+        hasSkeleton = False
+        objArmature = None      # if there's an armature, this will be the one!
+        if len(object.vertex_groups) > 0 and not object.data.ignoreSkeleton:
+            objArmature = object.find_armature()
+            if objArmature != None:
+                hasSkeleton = True
+                # used to get bone index, since could be skipping IK bones
+                skeleton = exporter.get_skeleton(objArmature.name)
+                i = 0
+                for obj in scene.objects:
+                    if obj.type == "ARMATURE":
+                        if obj == objArmature:
+                            self.skeletonId = i
+                            break
+                        else:
+                            i += 1
+
+        # determine Position, rotation, & scaling
+        if forcedParent is None:
+            # Use local matrix
+            locMatrix = object.matrix_local
+            if objArmature != None:
+                # unless the armature is the parent
+                if object.parent and object.parent == objArmature:
+                    locMatrix = object.matrix_world * object.parent.matrix_world.inverted()
+
+            loc, rot, scale = locMatrix.decompose()
+            self.position = loc
+            if object.rotation_mode == 'QUATERNION':
+                self.rotationQuaternion = rot
+            else:
+                self.rotation = scale_vector(rot.to_euler('XYZ'), -1)
+            self.scaling  = scale
+        else:
+            # use defaults when not None
+            self.position = Vector((0, 0, 0))
+            self.rotation = scale_vector(Vector((0, 0, 0)), 1) # isn't scaling 0's by 1 same as 0?
+            self.scaling  = Vector((1, 1, 1))
+            
+        # ensure no unapplied rotation or scale, when there is an armature
+        self.hasUnappliedTransforms = (self.scaling .x != 1 or self.scaling .y != 1 or self.scaling .z != 1 or
+                self.rotation.x != 0 or self.rotation.y != 0 or self.rotation.z != 0 or 
+                (object.rotation_mode == 'QUATERNION' and self.rotation.w != 1)
+                )
+
+        # determine parent & dataName
+        if forcedParent is None:
+            self.dataName = object.data.name # used to support shared vertex instances in later passed
+            if object.parent and object.parent.type != 'ARMATURE':
+                self.parentId = object.parent.name
+        else:
+            self.dataName = self.name
+            self.parentId = forcedParent.name
+
+        # Get if this will be an instance of another, before processing materials, to avoid multi-bakes
+        sourceMesh = exporter.getSourceMeshInstance(self.dataName)
+        if sourceMesh is not None:
+            #need to make sure rotation mode matches, since value initially copied in InstancedMesh constructor
+            if hasattr(sourceMesh, 'rotationQuaternion'):
+                instRot = None
+                instRotq = rot
+            else:
+                instRot = scale_vector(rot.to_euler('XYZ'), -1)
+                instRotq = None
+
+            instance = MeshInstance(self.name, self.position, instRot, instRotq, self.scaling, self.freezeWorldMatrix)
+            sourceMesh.instances.append(instance)
+            Logger.log('mesh is an instance of :  ' + sourceMesh.name + '.  Processing halted.', 2)
+            return
+        else:
+            self.instances = []
+
+        # Physics
+        if object.rigid_body != None:
+            shape_items = {'SPHERE'     : SPHERE_IMPOSTER,
+                           'BOX'        : BOX_IMPOSTER,
+                           'MESH'       : MESH_IMPOSTER,
+                           'CAPSULE'    : CAPSULE_IMPOSTER,
+                           'CONE'       : CONE_IMPOSTER,
+                           'CYLINDER'   : CYLINDER_IMPOSTER,
+                           'CONVEX_HULL': CONVEX_HULL_IMPOSTER}
+
+            shape_type = shape_items[object.rigid_body.collision_shape]
+            self.physicsImpostor = shape_type
+            mass = object.rigid_body.mass
+            if mass < 0.005:
+                mass = 0
+            self.physicsMass = mass
+            self.physicsFriction = object.rigid_body.friction
+            self.physicsRestitution = object.rigid_body.restitution
+
+        # process all of the materials required
+        maxVerts = MAX_VERTEX_ELEMENTS # change for multi-materials
+        recipe = BakingRecipe(object)
+        self.billboardMode = BILLBOARDMODE_ALL if recipe.isBillboard else BILLBOARDMODE_NONE
+
+        if recipe.needsBaking:
+            if recipe.multipleRenders:
+                Logger.warn('Mixing of Cycles & Blender Render in same mesh not supported.  No materials exported.', 2)
+            else:
+                bakedMat = BakedMaterial(exporter, object, recipe)
+                exporter.materials.append(bakedMat)
+                self.materialId = bakedMat.name
+
+        else:
+            bjs_material_slots = []
+            for slot in object.material_slots:
+                # None will be returned when either the first encounter or must be unique due to baked textures
+                material = exporter.getMaterial(slot.name)
+                if (material != None):
+                    Logger.log('registered as also a user of material:  ' + slot.name, 2)
+                else:
+                    material = StdMaterial(slot, exporter, object)
+                    exporter.materials.append(material)
+
+                bjs_material_slots.append(material)
+
+            if len(bjs_material_slots) == 1:
+                self.materialId = bjs_material_slots[0].name
+
+            elif len(bjs_material_slots) > 1:
+                multimat = MultiMaterial(bjs_material_slots, len(exporter.multiMaterials), exporter.nameSpace)
+                self.materialId = multimat.name
+                exporter.multiMaterials.append(multimat)
+                maxVerts = MAX_VERTEX_ELEMENTS_32Bit
+            else:
+                Logger.warn('No materials have been assigned: ', 2)
+
+        # Get mesh
+        mesh = object.to_mesh(scene, True, 'PREVIEW')
+
+        # Triangulate mesh if required
+        Mesh.mesh_triangulate(mesh)
+
+        # Getting vertices and indices
+        self.positions  = []
+        self.normals    = []
+        self.uvs        = [] # not always used
+        self.uvs2       = [] # not always used
+        self.colors     = [] # not always used
+        self.indices    = []
+        self.subMeshes  = []
+
+        hasUV = len(mesh.tessface_uv_textures) > 0
+        if hasUV:
+            which = len(mesh.tessface_uv_textures) - 1 if recipe.needsBaking else 0
+            UVmap = mesh.tessface_uv_textures[which].data
+
+        hasUV2 = len(mesh.tessface_uv_textures) > 1 and not recipe.needsBaking
+        if hasUV2:
+            UV2map = mesh.tessface_uv_textures[1].data
+
+        hasVertexColor = len(mesh.vertex_colors) > 0
+        if hasVertexColor:
+            Colormap = mesh.tessface_vertex_colors.active.data
+
+        if hasSkeleton:
+            weightsPerVertex = []
+            indicesPerVertex = []
+            influenceCounts = [0, 0, 0, 0, 0, 0, 0, 0, 0] # 9, so accessed orign 1; 0 used for all those greater than 8
+            totalInfluencers = 0
+            highestInfluenceObserved = 0
+
+        # used tracking of vertices as they are received
+        alreadySavedVertices = []
+        vertices_Normals = []
+        vertices_UVs = []
+        vertices_UV2s = []
+        vertices_Colors = []
+        vertices_indices = []
+        vertices_sk_weights = []
+        vertices_sk_indices = []
+
+        self.offsetFace = 0
+
+        for v in range(0, len(mesh.vertices)):
+            alreadySavedVertices.append(False)
+            vertices_Normals.append([])
+            vertices_UVs.append([])
+            vertices_UV2s.append([])
+            vertices_Colors.append([])
+            vertices_indices.append([])
+            vertices_sk_weights.append([])
+            vertices_sk_indices.append([])
+
+        materialsCount = 1 if recipe.needsBaking else max(1, len(object.material_slots))
+        verticesCount = 0
+        indicesCount = 0
+
+        for materialIndex in range(materialsCount):
+            if self.offsetFace != 0:
+                break
+
+            subMeshVerticesStart = verticesCount
+            subMeshIndexStart = indicesCount
+
+            for faceIndex in range(startFace, len(mesh.tessfaces)):  # For each face
+                face = mesh.tessfaces[faceIndex]
+
+                if face.material_index != materialIndex and not recipe.needsBaking:
+                    continue
+
+                if verticesCount + 3 > maxVerts:
+                    self.offsetFace = faceIndex
+                    break
+
+                for v in range(3): # For each vertex in face
+                    vertex_index = face.vertices[v]
+
+                    vertex = mesh.vertices[vertex_index]
+                    position = vertex.co
+                    normal = face.normal if useFlatShading else vertex.normal
+
+                    #skeletons
+                    if hasSkeleton:
+                        matricesWeights = []
+                        matricesIndices = []
+
+                        # Getting influences
+                        for group in vertex.groups:
+                            index = group.group
+                            weight = group.weight
+
+                            for bone in objArmature.pose.bones:
+                                if object.vertex_groups[index].name == bone.name:
+                                    matricesWeights.append(weight)
+                                    matricesIndices.append(skeleton.get_index_of_bone(bone.name))
+
+                    # Texture coordinates
+                    if hasUV:
+                        vertex_UV = UVmap[face.index].uv[v]
+
+                    if hasUV2:
+                        vertex_UV2 = UV2map[face.index].uv[v]
+
+                    # Vertex color
+                    if hasVertexColor:
+                        if v == 0:
+                            vertex_Color = Colormap[face.index].color1
+                        if v == 1:
+                            vertex_Color = Colormap[face.index].color2
+                        if v == 2:
+                            vertex_Color = Colormap[face.index].color3
+
+                    # Check if the current vertex is already saved
+                    alreadySaved = alreadySavedVertices[vertex_index] and not useFlatShading
+                    if alreadySaved:
+                        alreadySaved = False
+
+                        # UV
+                        index_UV = 0
+                        for savedIndex in vertices_indices[vertex_index]:
+                            vNormal = vertices_Normals[vertex_index][index_UV]
+                            if not same_vertex(normal, vNormal):
+                                continue;
+
+                            if hasUV:
+                                vUV = vertices_UVs[vertex_index][index_UV]
+                                if not same_array(vertex_UV, vUV):
+                                    continue
+
+                            if hasUV2:
+                                vUV2 = vertices_UV2s[vertex_index][index_UV]
+                                if not same_array(vertex_UV2, vUV2):
+                                    continue
+
+                            if hasVertexColor:
+                                vColor = vertices_Colors[vertex_index][index_UV]
+                                if vColor.r != vertex_Color.r or vColor.g != vertex_Color.g or vColor.b != vertex_Color.b:
+                                    continue
+
+                            if hasSkeleton:
+                                vSkWeight = vertices_sk_weights[vertex_index]
+                                vSkIndices = vertices_sk_indices[vertex_index]
+                                if not same_array(vSkWeight[index_UV], matricesWeights) or not same_array(vSkIndices[index_UV], matricesIndices):
+                                    continue
+
+                            if vertices_indices[vertex_index][index_UV] >= subMeshVerticesStart:
+                                alreadySaved = True
+                                break
+
+                            index_UV += 1
+
+                    if (alreadySaved):
+                        # Reuse vertex
+                        index = vertices_indices[vertex_index][index_UV]
+                    else:
+                        # Export new one
+                        index = verticesCount
+                        alreadySavedVertices[vertex_index] = True
+
+                        vertices_Normals[vertex_index].append(normal)
+                        self.normals.append(normal)
+
+                        if hasUV:
+                            vertices_UVs[vertex_index].append(vertex_UV)
+                            self.uvs.append(vertex_UV[0])
+                            self.uvs.append(vertex_UV[1])
+                        if hasUV2:
+                            vertices_UV2s[vertex_index].append(vertex_UV2)
+                            self.uvs2.append(vertex_UV2[0])
+                            self.uvs2.append(vertex_UV2[1])
+                        if hasVertexColor:
+                            vertices_Colors[vertex_index].append(vertex_Color)
+                            self.colors.append(vertex_Color.r)
+                            self.colors.append(vertex_Color.g)
+                            self.colors.append(vertex_Color.b)
+                            self.colors.append(1.0)
+                        if hasSkeleton:
+                            vertices_sk_weights[vertex_index].append(matricesWeights)
+                            vertices_sk_indices[vertex_index].append(matricesIndices)
+                            nInfluencers = len(matricesWeights)
+                            totalInfluencers += nInfluencers
+                            if nInfluencers <= 8:
+                                influenceCounts[nInfluencers] += 1
+                            else:
+                                influenceCounts[0] += 1
+                            highestInfluenceObserved = nInfluencers if nInfluencers > highestInfluenceObserved else highestInfluenceObserved
+                            weightsPerVertex.append(matricesWeights)
+                            indicesPerVertex.append(matricesIndices)
+
+                        vertices_indices[vertex_index].append(index)
+
+                        self.positions.append(position)
+
+                        verticesCount += 1
+                    self.indices.append(index)
+                    indicesCount += 1
+
+            self.subMeshes.append(SubMesh(materialIndex, subMeshVerticesStart, subMeshIndexStart, verticesCount - subMeshVerticesStart, indicesCount - subMeshIndexStart))
+
+        if verticesCount > MAX_VERTEX_ELEMENTS:
+            Logger.warn('Due to multi-materials / Shapekeys & this meshes size, 32bit indices must be used.  This may not run on all hardware.', 2)
+
+        BakedMaterial.meshBakingClean(object)
+
+        Logger.log('num positions      :  ' + str(len(self.positions)), 2)
+        Logger.log('num normals        :  ' + str(len(self.normals  )), 2)
+        Logger.log('num uvs            :  ' + str(len(self.uvs      )), 2)
+        Logger.log('num uvs2           :  ' + str(len(self.uvs2     )), 2)
+        Logger.log('num colors         :  ' + str(len(self.colors   )), 2)
+        Logger.log('num indices        :  ' + str(len(self.indices  )), 2)
+        
+        if hasSkeleton:
+            Logger.log('Skeleton stats:  ', 2)
+            self.toFixedInfluencers(weightsPerVertex, indicesPerVertex, object.data.maxInfluencers, highestInfluenceObserved)
+
+            if (COMPRESS_MATRIX_INDICES):
+                self.skeletonIndices = Mesh.packSkeletonIndices(self.skeletonIndices)
+                if (self.numBoneInfluencers > 4):
+                    self.skeletonIndicesExtra = Mesh.packSkeletonIndices(self.skeletonIndicesExtra)
+
+            Logger.log('Total Influencers:  ' + format_f(totalInfluencers), 3)
+            Logger.log('Avg # of influencers per vertex:  ' + format_f(totalInfluencers / len(self.positions)), 3)
+            Logger.log('Highest # of influencers observed:  ' + str(highestInfluenceObserved) + ', num vertices with this:  ' + format_int(influenceCounts[highestInfluenceObserved if highestInfluenceObserved < 9 else 0]), 3)
+            Logger.log('exported as ' + str(self.numBoneInfluencers) + ' influencers', 3)
+            nWeights = len(self.skeletonWeights) + (len(self.skeletonWeightsExtra) if hasattr(self, 'skeletonWeightsExtra') else 0)
+            Logger.log('num skeletonWeights and skeletonIndices:  ' + str(nWeights), 3)
+
+        numZeroAreaFaces = self.find_zero_area_faces()
+        if numZeroAreaFaces > 0:
+            Logger.warn('# of 0 area faces found:  ' + str(numZeroAreaFaces), 2)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def find_zero_area_faces(self):
+        nFaces = int(len(self.indices) / 3)
+        nZeroAreaFaces = 0
+        for f in range(0, nFaces):
+            faceOffset = f * 3
+            p1 = self.positions[self.indices[faceOffset    ]]
+            p2 = self.positions[self.indices[faceOffset + 1]]
+            p3 = self.positions[self.indices[faceOffset + 2]]
+
+            if same_vertex(p1, p2) or same_vertex(p1, p3) or same_vertex(p2, p3): nZeroAreaFaces += 1
+
+        return nZeroAreaFaces
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    @staticmethod
+    def mesh_triangulate(mesh):
+        try:
+            import bmesh
+            bm = bmesh.new()
+            bm.from_mesh(mesh)
+            bmesh.ops.triangulate(bm, faces = bm.faces)
+            bm.to_mesh(mesh)
+            mesh.calc_tessface()
+            bm.free()
+        except:
+            pass
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def toFixedInfluencers(self, weightsPerVertex, indicesPerVertex, maxInfluencers, highestObserved):
+        if (maxInfluencers > 8 or maxInfluencers < 1):
+            maxInfluencers = 8
+            Logger.warn('Maximum # of influencers invalid, set to 8', 3)
+
+        self.numBoneInfluencers = maxInfluencers if maxInfluencers < highestObserved else highestObserved
+        needExtras = self.numBoneInfluencers > 4
+
+        maxInfluencersExceeded = 0
+
+        fixedWeights = []
+        fixedIndices = []
+
+        fixedWeightsExtra = []
+        fixedIndicesExtra = []
+
+        for i in range(len(weightsPerVertex)):
+            weights = weightsPerVertex[i]
+            indices = indicesPerVertex[i]
+            nInfluencers = len(weights)
+
+            if (nInfluencers > self.numBoneInfluencers):
+                maxInfluencersExceeded += 1
+                Mesh.sortByDescendingInfluence(weights, indices)
+
+            for j in range(4):
+                fixedWeights.append(weights[j] if nInfluencers > j else 0.0)
+                fixedIndices.append(indices[j] if nInfluencers > j else 0  )
+
+            if needExtras:
+                for j in range(4, 8):
+                    fixedWeightsExtra.append(weights[j] if nInfluencers > j else 0.0)
+                    fixedIndicesExtra.append(indices[j] if nInfluencers > j else 0  )
+
+        self.skeletonWeights = fixedWeights
+        self.skeletonIndices = fixedIndices
+
+        if needExtras:
+            self.skeletonWeightsExtra = fixedWeightsExtra
+            self.skeletonIndicesExtra = fixedIndicesExtra
+
+        if maxInfluencersExceeded > 0:
+            Logger.warn('Maximum # of influencers exceeded for ' + format_int(maxInfluencersExceeded) + ' vertices, extras ignored', 3)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    # sorts one set of weights & indices by descending weight, by reference
+    # not shown to help with MakeHuman, but did not hurt.  In just so it is not lost for future.
+    @staticmethod
+    def sortByDescendingInfluence(weights, indices):
+        notSorted = True
+        while(notSorted):
+            notSorted = False
+            for idx in range(1, len(weights)):
+                if weights[idx - 1] < weights[idx]:
+                    tmp = weights[idx]
+                    weights[idx    ] = weights[idx - 1]
+                    weights[idx - 1] = tmp
+
+                    tmp = indices[idx]
+                    indices[idx    ] = indices[idx - 1]
+                    indices[idx - 1] = tmp
+
+                    notSorted = True
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    # assume that toFixedInfluencers has already run, which ensures indices length is a multiple of 4
+    @staticmethod
+    def packSkeletonIndices(indices):
+        compressedIndices = []
+
+        for i in range(math.floor(len(indices) / 4)):
+            idx = i * 4
+            matricesIndicesCompressed  = indices[idx    ]
+            matricesIndicesCompressed += indices[idx + 1] <<  8
+            matricesIndicesCompressed += indices[idx + 2] << 16
+            matricesIndicesCompressed += indices[idx + 3] << 24
+
+            compressedIndices.append(matricesIndicesCompressed)
+
+        return compressedIndices
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_string(file_handler, 'name', self.name, True)
+        write_string(file_handler, 'id', self.name)
+        if hasattr(self, 'parentId'): write_string(file_handler, 'parentId', self.parentId)
+
+        if hasattr(self, 'materialId'): write_string(file_handler, 'materialId', self.materialId)
+        write_int(file_handler, 'billboardMode', self.billboardMode)
+        write_vector(file_handler, 'position', self.position)
+
+        if hasattr(self, "rotationQuaternion"):
+            write_quaternion(file_handler, 'rotationQuaternion', self.rotationQuaternion)
+        else:
+            write_vector(file_handler, 'rotation', self.rotation)
+
+        write_vector(file_handler, 'scaling', self.scaling)
+        write_bool(file_handler, 'isVisible', self.isVisible)
+        write_bool(file_handler, 'freezeWorldMatrix', self.freezeWorldMatrix)
+        write_bool(file_handler, 'isEnabled', self.isEnabled)
+        write_bool(file_handler, 'checkCollisions', self.checkCollisions)
+        write_bool(file_handler, 'receiveShadows', self.receiveShadows)
+
+        if hasattr(self, 'physicsImpostor'):
+            write_int(file_handler, 'physicsImpostor', self.physicsImpostor)
+            write_float(file_handler, 'physicsMass', self.physicsMass)
+            write_float(file_handler, 'physicsFriction', self.physicsFriction)
+            write_float(file_handler, 'physicsRestitution', self.physicsRestitution)
+
+        # Geometry
+        if hasattr(self, 'skeletonId'):
+            write_int(file_handler, 'skeletonId', self.skeletonId)
+            write_int(file_handler, 'numBoneInfluencers', self.numBoneInfluencers)
+
+        write_vector_array(file_handler, 'positions', self.positions)
+        write_vector_array(file_handler, 'normals'  , self.normals  )
+
+        if len(self.uvs) > 0:
+            write_array(file_handler, 'uvs', self.uvs)
+
+        if len(self.uvs2) > 0:
+            write_array(file_handler, 'uvs2', self.uvs2)
+
+        if len(self.colors) > 0:
+            write_array(file_handler, 'colors', self.colors)
+
+        if hasattr(self, 'skeletonWeights'):
+            write_array(file_handler, 'matricesWeights', self.skeletonWeights)
+            write_array(file_handler, 'matricesIndices', self.skeletonIndices)
+
+        if hasattr(self, 'skeletonWeightsExtra'):
+            write_array(file_handler, 'matricesWeightsExtra', self.skeletonWeightsExtra)
+            write_array(file_handler, 'matricesIndicesExtra', self.skeletonIndicesExtra)
+
+        write_array(file_handler, 'indices', self.indices)
+
+        # Sub meshes
+        file_handler.write('\n,"subMeshes":[')
+        first = True
+        for subMesh in self.subMeshes:
+            if first == False:
+                file_handler.write(',')
+            subMesh.to_scene_file(file_handler)
+            first = False
+        file_handler.write(']')
+
+        super().to_scene_file(file_handler) # Animations
+
+        # Instances
+        first = True
+        file_handler.write('\n,"instances":[')
+        for instance in self.instances:
+            if first == False:
+                file_handler.write(',')
+
+            instance.to_scene_file(file_handler)
+
+            first = False
+        file_handler.write(']')
+
+        # Close mesh
+        file_handler.write('}\n')
+        self.alreadyExported = True
+#===============================================================================
+class MeshInstance:
+     def __init__(self, name, position, rotation, rotationQuaternion, scaling, freezeWorldMatrix):
+        self.name = name
+        self.position = position
+        if rotation is not None:
+            self.rotation = rotation
+        if rotationQuaternion is not None:
+            self.rotationQuaternion = rotationQuaternion
+        self.scaling = scaling
+        self.freezeWorldMatrix = freezeWorldMatrix
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+     def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_string(file_handler, 'name', self.name, True)
+        write_vector(file_handler, 'position', self.position)
+        if hasattr(self, 'rotation'):
+            write_vector(file_handler, 'rotation', self.rotation)
+        else:
+            write_quaternion(file_handler, 'rotationQuaternion', self.rotationQuaternion)
+
+        write_vector(file_handler, 'scaling', self.scaling)
+        # freeze World Matrix currently ignored for instances
+        write_bool(file_handler, 'freezeWorldMatrix', self.freezeWorldMatrix)
+        file_handler.write('}')
+#===============================================================================
+class Node(FCurveAnimatable):
+    def __init__(self, node):
+        Logger.log('processing begun of node:  ' + node.name)
+        self.define_animations(node, True, True, True)  #Should animations be done when forcedParent
+        self.name = node.name
+
+        if node.parent and node.parent.type != 'ARMATURE':
+            self.parentId = node.parent.name
+
+        loc, rot, scale = node.matrix_local.decompose()
+
+        self.position = loc
+        if node.rotation_mode == 'QUATERNION':
+            self.rotationQuaternion = rot
+        else:
+            self.rotation = scale_vector(rot.to_euler('XYZ'), -1)
+        self.scaling = scale
+        self.isVisible = False
+        self.isEnabled = True
+        self.checkCollisions = False
+        self.billboardMode = BILLBOARDMODE_NONE
+        self.castShadows = False
+        self.receiveShadows = False
+        self.layer = getLayer(object) # used only for lights with 'This Layer Only' checked, not exported
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_string(file_handler, 'name', self.name, True)
+        write_string(file_handler, 'id', self.name)
+        if hasattr(self, 'parentId'): write_string(file_handler, 'parentId', self.parentId)
+
+        write_vector(file_handler, 'position', self.position)
+        if hasattr(self, "rotationQuaternion"):
+            write_quaternion(file_handler, "rotationQuaternion", self.rotationQuaternion)
+        else:
+            write_vector(file_handler, 'rotation', self.rotation)
+        write_vector(file_handler, 'scaling', self.scaling)
+        write_bool(file_handler, 'isVisible', self.isVisible)
+        write_bool(file_handler, 'isEnabled', self.isEnabled)
+        write_bool(file_handler, 'checkCollisions', self.checkCollisions)
+        write_int(file_handler, 'billboardMode', self.billboardMode)
+        write_bool(file_handler, 'receiveShadows', self.receiveShadows)
+
+        super().to_scene_file(file_handler) # Animations
+        file_handler.write('}')
+#===============================================================================
+class SubMesh:
+    def __init__(self, materialIndex, verticesStart, indexStart, verticesCount, indexCount):
+        self.materialIndex = materialIndex
+        self.verticesStart = verticesStart
+        self.indexStart = indexStart
+        self.verticesCount = verticesCount
+        self.indexCount = indexCount
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_int(file_handler, 'materialIndex', self.materialIndex, True)
+        write_int(file_handler, 'verticesStart', self.verticesStart)
+        write_int(file_handler, 'verticesCount', self.verticesCount)
+        write_int(file_handler, 'indexStart'   , self.indexStart)
+        write_int(file_handler, 'indexCount'   , self.indexCount)
+        file_handler.write('}')
+#===============================================================================
+bpy.types.Mesh.autoAnimate = bpy.props.BoolProperty(
+    name='Auto launch animations',
+    description='',
+    default = False
+)
+bpy.types.Mesh.useFlatShading = bpy.props.BoolProperty(
+    name='Use Flat Shading',
+    description='Use face normals.  Increases vertices.',
+    default = False
+)
+bpy.types.Mesh.checkCollisions = bpy.props.BoolProperty(
+    name='Check Collisions',
+    description='Indicates mesh should be checked that it does not run into anything.',
+    default = False
+)
+bpy.types.Mesh.castShadows = bpy.props.BoolProperty(
+    name='Cast Shadows',
+    description='',
+    default = False
+)
+bpy.types.Mesh.receiveShadows = bpy.props.BoolProperty(
+    name='Receive Shadows',
+    description='',
+    default = False
+)
+# not currently in use
+bpy.types.Mesh.forceBaking = bpy.props.BoolProperty(
+    name='Combine Multi-textures / resize',
+    description='Also good to adjust single texture\'s size /compression.',
+    default = False
+)
+# not currently in use
+bpy.types.Mesh.usePNG = bpy.props.BoolProperty(
+    name='Need Alpha',
+    description='Saved as PNG when alpha is required, else JPG.',
+    default = False
+)
+bpy.types.Mesh.bakeSize = bpy.props.IntProperty(
+    name='Texture Size',
+    description='',
+    default = 1024
+)
+bpy.types.Mesh.bakeQuality = bpy.props.IntProperty(
+    name='Quality 1-100',
+    description='For JPG: The trade-off between Quality - File size(100 highest quality)\nFor PNG: amount of time spent for compression',
+    default = 50, min = 1, max = 100
+)
+bpy.types.Mesh.materialNameSpace = bpy.props.StringProperty(
+    name='Name Space',
+    description='Prefix to use for materials for sharing across .blends.',
+    default = DEFAULT_MATERIAL_NAMESPACE
+)
+bpy.types.Mesh.maxSimultaneousLights = bpy.props.IntProperty(
+    name='Max Simultaneous Lights 0 - 32',
+    description='BJS property set on each material of this mesh.\nSet higher for more complex lighting.\nSet lower for armatures on mobile',
+    default = 4, min = 0, max = 32
+)
+bpy.types.Mesh.checkReadyOnlyOnce = bpy.props.BoolProperty(
+    name='Check Ready Only Once',
+    description='When checked better CPU utilization.  Advanced user option.',
+    default = False
+)
+bpy.types.Mesh.freezeWorldMatrix = bpy.props.BoolProperty(
+    name='Freeze World Matrix',
+    description='Indicate the position, rotation, & scale do not change for performance reasons',
+    default = False
+)
+bpy.types.Mesh.loadDisabled = bpy.props.BoolProperty(
+    name='Load Disabled',
+    description='Indicate this mesh & children should not be active until enabled by code.',
+    default = False
+)
+bpy.types.Mesh.attachedSound = bpy.props.StringProperty(
+    name='Sound',
+    description='',
+    default = ''
+)
+bpy.types.Mesh.loopSound = bpy.props.BoolProperty(
+    name='Loop sound',
+    description='',
+    default = True
+)
+bpy.types.Mesh.autoPlaySound = bpy.props.BoolProperty(
+    name='Auto play sound',
+    description='',
+    default = True
+)
+bpy.types.Mesh.maxSoundDistance = bpy.props.FloatProperty(
+    name='Max sound distance',
+    description='',
+    default = 100
+)
+bpy.types.Mesh.ignoreSkeleton = bpy.props.BoolProperty(
+    name='Ignore',
+    description='Do not export assignment to a skeleton',
+    default = False
+)
+bpy.types.Mesh.maxInfluencers = bpy.props.IntProperty(
+    name='Max bone Influencers / Vertex',
+    description='When fewer than this are observed, the lower value is used.',
+    default = 8, min = 1, max = 8
+)
+
+#===============================================================================
+class MeshPanel(bpy.types.Panel):
+    bl_label = get_title()
+    bl_space_type = 'PROPERTIES'
+    bl_region_type = 'WINDOW'
+    bl_context = 'data'
+    
+    @classmethod
+    def poll(cls, context):
+        ob = context.object
+        return ob is not None and isinstance(ob.data, bpy.types.Mesh)
+    
+    def draw(self, context):
+        ob = context.object
+        layout = self.layout  
+        row = layout.row()
+        row.prop(ob.data, 'useFlatShading')
+        row.prop(ob.data, 'checkCollisions')
+        
+        row = layout.row()
+        row.prop(ob.data, 'castShadows')
+        row.prop(ob.data, 'receiveShadows')
+        
+        row = layout.row()
+        row.prop(ob.data, 'freezeWorldMatrix')
+        row.prop(ob.data, 'loadDisabled')
+        
+        layout.prop(ob.data, 'autoAnimate')
+        
+        box = layout.box()
+        box.label(text='Skeleton:')
+        box.prop(ob.data, 'ignoreSkeleton')
+        row = box.row()
+        row.enabled = not ob.data.ignoreSkeleton
+        row.prop(ob.data, 'maxInfluencers')
+        
+        box = layout.box()
+        box.label('Materials')
+        box.prop(ob.data, 'materialNameSpace')
+        box.prop(ob.data, 'maxSimultaneousLights')
+        box.prop(ob.data, 'checkReadyOnlyOnce')
+        
+        box = layout.box()
+        box.label(text='Procedural Texture / Cycles Baking')
+#        box.prop(ob.data, 'forceBaking')
+#        box.prop(ob.data, 'usePNG')
+        box.prop(ob.data, 'bakeSize')
+        box.prop(ob.data, 'bakeQuality')
+        
+        box = layout.box()
+        box.prop(ob.data, 'attachedSound')
+        row = box.row()
+
+        row.prop(ob.data, 'autoPlaySound')
+        row.prop(ob.data, 'loopSound')
+        box.prop(ob.data, 'maxSoundDistance')

+ 263 - 0
Exporters/Blender/src/package_level.py

@@ -0,0 +1,263 @@
+from sys import modules
+from math import floor
+
+from bpy import app
+from time import strftime
+MAX_FLOAT_PRECISION_INT = 4
+MAX_FLOAT_PRECISION = '%.' + str(MAX_FLOAT_PRECISION_INT) + 'f'
+VERTEX_OUTPUT_PER_LINE = 50
+STRIP_LEADING_ZEROS_DEFAULT = False # false for .babylon
+#===============================================================================
+#  module level formatting methods, called from multiple classes
+#===============================================================================
+def get_title():
+    bl_info = get_bl_info()
+    return bl_info['name'] + ' ver ' + format_exporter_version(bl_info)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_exporter_version(bl_info = None):
+    if bl_info is None:
+        bl_info = get_bl_info()
+    exporterVersion = bl_info['version']
+    return str(exporterVersion[0]) + '.' + str(exporterVersion[1]) +  '.' + str(exporterVersion[2])
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def blenderMajorMinorVersion():
+    # in form of '2.77 (sub 0)'
+    split1 = app.version_string.partition('.') 
+    major = split1[0]
+    
+    split2 = split1[2].partition(' ')
+    minor = split2[0]
+    
+    return float(major + '.' + minor)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def verify_min_blender_version():
+    reqd = get_bl_info()['blender']
+    
+    # in form of '2.77 (sub 0)'
+    split1 = app.version_string.partition('.') 
+    major = int(split1[0])
+    if reqd[0] > major: return False
+    
+    split2 = split1[2].partition(' ')
+    minor = int(split2[0])
+    if reqd[1] > minor: return False
+    
+    split3 = split2[2].partition(' ')
+    revision = int(split3[2][:1])
+    if reqd[2] > revision: return False
+    
+    return True
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def getNameSpace(filepathMinusExtension):
+    # assign nameSpace, based on OS
+    if filepathMinusExtension.find('\\') != -1:
+        return legal_js_identifier(filepathMinusExtension.rpartition('\\')[2])
+    else:
+        return legal_js_identifier(filepathMinusExtension.rpartition('/')[2])
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def getLayer(obj):
+    for idx, layer in enumerate(obj.layers):
+        if layer:
+            return idx
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# a class for getting the module name, exporter version, & reqd blender version in get_bl_info()
+class dummy: pass
+def get_bl_info():
+    # .__module__ is the 'name of package.module', so strip after dot
+    packageName = dummy.__module__.partition('.')[0]
+    return modules.get(packageName).bl_info
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def legal_js_identifier(input):
+    out = ''
+    prefix = ''
+    for char in input:
+        if len(out) == 0:
+            if char in '0123456789':
+                # cannot take the chance that leading numbers being chopped of cause name conflicts, e.g (01.R & 02.R)
+                prefix += char
+                continue
+            elif char.upper() not in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
+                continue
+
+        legal = char if char.upper() in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_' else '_'
+        out += legal
+
+    if len(prefix) > 0:
+        out += '_' + prefix
+    return out
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_f(num, stripLeadingZero = STRIP_LEADING_ZEROS_DEFAULT):
+    s = MAX_FLOAT_PRECISION % num # rounds to N decimal places while changing to string
+    s = s.rstrip('0') # strip trailing zeroes
+    s = s.rstrip('.') # strip trailing .
+    s = '0' if s == '-0' else s # nuke -0
+    
+    if stripLeadingZero:
+        asNum = float(s)
+        if asNum != 0 and asNum > -1 and asNum < 1:
+            if asNum < 0:
+                s = '-' + s[2:]
+            else:
+                s = s[1:]
+        
+    return s
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_matrix4(matrix):
+    tempMatrix = matrix.copy()
+    tempMatrix.transpose()
+
+    ret = ''
+    first = True
+    for vect in tempMatrix:
+        if (first != True):
+            ret +=','
+        first = False;
+
+        ret += format_f(vect[0]) + ',' + format_f(vect[1]) + ',' + format_f(vect[2]) + ',' + format_f(vect[3])
+
+    return ret
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_array3(array):
+    return format_f(array[0]) + ',' + format_f(array[1]) + ',' + format_f(array[2])
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_array(array, indent = ''):
+    ret = ''
+    first = True
+    nOnLine = 0
+    for element in array:
+        if (first != True):
+            ret +=','
+        first = False;
+
+        ret += format_f(element)
+        nOnLine += 1
+
+        if nOnLine >= VERTEX_OUTPUT_PER_LINE:
+            ret += '\n' + indent
+            nOnLine = 0
+
+    return ret
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_color(color):
+    return format_f(color.r) + ',' + format_f(color.g) + ',' + format_f(color.b)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_vector(vector, switchYZ = True):
+    return format_f(vector.x) + ',' + format_f(vector.z) + ',' + format_f(vector.y) if switchYZ else format_f(vector.x) + ',' + format_f(vector.y) + ',' + format_f(vector.z)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_vector_array(vectorArray, indent = ''):
+    ret = ''
+    first = True
+    nOnLine = 0
+    for vector in vectorArray:
+        if (first != True):
+            ret +=','
+        first = False;
+
+        ret += format_vector(vector)
+        nOnLine += 3
+
+        if nOnLine >= VERTEX_OUTPUT_PER_LINE:
+            ret += '\n' + indent
+            nOnLine = 0
+
+    return ret
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_quaternion(quaternion):
+    return format_f(quaternion.x) + ',' + format_f(quaternion.z) + ',' + format_f(quaternion.y) + ',' + format_f(-quaternion.w)
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_int(int):
+    candidate = str(int) # when int string of an int
+    if '.' in candidate:
+        return format_f(floor(int)) # format_f removes un-neccessary precision
+    else:
+        return candidate
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def format_bool(bool):
+    if bool:
+        return 'true'
+    else:
+        return 'false'
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def post_rotate_quaternion(quat, angle):
+    post = mathutils.Euler((angle, 0.0, 0.0)).to_matrix()
+    mqtn = quat.to_matrix()
+    quat = (mqtn*post).to_quaternion()
+    return quat
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def scale_vector(vector, mult, xOffset = 0):
+    ret = vector.copy()
+    ret.x *= mult
+    ret.x += xOffset
+    ret.z *= mult
+    ret.y *= mult
+    return ret
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def same_matrix4(matA, matB):
+    if(matA is None or matB is None): return False
+    if (len(matA) != len(matB)): return False
+    for i in range(len(matA)):
+        if (format_f(matA[i][0]) != format_f(matB[i][0]) or
+            format_f(matA[i][1]) != format_f(matB[i][1]) or
+            format_f(matA[i][2]) != format_f(matB[i][2]) or
+            format_f(matA[i][3]) != format_f(matB[i][3]) ):
+            return False
+    return True
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def same_vertex(vertA, vertB):
+    if vertA is None or vertB is None: return False
+    
+    if (format_f(vertA.x) != format_f(vertB.x) or
+        format_f(vertA.y) != format_f(vertB.y) or
+        format_f(vertA.z) != format_f(vertB.z) ):
+        return False
+
+    return True
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def same_array(arrayA, arrayB):
+    if(arrayA is None or arrayB is None): return False
+    if len(arrayA) != len(arrayB): return False
+    for i in range(len(arrayA)):
+        if format_f(arrayA[i]) != format_f(arrayB[i]) : return False
+
+    return True
+#===============================================================================
+# module level methods for writing JSON (.babylon) files
+#===============================================================================
+def write_matrix4(file_handler, name, matrix):
+    file_handler.write(',"' + name + '":[' + format_matrix4(matrix) + ']')
+
+def write_array(file_handler, name, array):
+    file_handler.write('\n,"' + name + '":[' + format_array(array) + ']')
+
+def write_array3(file_handler, name, array):
+    file_handler.write(',"' + name + '":[' + format_array3(array) + ']')
+
+def write_color(file_handler, name, color):
+    file_handler.write(',"' + name + '":[' + format_color(color) + ']')
+
+def write_vector(file_handler, name, vector, switchYZ = True):
+    file_handler.write(',"' + name + '":[' + format_vector(vector, switchYZ) + ']')
+
+def write_vector_array(file_handler, name, vectorArray):
+    file_handler.write('\n,"' + name + '":[' + format_vector_array(vectorArray) + ']')
+
+def write_quaternion(file_handler, name, quaternion):
+    file_handler.write(',"' + name  +'":[' + format_quaternion(quaternion) + ']')
+
+def write_string(file_handler, name, string, noComma = False):
+    if noComma == False:
+        file_handler.write(',')
+    file_handler.write('"' + name + '":"' + string + '"')
+
+def write_float(file_handler, name, float):
+    file_handler.write(',"' + name + '":' + format_f(float))
+
+def write_int(file_handler, name, int, noComma = False):
+    if noComma == False:
+        file_handler.write(',')
+    file_handler.write('"' + name + '":' + format_int(int))
+
+def write_bool(file_handler, name, bool, noComma = False):
+    if noComma == False:
+        file_handler.write(',')
+    file_handler.write('"' + name + '":' + format_bool(bool))

+ 23 - 0
Exporters/Blender/src/sound.py

@@ -0,0 +1,23 @@
+from .package_level import *
+
+import bpy
+#===============================================================================
+class Sound:
+    def __init__(self, name, autoplay, loop, connectedMesh = None):
+        self.name = name;
+        self.autoplay = autoplay
+        self.loop = loop
+        if connectedMesh != None:
+            self.connectedMeshId = connectedMesh.name
+            self.maxDistance = connectedMesh.data.maxSoundDistance
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        file_handler.write('{')
+        write_string(file_handler, 'name', self.name, True)
+        write_bool(file_handler, 'autoplay', self.autoplay)
+        write_bool(file_handler, 'loop', self.loop)
+
+        if hasattr(self, 'connectedMeshId'):
+            write_string(file_handler, 'connectedMeshId', self.connectedMeshId)
+            write_float(file_handler, 'maxDistance', self.maxDistance)
+        file_handler.write('}')

+ 45 - 0
Exporters/Blender/src/world.py

@@ -0,0 +1,45 @@
+from .logger import *
+from .package_level import *
+
+import bpy
+
+# used in World constructor, defined in BABYLON.Scene
+#FOGMODE_NONE = 0
+#FOGMODE_EXP = 1
+#FOGMODE_EXP2 = 2
+FOGMODE_LINEAR = 3
+#===============================================================================
+class World:
+    def __init__(self, scene):
+        self.autoClear = True
+        world = scene.world
+        if world:
+            self.ambient_color = world.ambient_color
+            self.clear_color   = world.horizon_color
+        else:
+            self.ambient_color = mathutils.Color((0.2, 0.2, 0.3))
+            self.clear_color   = mathutils.Color((0.0, 0.0, 0.0))
+
+        self.gravity = scene.gravity
+
+        if world and world.mist_settings.use_mist:
+            self.fogMode = FOGMODE_LINEAR
+            self.fogColor = world.horizon_color
+            self.fogStart = world.mist_settings.start
+            self.fogEnd = world.mist_settings.depth
+            self.fogDensity = 0.1
+
+        Logger.log('Python World class constructor completed')
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    def to_scene_file(self, file_handler):
+        write_bool(file_handler, 'autoClear', self.autoClear, True)
+        write_color(file_handler, 'clearColor', self.clear_color)
+        write_color(file_handler, 'ambientColor', self.ambient_color)
+        write_vector(file_handler, 'gravity', self.gravity)
+
+        if hasattr(self, 'fogMode'):
+            write_int(file_handler, 'fogMode', self.fogMode)
+            write_color(file_handler, 'fogColor', self.fogColor)
+            write_float(file_handler, 'fogStart', self.fogStart)
+            write_float(file_handler, 'fogEnd', self.fogEnd)
+            write_float(file_handler, 'fogDensity', self.fogDensity)