# Copyright (c) 2017-2026 Soft8Soft, LLC. All rights reserved.
#
# Use of this software is subject to the terms of the Verge3D license
# agreement provided at the time of installation or download, or which
# otherwise accompanies this software in either electronic or hard copy form.
import json, lzma, math, os, shutil, struct, sys, tempfile

from profilehooks import profile

import maya.cmds as cmds
import maya.OpenMaya as OpenMaya
import maya.api.OpenMaya as OpenMaya2

import pluginUtils as pU
from pluginUtils import gltf

from collect import Collector
from mayaUtils import (getName, isMesh, isCamera, isLight, isLightProbe, isClippingPlane,
                       getFPS, extractMeshData, extractMeshDataLine, extractNurbsCurveData, extractBezierCurveData,
                       extractTexture, extractTexturePath, extractAnimData, getInputNode,
                       getSkyDomeLightTexRes)

import mayaUtils
import customAttrs
import matNodes

import reflectionCubemap as rc
import reflectionPlane
import clippingPlane

import sysfont

try:
    from PySide6.QtGui import QImageWriter
except ImportError:
    from PySide2.QtGui import QImageWriter

WORLD_NODE_MAT_NAME = 'Verge3D_Environment'
CAM_ANGLE_EPSILON = math.pi / 180
SPOT_SHADOW_MIN_NEAR = 0.01
SHADOW_MAX_FAR = 10000

ALPHA_CUTOFF_EPS = 1e-4

SHADOW_BB_FAR_COEFF = 1.01

PMREM_SIZE_MIN = 256
PMREM_SIZE_MAX = 1024

PRIMITIVE_MODE_LINES = 1
PRIMITIVE_MODE_TRI = 4

log = pU.log.getLogger('V3D-MY')
join = os.path.join

class GLTFExporter(object):
    def __init__(self):
        self.output = {}
        self.binary = bytearray()

    def generateAsset(self):
        asset = {}

        asset['version'] = '2.0'
        asset['generator'] = 'Verge3D for Maya v{}'.format(cmds.pluginInfo('verge3d', query=True, version=True))

        copyright = self.exportSettings['copyright']

        if copyright != '':
            asset['copyright'] = copyright

        self.output['asset'] = asset

    def createAnimation(self, collector, mNode):
        channels = []
        samplers = []

        animProxyNode = mayaUtils.findSkeletonRoot(mNode)
        if not animProxyNode:
            animProxyNode = mNode

        animSettings = customAttrs.parseAnimationSettings(animProxyNode)

        if animSettings['animUseCustomRange']:
            customRange = (animSettings['animCustomRangeFrom'], animSettings['animCustomRangeTo'])
            forcedAnim = True
        elif self.exportSettings['animUsePlaybackRange']:
            customRange = (cmds.playbackOptions(minTime=True, query=True),
                           cmds.playbackOptions(maxTime=True, query=True))
            forcedAnim = False
        else:
            customRange = None
            forcedAnim = False

        startWithZero = self.exportSettings['animStartWithZero']

        samplerIdx = 0

        if cmds.keyframe(mNode, attribute='translate', query=True, keyframeCount=True) or forcedAnim:
            keys, values, interp = extractAnimData(mNode, 'translation', startWithZero, customRange)

            channels.append(gltf.createAnimChannel(samplerIdx,
                    gltf.getNodeIndex(self.output, mNode), 'translation'))
            samplers.append(gltf.createAnimSampler(self.output, self.binary,
                    keys, values, 3, interp))
            samplerIdx += 1

        if cmds.keyframe(mNode, attribute='rotate', query=True, keyframeCount=True) or forcedAnim:
            keys, values, interp = extractAnimData(mNode, 'rotation', startWithZero, customRange)

            channels.append(gltf.createAnimChannel(samplerIdx,
                    gltf.getNodeIndex(self.output, mNode), 'rotation'))
            samplers.append(gltf.createAnimSampler(self.output, self.binary,
                    keys, values, 4, interp))
            samplerIdx += 1

        if cmds.keyframe(mNode, attribute='scale', query=True, keyframeCount=True) or forcedAnim:
            keys, values, interp = extractAnimData(mNode, 'scale', startWithZero, customRange)

            channels.append(gltf.createAnimChannel(samplerIdx,
                    gltf.getNodeIndex(self.output, mNode), 'scale'))
            samplers.append(gltf.createAnimSampler(self.output, self.binary,
                    keys, values, 3, interp))
            samplerIdx += 1

        shapeTargets = mayaUtils.getBlendShapeTargets(mNode, False)
        for target in shapeTargets:
            if cmds.keyframe(target['blendShape'], attribute=target['name'], query=True, keyframeCount=True):
                keys, values, interp = extractAnimData(mNode, 'weights', startWithZero, customRange)

                channels.append(gltf.createAnimChannel(samplerIdx,
                        gltf.getNodeIndex(self.output, mNode), 'weights'))
                samplers.append(gltf.createAnimSampler(self.output, self.binary,
                        keys, values, 1, interp))
                samplerIdx += 1

                break   # got weights for all shape targets

        shape = mayaUtils.getFirstShape(mNode)
        if shape and isMesh(shape):
            for group in mayaUtils.getUniqueShadingGroups(shape):
                animNodes = matNodes.extractAnimNodes(group)
                for animNode in animNodes:
                    keys, values, interp = extractAnimData(animNode, '', startWithZero, customRange)

                    channel = gltf.createAnimChannel(samplerIdx,
                            gltf.getNodeIndex(self.output, mNode), 'material.nodeValue["' + animNode + '"]')

                    groupMaterials = cmds.ls(cmds.listConnections(group), materials=True)
                    mMat = groupMaterials[0] if groupMaterials else None
                    if mMat:
                        materialIndex = gltf.getMaterialIndex(self.output, mMat)
                        if materialIndex == -1:
                            materialIndex = gltf.getMaterialIndex(self.output, getName(shape) + '_' + mMat)

                        channel['target']['extras'] = {
                            'material': materialIndex
                        }

                    channels.append(channel)

                    samplers.append(gltf.createAnimSampler(self.output, self.binary,
                            keys, values, 1, interp))

                    samplerIdx += 1

        motionPath = mayaUtils.getMotionPath(mNode, True)
        if motionPath:
            keys, values, interp = extractAnimData(mNode, 'motionPath', startWithZero, customRange)

            channels.append(gltf.createAnimChannel(samplerIdx,
                    gltf.getNodeIndex(self.output, mNode), 'constraint["' + motionPath + '"].value'))
            samplers.append(gltf.createAnimSampler(self.output, self.binary,
                    keys, values, 1, interp))

            samplerIdx += 1

        if len(channels) and len(samplers):
            animation = {
                'name': getName(animProxyNode),
                'channels': channels,
                'samplers': samplers
            }

            v3dExt = gltf.appendExtension(self.output, 'S8S_v3d_animation', animation)

            v3dExt['auto'] = animSettings['animAuto']
            v3dExt['loop'] = animSettings['animLoop']

            v3dExt['repeatInfinite'] = animSettings['animRepeatInfinite']
            v3dExt['repeatCount'] = animSettings['animRepeatCount']
            v3dExt['offset'] = animSettings['animOffset']
        else:
            animation = None

        return animation

    def generateAnimations(self, collector):

        if not self.exportSettings['animExport']:
            return

        animations = []

        for mNode in collector.nodes:

            anim = self.createAnimation(collector, mNode)
            if anim:
                animations.append(anim)

        if len(animations) > 0:
            self.output['animations'] = gltf.mergeAnimations(self.output, animations)

    def generateBuffers(self):
        byteLength = len(self.binary)

        if byteLength > 0:
            buffer = {
                'byteLength' : byteLength
            }

            if self.exportSettings['format'] == 'ASCII':
                buffer['uri'] = self.exportSettings['binaryfilename']

            self.output['buffers'] = [buffer]

    def createCamera(self, mCamera):
        camera = {
            'name': getName(mCamera),
            'id': mCamera
        }

        near = cmds.camera(mCamera, query=True, nearClipPlane=True)
        far = cmds.camera(mCamera, query=True, farClipPlane=True)

        if cmds.camera(mCamera, query=True, orthographic=True):
            camera['type'] = 'orthographic'
            camera['orthographic'] = {}

            camera['orthographic']['xmag'] = cmds.camera(mCamera, query=True, orthographicWidth=True) / 2
            camera['orthographic']['ymag'] = camera['orthographic']['xmag']

            camera['orthographic']['znear'] = near
            camera['orthographic']['zfar'] = far
        else:
            camera['type'] = 'perspective'
            camera['perspective'] = {}
            camera['perspective']['aspectRatio'] = cmds.camera(mCamera, query=True, aspectRatio=True)
            camera['perspective']['yfov'] = math.radians(cmds.camera(mCamera, query=True, verticalFieldOfView=True))

            camera['perspective']['znear'] = near
            camera['perspective']['zfar'] = far

        camSettings = customAttrs.parseCameraSettings(mCamera)

        v3dExt = gltf.appendExtension(self.output, 'S8S_v3d_camera', camera)

        v3dExt['viewportFitType'] = cmds.camera(mCamera, q=True, ff=True).upper()
        v3dExt['viewportFitInitialAspect'] = cmds.camera(mCamera, q=True, aspectRatio=True)

        v3dExt['enablePan'] = camSettings['panningEnabled']
        v3dExt['rotateSpeed'] = camSettings['rotateSpeed']
        v3dExt['moveSpeed'] = camSettings['moveSpeed']

        v3dExt['controls'] = camSettings['controls']

        if camSettings['controls'] == 'ORBIT':
            coi = cmds.camera(mCamera, query=True, worldCenterOfInterest=True)
            v3dExt['orbitTarget'] = [coi[0], coi[1], coi[2]]

            v3dExt['orbitMinDistance'] = camSettings['minDist']
            v3dExt['orbitMaxDistance'] = camSettings['maxDist']
            v3dExt['orbitMinZoom'] = camSettings['minZoom']
            v3dExt['orbitMaxZoom'] = camSettings['maxZoom']
            v3dExt['orbitMinPolarAngle'] = camSettings['minAngle'] + math.pi/2
            v3dExt['orbitMaxPolarAngle'] = camSettings['maxAngle'] + math.pi/2

            minAzAngle = camSettings['minAzimuthAngle']
            maxAzAngle = camSettings['maxAzimuthAngle']

            if abs(2 * math.pi - (maxAzAngle - minAzAngle)) > CAM_ANGLE_EPSILON:
                v3dExt['orbitMinAzimuthAngle'] = minAzAngle
                v3dExt['orbitMaxAzimuthAngle'] = maxAzAngle

        elif camSettings['controls'] == 'FIRST_PERSON':
            v3dExt['fpsGazeLevel'] = camSettings['fpsGazeLevel']
            v3dExt['fpsStoryHeight'] = camSettings['fpsStoryHeight']
            v3dExt['enablePointerLock'] = camSettings['enablePointerLock']

        return camera

    def generateCameras(self, collector):
        cameras = []

        mCameras = collector.cameras

        if len(mCameras):
            for mCamera in mCameras:
                cameras.append(self.createCamera(mCamera))

            self.output['cameras'] = cameras

    def createDefaultLight(self):
        log.info('Generating default light')

        light = {
            'name': '__DEFAULT__',
            'profile': 'maya',
            'color': [1,1,1],
            'intensity': 2.5,
            'type': 'directional',
        }

        return light

    def createLight(self, mLight, shadowBox):

        light = {}
        light['name'] = getName(mLight)
        light['id'] = mLight
        light['profile'] = 'maya'

        lightType = cmds.objectType(mLight)

        if lightType == 'ambientLight':
            light['type'] = 'ambient'
            queryFun = cmds.ambientLight
        elif lightType == 'directionalLight':
            light['type'] = 'directional'
            queryFun = cmds.directionalLight
        elif lightType == 'pointLight':
            light['type'] = 'point'
            queryFun = cmds.pointLight
        elif lightType == 'spotLight':
            light['type'] = 'spot'
            queryFun = cmds.spotLight
        elif lightType == 'areaLight' or lightType == 'aiAreaLight':
            light['type'] = 'area'
            queryFun = mayaUtils.queryAreaLight
        else:
            log.error('Unknown light type: ' + lightType)

        light['color'] = queryFun(mLight, query=True, rgb=True)
        light['intensity'] = queryFun(mLight, query=True, intensity=True)

        if light['type'] == 'ambient':
            light['intensity'] *= math.pi

        if (light['type'] == 'point' or light['type'] == 'spot' or light['type'] == 'area'):

            light['decay'] = queryFun(mLight, query=True, decayRate=True)

        if light['type'] == 'spot':
            spotAngle = math.radians(queryFun(mLight, query=True, coneAngle=True))
            spotPenumbra = math.radians(queryFun(mLight, query=True, penumbra=True))

            light['angle'] = spotAngle / 2 + max(spotPenumbra, 0.0)
            light['penumbra'] = abs(spotPenumbra) / light['angle']

        elif light['type'] == 'area':

            lightTrans = mayaUtils.getTransformNode(mLight)

            width = cmds.getAttr(lightTrans + '.scaleX') * 2
            height = cmds.getAttr(lightTrans + '.scaleY') * 2

            light['width'] = width
            light['height'] = height

            if cmds.getAttr(mLight + '.normalize'):
                light['intensity'] /= (width * height)

            light['ltcMat1'] = pU.rawdata.ltcMat1
            light['ltcMat2'] = pU.rawdata.ltcMat2

        if light['type'] != 'ambient':

            if light['type'] == 'directional':
                shadowFov = 0
            elif light['type'] == 'point' or light['type'] == 'area':
                shadowFov = math.pi / 2
            elif light['type'] == 'spot':
                shadowFov = light['angle'] * 2

            if light['type'] in ['point', 'spot', 'area']:

                trans = mayaUtils.getNodeWorldTranslation(mLight)
                cameraNear = SPOT_SHADOW_MIN_NEAR
                cameraFar = pU.math.clamp(
                        mayaUtils.calcBoundBoxFarthestDistanceFromPoint(shadowBox, trans),
                        SPOT_SHADOW_MIN_NEAR, SHADOW_MAX_FAR)
                orthoLeft = -1
                orthoRight = 1
                orthoBottom = -1
                orthoTop = 1

            else:

                shadowBoxTrans = mayaUtils.transformBoundingBox(shadowBox,
                    cmds.listRelatives(mLight, parent=True)[0])
                cameraNear = -shadowBoxTrans[5]
                cameraFar = -shadowBoxTrans[2] * SHADOW_BB_FAR_COEFF

                orthoLeft = shadowBoxTrans[0]
                orthoRight = shadowBoxTrans[3]
                orthoBottom = shadowBoxTrans[1]
                orthoTop = shadowBoxTrans[4]

            if (light['type'] == 'spot' or light['type'] == 'point') and cameraNear < SPOT_SHADOW_MIN_NEAR:
                cameraNear = SPOT_SHADOW_MIN_NEAR

            lightSettings = customAttrs.parseLightSettings(mLight)

            if lightType != 'aiAreaLight':
                shadowEnabled = cmds.getAttr(mLight + '.useDepthMapShadows')
                shadowMapSize = int(cmds.getAttr(mLight + '.dmapResolution'))
                shadowFilterSize = cmds.getAttr(mLight + '.dmapFilterSize')
                shadowBias = cmds.getAttr(mLight + '.dmapBias')
            else:
                shadowEnabled = cmds.getAttr(mLight + '.aiCastShadows')
                shadowMapSize = int(cmds.getAttr(mLight + '.aiResolution'))
                shadowFilterSize = cmds.getAttr(mLight + '.aiSamples')
                shadowBias = 0.001

            light['shadow'] = {
                'enabled': shadowEnabled,
                'mapSize': shadowMapSize,

                'cameraOrthoLeft': orthoLeft,
                'cameraOrthoRight': orthoRight,
                'cameraOrthoBottom': orthoBottom,
                'cameraOrthoTop': orthoTop,

                'cameraNear': cameraNear,
                'cameraFar': cameraFar,
                'cameraFov': shadowFov,
                'radius': pU.math.clamp(shadowFilterSize-1, 0, 3),
                'bias': -shadowBias,

                'slopeScaledBias': 2.5,

                'expBias': lightSettings['esmExponent'],
            }

            if light['type'] == 'directional' and 'csmCount' in lightSettings:
                light['shadow']['csm'] = {
                    'count': lightSettings['csmCount'],
                    'fade': lightSettings['csmFade'],
                    'exponent': lightSettings['csmDistribution'],
                    'maxDistance': cameraFar,
                    'lightMargin': lightSettings['csmLightMargin'],
                }

        return light

    def generateLights(self, collector):

        lights = []

        mLights = collector.lights

        if len(mLights):

            for mLight in mLights:
                lights.append(self.createLight(mLight, collector.shadowBox))
        elif len(collector.skyDomeLights) == 0:
            lights.append(self.createDefaultLight())

        gltf.appendExtension(self.output, 'S8S_v3d_lights', self.output, {'lights': lights})

    def createDefaultLightNode(self, cameraNode):
        node = {}

        node['name'] = '__DEFAULT_LIGHT__'

        node['translation'] = cameraNode['translation'][:]
        node['rotation'] = mayaUtils.angleAxisToQuat(math.radians(-15), [1, 0, 0])

        light = gltf.getLightIndex(self.output, '__DEFAULT__')
        if light >= 0:
            gltf.appendExtension(self.output, 'S8S_v3d_lights', node, {'light': light})

        return node

    def createLightProbe(self, mLightProbe):
        probe = {}
        probe['name'] = getName(mLightProbe)
        probe['id'] = mLightProbe

        if cmds.objectType(mLightProbe) == 'v3dReflectionCubemap':
            probe['type'] = 'SPHERE'

            influenceType = rc.getVolumeTypeName(cmds.getAttr(mLightProbe
                    + '.influenceType'))
            parallaxType = rc.getVolumeTypeName(cmds.getAttr(mLightProbe
                    + '.parallaxType'))

            probe['influenceType'] = influenceType
            probe['parallaxType'] = (parallaxType
                    if cmds.getAttr(mLightProbe + '.useCustomParallax')
                    else influenceType)

            probe['influenceDistance'] = cmds.getAttr(mLightProbe + '.influenceDistance')
            probe['parallaxDistance'] = (cmds.getAttr(mLightProbe + '.parallaxDistance')
                    if cmds.getAttr(mLightProbe + '.useCustomParallax')
                    else cmds.getAttr(mLightProbe + '.influenceDistance'))

            probe['intensity'] = cmds.getAttr(mLightProbe + '.intensity')
            probe['clipStart'] = cmds.getAttr(mLightProbe + '.clipStart')
            probe['clipEnd'] = cmds.getAttr(mLightProbe + '.clipEnd')

            visibilitySetConn = cmds.listConnections(mLightProbe
                    + '.visibilitySelectionSet', type='objectSet')
            probe['visibilityGroup'] = (visibilitySetConn[0]
                    if visibilitySetConn is not None else None)
            probe['visibilityGroupInv'] = cmds.getAttr(mLightProbe
                    + '.invertVisibilitySelectionSet')

            influenceSetConn = cmds.listConnections(mLightProbe
                    + '.influenceSelectionSet', type='objectSet')
            probe['influenceGroup'] = (influenceSetConn[0]
                    if (cmds.getAttr(mLightProbe + '.useCustomInfluence')
                    and influenceSetConn is not None) else None)
            probe['influenceGroupInv'] = cmds.getAttr(mLightProbe
                    + '.invertInfluenceSelectionSet')
        elif cmds.objectType(mLightProbe) == 'v3dReflectionPlane':
            probe['type'] = 'PLANE'

            probe['influenceDistance'] = cmds.getAttr(mLightProbe + '.influenceDistance')
            probe['falloff'] = cmds.getAttr(mLightProbe + '.falloff')
            probe['clipStart'] = cmds.getAttr(mLightProbe + '.clipStart')

            visibilitySetConn = cmds.listConnections(mLightProbe
                    + '.visibilitySelectionSet', type='objectSet')
            probe['visibilityGroup'] = (visibilitySetConn[0]
                    if visibilitySetConn is not None else None)
            probe['visibilityGroupInv'] = cmds.getAttr(mLightProbe
                    + '.invertVisibilitySelectionSet')

        return probe

    def generateLightProbes(self, collector):
        probes = []
        for mProbe in collector.lightProbes:
            probes.append(self.createLightProbe(mProbe))

        if len(probes):
            gltf.appendExtension(self.output, 'S8S_v3d_light_probes', self.output,
                    {'lightProbes': probes})

    def createClippingPlane(self, mPlane):

        plane = {}
        plane['name'] = getName(mPlane)
        plane['id'] = mPlane

        affectedObjs = clippingPlane.getAffectedObjectsSet(mPlane)
        plane['clippingGroup'] = getName(affectedObjs) if affectedObjs else None
        plane['negated'] = cmds.getAttr(mPlane + '.negated')
        plane['clipShadows'] = cmds.getAttr(mPlane + '.clipShadows')

        unionPlanes = cmds.getAttr(mPlane + '.unionPlanes')
        plane['clipIntersection'] = not unionPlanes
        plane['crossSection'] = cmds.getAttr(mPlane + '.crossSection') if unionPlanes else False

        crossSectionColor = cmds.getAttr(mPlane + '.crossSectionColor')[0]
        plane['color'] = crossSectionColor
        plane['opacity'] = 1
        plane['renderSide'] = clippingPlane.getRenderSideName(
                cmds.getAttr(mPlane + '.crossSectionRenderSide'))
        plane['size'] = cmds.getAttr(mPlane + '.crossSectionSize')

        return plane

    def generateClippingPlanes(self, collector):
        planes = []
        for mPlane in collector.clippingPlanes:
            planes.append(self.createClippingPlane(mPlane))

        if len(planes):
            gltf.appendExtension(self.output, 'S8S_v3d_clipping_planes', self.output,
                    {'clippingPlanes': planes})

    def createImage(self, filePath, qimage=None, compressionMethod=None):
        fileNameExp = os.path.basename(filePath)

        if qimage:
            base, ext = os.path.splitext(filePath)
            mimeSuffix = ext.lower()[1:]
            if mimeSuffix == 'jpg':
                mimeSuffix = 'jpeg'

            writer = QImageWriter(filePath, mimeSuffix.encode('utf-8'));
            writer.write(qimage);
            del writer

        mimeType = gltf.imageMimeType(filePath)

        image = {
            'id': filePath,
            'mimeType': mimeType
        }

        tmpImg = None

        if not gltf.isCompatibleImagePath(filePath):
            img = OpenMaya.MImage()
            img.readFromFile(filePath)

            log.info('Converting image file: ' + fileNameExp)

            tmpImg = tempfile.NamedTemporaryFile(delete=False, suffix='.png')

            filePath = tmpImg.name
            fileNameExp = os.path.splitext(fileNameExp)[0] + '.png'

            img.writeToFile(filePath, 'png')

        if self.exportSettings['format'] == 'ASCII':
            if not qimage:
                destPath = join(self.exportSettings['filedirectory'], fileNameExp)

                if compressionMethod != None:
                    if mimeType == 'image/vnd.radiance':
                        destPath += '.xz'
                        fileNameExp += '.xz'
                        pU.convert.compressLZMA(filePath, dstPath=destPath)
                        image['mimeType'] = 'application/x-xz'
                    else:
                        destPath += '.ktx2'
                        fileNameExp += '.ktx2'
                        pU.convert.compressKTX2(filePath, dstPath=destPath, method=compressionMethod)
                        image['mimeType'] = 'image/ktx2'

                elif os.path.normcase(filePath) != os.path.normcase(destPath):
                    shutil.copyfile(filePath, destPath)

            image['uri'] = fileNameExp

        else:
            with open(filePath, 'rb') as f:
                imgBytes = f.read()

            if compressionMethod != None:
                if mimeType == 'image/vnd.radiance':
                    imgBytes = lzma.compress(imgBytes)
                    image['mimeType'] = 'application/x-xz'
                else:
                    imgBytes = pU.convert.compressKTX2(srcData=imgBytes, method=compressionMethod)
                    image['mimeType'] = 'image/ktx2'

            if qimage:
                os.remove(filePath)

            bufferView = gltf.generateBufferView(self.output, self.binary, imgBytes, 0, 0)
            image['bufferView'] = bufferView

        if tmpImg:
            tmpImg.close()
            os.unlink(tmpImg.name)

        return image

    def generateImages(self, collector):

        images = []

        for mTex in collector.textures:
            texPath = extractTexturePath(mTex)
            texSettings = customAttrs.parseTextureSettings(mTex)
            compressionMethod = mayaUtils.getCompressionMethod(mTex, self.exportSettings, texSettings)
            try:
                image = self.createImage(texPath, compressionMethod=compressionMethod)
            except pU.convert.CompressionFailed:
                image = self.createImage(texPath, compressionMethod=None)

            images.append(image)

        if len(images) > 0:
            self.output['images'] = images

    def createTextureFromImage(self, image,
                               magFilter=gltf.WEBGL_FILTERS['LINEAR'],
                               wrapS=gltf.WEBGL_WRAPPINGS['REPEAT'],
                               wrapT=gltf.WEBGL_WRAPPINGS['REPEAT']):

        texture = {
            'name': os.path.basename('image'),
            'id': image['id'],
            'sampler': gltf.createSampler(self.output, magFilter, wrapS, wrapT),
            'source': gltf.getImageIndex(self.output, image['id'])
        }

        return texture

    def generateTextures(self, collector):
        textures = []
        for mTex in collector.textures:

            texture = {}

            texture['name'] = getName(mTex)
            texture['id'] = mTex

            magFilter = gltf.WEBGL_FILTERS['LINEAR']
            if not cmds.objectType(mTex) == 'aiImage':
                if cmds.getAttr(mTex + '.filterType') == 0:
                    magFilter = gltf.WEBGL_FILTERS['NEAREST']

            wrapS = gltf.WEBGL_WRAPPINGS['REPEAT']
            wrapT = gltf.WEBGL_WRAPPINGS['REPEAT']

            wrapInfo = mayaUtils.getUvWrapInfo(mTex)

            if wrapInfo['mirrorU']:
                wrapS = gltf.WEBGL_WRAPPINGS['MIRRORED_REPEAT']
            elif not wrapInfo['wrapU']:
                wrapS = gltf.WEBGL_WRAPPINGS['CLAMP_TO_EDGE']

            if wrapInfo['mirrorV']:
                wrapT = gltf.WEBGL_WRAPPINGS['MIRRORED_REPEAT']
            elif not wrapInfo['wrapV']:
                wrapT = gltf.WEBGL_WRAPPINGS['CLAMP_TO_EDGE']

            texture['sampler'] = gltf.createSampler(self.output, magFilter, wrapS, wrapT)

            v3dExt = gltf.appendExtension(self.output, 'S8S_v3d_texture', texture)

            uri = extractTexturePath(mTex)

            imgIndex = gltf.getImageIndex(self.output, uri)
            if imgIndex >= 0:
                mimeType = self.output['images'][imgIndex]['mimeType']

                if mimeType == 'image/ktx2':
                    gltf.appendExtension(self.output, 'KHR_texture_basisu', texture, { 'source' : imgIndex })
                elif mimeType in ['image/vnd.radiance', 'application/x-xz']: # HDR or compressed HDR
                    v3dExt['source'] = imgIndex
                else:
                    texture['source'] = imgIndex

            v3dExt['colorSpace'] = mayaUtils.extractTextureColorSpace(mTex)

            texSettings = customAttrs.parseTextureSettings(mTex)

            anisotropy = texSettings['anisotropy']
            if anisotropy > 1:
                v3dExt['anisotropy'] = anisotropy

            textures.append(texture)

        if len(textures) > 0:
            self.output['textures'] = textures

    def createMaterial(self, mMat, shape=None):
        material = {}

        material['name'] = getName(mMat)

        if shape:
            material['id'] = shape + '_' + mMat
        else:
            material['id'] = mMat

        matSettings = customAttrs.parseMaterialSettings(mMat)

        isPbr = matSettings['gltfCompat']
        if isPbr:
            material['pbrMetallicRoughness'] = {}
            pbr = material['pbrMetallicRoughness']

            matType = cmds.objectType(mMat)

            if matType == 'aiStandardSurface' or matType == 'standardSurface':

                weight = cmds.getAttr(mMat + '.base')

                occlusionTex1 = mayaUtils.extractTexture(mMat, 'base')
                occlusionTex2 = mayaUtils.extractTexture(mMat, 'specular')
                if occlusionTex1 and occlusionTex1 == occlusionTex2:
                    texIndex = gltf.getTextureIndex(self.output, occlusionTex1)
                    material['occlusionTexture'] = {'index': texIndex}

                    texCoord = mayaUtils.extractTexCoordIndex(occlusionTex1, shape)
                    if texCoord > 0:
                        material['occlusionTexture']['texCoord'] = texCoord

                if occlusionTex1:
                    weight = 1

                tex = extractTexture(mMat, 'baseColor')
                if tex:
                    pbr['baseColorTexture'] =  {'index': gltf.getTextureIndex(self.output, tex)}

                    texCoord = mayaUtils.extractTexCoordIndex(tex, shape)
                    if texCoord > 0:
                        pbr['baseColorTexture']['texCoord'] = texCoord

                    pbr['baseColorFactor'] = [weight, weight, weight, 1]
                else:
                    color = list(cmds.getAttr(mMat + '.baseColor')[0])
                    color = [v * weight for v in color]

                    opacity = list(cmds.getAttr(mMat + '.opacity')[0])
                    opacity = sum(opacity) / float(len(opacity))
                    color.append(opacity)

                    pbr['baseColorFactor'] = color

                metallicTex = extractTexture(mMat, 'metalness')
                roughnessTex = extractTexture(mMat, 'specularRoughness')

                if metallicTex and metallicTex == roughnessTex:
                    texIndex = gltf.getTextureIndex(self.output, metallicTex)
                    pbr['metallicRoughnessTexture'] = {'index': texIndex}

                    texCoord = mayaUtils.extractTexCoordIndex(metallicTex, shape)
                    if texCoord > 0:
                        pbr['metallicRoughnessTexture']['texCoord'] = texCoord

                    pbr['metallicFactor'] = 1
                    pbr['roughnessFactor'] = 1
                else:
                    pbr['metallicFactor'] = cmds.getAttr(mMat + '.metalness')
                    pbr['roughnessFactor'] = cmds.getAttr(mMat + '.specularRoughness')

                normalTex = extractTexture(mMat, 'normalCamera', 'bumpValue')
                if normalTex:
                    material['normalTexture'] = {'index': gltf.getTextureIndex(self.output, normalTex)}

                    texCoord = mayaUtils.extractTexCoordIndex(normalTex, shape)
                    if texCoord > 0:
                        material['normalTexture']['texCoord'] = texCoord

                    scale = cmds.getAttr(getInputNode(mMat, 'normalCamera') + '.bumpDepth')
                    if scale != 1:
                        material['normalTexture']['scale'] = scale

                emissiveWeight = cmds.getAttr(mMat + '.emission')

                emissiveTex = extractTexture(mMat, 'emissionColor')
                if emissiveTex:
                    material['emissiveTexture'] = {'index': gltf.getTextureIndex(self.output, emissiveTex)}

                    texCoord = mayaUtils.extractTexCoordIndex(emissiveTex, shape)
                    if texCoord > 0:
                        material['emissiveTexture']['texCoord'] = texCoord

                    material['emissiveFactor'] = [emissiveWeight, emissiveWeight, emissiveWeight]
                else:
                    emissive = list(cmds.getAttr(mMat + '.emissionColor')[0])
                    emissive = [v * emissiveWeight for v in emissive]
                    material['emissiveFactor'] = emissive

            elif matType == 'lambert':
                trans = list(cmds.getAttr(mMat + '.transparency')[0])
                transparency = sum(trans) / float(len(trans))

                tex = extractTexture(mMat, 'color')
                if tex:
                    pbr['baseColorTexture'] =  {'index': gltf.getTextureIndex(self.output, tex)}

                    texCoord = mayaUtils.extractTexCoordIndex(tex, shape)
                    if texCoord > 0:
                        pbr['baseColorTexture']['texCoord'] = texCoord

                else:
                    color = list(cmds.getAttr(mMat + '.color')[0])
                    color.append(1 - transparency)
                    pbr['baseColorFactor'] = color

                pbr['metallicFactor'] = 0
                pbr['roughnessFactor'] = 1

            elif matType == 'usdPreviewSurface':

                diffuseTex = extractTexture(mMat, 'diffuseColor')
                if diffuseTex:
                    pbr['baseColorTexture'] =  {'index': gltf.getTextureIndex(self.output, diffuseTex)}
                    texCoord = mayaUtils.extractTexCoordIndex(diffuseTex, shape)
                    if texCoord > 0:
                        pbr['baseColorTexture']['texCoord'] = texCoord
                else:
                    color = list(cmds.getAttr(mMat + '.diffuseColor')[0])
                    opacity = cmds.getAttr(mMat + '.opacity')
                    color.append(opacity)
                    pbr['baseColorFactor'] = color

                opacityThreshold = cmds.getAttr(mMat + '.opacityThreshold')
                material['alphaCutoff'] = opacityThreshold + ALPHA_CUTOFF_EPS

                occlusionTex = mayaUtils.extractTexture(mMat, 'occlusion')
                if occlusionTex:
                    texIndex = gltf.getTextureIndex(self.output, occlusionTex)
                    material['occlusionTexture'] = {'index': texIndex}

                    texCoord = mayaUtils.extractTexCoordIndex(occlusionTex, shape)
                    if texCoord > 0:
                        material['occlusionTexture']['texCoord'] = texCoord

                metallicTex = extractTexture(mMat, 'metallic')
                roughnessTex = extractTexture(mMat, 'roughness')
                if metallicTex and metallicTex == roughnessTex:
                    texIndex = gltf.getTextureIndex(self.output, metallicTex)
                    pbr['metallicRoughnessTexture'] = {'index': texIndex}

                    texCoord = mayaUtils.extractTexCoordIndex(metallicTex, shape)
                    if texCoord > 0:
                        pbr['metallicRoughnessTexture']['texCoord'] = texCoord

                    pbr['metallicFactor'] = 1
                    pbr['roughnessFactor'] = 1
                else:
                    pbr['metallicFactor'] = cmds.getAttr(mMat + '.metallic')
                    pbr['roughnessFactor'] = cmds.getAttr(mMat + '.roughness')

                normalTex = extractTexture(mMat, 'normal')
                if normalTex:
                    material['normalTexture'] = {'index': gltf.getTextureIndex(self.output, normalTex)}

                    texCoord = mayaUtils.extractTexCoordIndex(normalTex, shape)
                    if texCoord > 0:
                        material['normalTexture']['texCoord'] = texCoord

                emissiveTex = extractTexture(mMat, 'emissiveColor')
                if emissiveTex:
                    material['emissiveTexture'] = {'index': gltf.getTextureIndex(self.output, emissiveTex)}
                    texCoord = mayaUtils.extractTexCoordIndex(emissiveTex, shape)
                    if texCoord > 0:
                        material['emissiveTexture']['texCoord'] = texCoord

                    material['emissiveFactor'] = [1, 1, 1]
                else:
                    emissive = list(cmds.getAttr(mMat + '.emissiveColor')[0])
                    material['emissiveFactor'] = emissive

            elif matType == 'StingrayPBS':
                tex = extractTexture(mMat, 'TEX_color_map')
                if tex and cmds.getAttr(mMat + '.use_color_map'):
                    pbr['baseColorTexture'] =  {'index': gltf.getTextureIndex(self.output, tex)}
                else:
                    color = list(cmds.getAttr(mMat + '.base_color')[0]) + [1]
                    pbr['baseColorFactor'] = color

                pbr['metallicFactor'] = cmds.getAttr(mMat + '.metallic')
                pbr['roughnessFactor'] = cmds.getAttr(mMat + '.roughness')

        alphaMode = mayaUtils.extractAlphaMode(mMat, matSettings)
        if alphaMode != 'OPAQUE':
            material['alphaMode'] = alphaMode

        if matSettings['twoSided']:
            material['doubleSided'] = True

        if not isPbr:
            v3dExt = gltf.appendExtension(self.output, 'S8S_v3d_materials', material)

            v3dExt['profile'] = 'maya'

            v3dExt['nodeGraph'] = matNodes.extractNodeGraph(matNodes.nodeGraphSG(mMat), self.output, shape)

            if matSettings['alphaMode'] == 'BLEND':
                if matSettings['transparencyHack'] == 'NEAREST_LAYER':
                    v3dExt['depthPrepass'] = True
                elif matSettings['transparencyHack'] == 'TWO_PASS' and matSettings['twoSided']:
                    v3dExt['renderSide'] = 'TWO_PASS_DOUBLE';
            elif matSettings['alphaMode'] == 'COVERAGE':
                v3dExt['alphaToCoverage'] = True
            elif matSettings['alphaMode'] == 'ADD':
                blendMode = gltf.createBlendMode('FUNC_ADD', 'ONE', 'ONE')
                v3dExt['blendMode'] = blendMode

            v3dExt['gtaoVisible'] = matSettings['alphaMode'] != 'BLEND'

            if alphaMode != 'OPAQUE' and not matSettings['depthWrite']:
                v3dExt['depthWrite'] = False

            if not matSettings['depthTest']:
                v3dExt['depthTest'] = False

            if matSettings['dithering']:
                v3dExt['dithering'] = True

        return material

    def generateMaterials(self, collector):

        materials = []

        for mMat in collector.materials:
            matIdx = collector.materials.index(mMat)
            shapes = mayaUtils.extractTexCoordShapeRefs(collector.materialTextures[matIdx])

            if shapes:
                for shape in shapes:
                    material = self.createMaterial(mMat, shape)
                    materials.append(material)
            else:
                material = self.createMaterial(mMat)
                materials.append(material)

        activeCamera = collector.cameras[0]
        renderBackground = customAttrs.parseCameraSettings(activeCamera)['renderBackground']

        if renderBackground:
            bgColor = list(cmds.getAttr(activeCamera + '.backgroundColor')[0])
        else:
            bgColor = [pU.math.srgbToLinear(x) for x in cmds.displayRGBColor('background', query=True)]

        if len(collector.skyDomeLights):
            skyDomeLight = collector.skyDomeLights[0]
        else:
            skyDomeLight = None

        if skyDomeLight or set(bgColor) != {0}:
            materials.append(self.createWorldMaterial(skyDomeLight, bgColor))

        if len(materials) > 0:
            self.output['materials'] = materials

    def generateMeshes(self, collector):

        meshes = []

        for mMesh in collector.meshes:

            optimizeAttrs = self.exportSettings['optimizeAttrs']

            primitives = []
            targetWeights = []
            targetNames = []
            colorLayers = {}

            if mayaUtils.isNurbsCurve(mMesh) or mayaUtils.isBezierCurve(mMesh):

                lineSettings = customAttrs.parseLineSettings(mMesh)
                if lineSettings['enableLineRendering']:
                    lineSteps = lineSettings['lineResolutionSteps']
                    if mayaUtils.isNurbsCurve(mMesh):
                        meshData = extractNurbsCurveData(mMesh, lineSteps)
                    elif mayaUtils.isBezierCurve(mMesh):
                        meshData = extractBezierCurveData(mMesh, lineSteps)

                    if not meshData:
                        continue

                    indices = meshData['indices']
                    positions = meshData['positions']

                    if len(positions) >= 0xffff:
                        idxCompType = 'UNSIGNED_INT'
                    else:
                        idxCompType = 'UNSIGNED_SHORT'

                    indicesAccessor = gltf.generateAccessor(self.output, self.binary, indices,
                            idxCompType, len(indices), 'SCALAR', 'ELEMENT_ARRAY_BUFFER')

                    vcount = len(positions) // 3
                    positionAccessor = gltf.generateAccessor(self.output, self.binary, positions,
                            'FLOAT', vcount, 'VEC3', 'ARRAY_BUFFER')

                    primitive = {
                        'mode': PRIMITIVE_MODE_LINES,
                        'indices' : indicesAccessor,
                        'attributes' : {
                            'POSITION' : positionAccessor
                        },
                    }

                    primitives.append(primitive)
                else:
                    continue
            else:
                shadingGrps = mayaUtils.getUniqueShadingGroups(mMesh)

                nurbsSurf = mayaUtils.getNurbsSurfaceOriginal(mMesh)
                lineSettings = customAttrs.parseLineSettings(nurbsSurf if nurbsSurf else mMesh)

                for group in shadingGrps:

                    groupMaterials = cmds.ls(cmds.listConnections(group), materials=True)
                    mMat = groupMaterials[0] if groupMaterials else None

                    materialIndex = gltf.getMaterialIndex(self.output, mMat)
                    if materialIndex == -1 and mMat is not None:
                        materialIndex = gltf.getMaterialIndex(self.output, getName(mMesh) + '_' + mMat)

                    mode = PRIMITIVE_MODE_TRI
                    if lineSettings and lineSettings['enableLineRendering']:
                        meshData = extractMeshDataLine(mMesh, group)
                        mode = PRIMITIVE_MODE_LINES
                    else:
                        extractTangents = (not optimizeAttrs
                                or mMat is not None and matNodes.hasNormalMap(mMat))
                        meshData = extractMeshData(mMesh, group, extractTangents)

                    if not meshData:
                        continue

                    indices = meshData['indices']
                    positions = meshData['positions']

                    if len(indices) == 0 or len(positions) == 0:
                        log.warning('Skipping empty shading group {}'.format(group))
                        continue

                    normals = meshData.get('normals')
                    uvs = meshData.get('uvs', [])
                    tangents = meshData.get('tangents')
                    targets = meshData.get('targets', [])
                    joints = meshData.get('joints')
                    weights = meshData.get('weights')
                    colors = meshData.get('colors', [])
                    colorLayersNames = meshData.get('colorLayerNames', [])

                    if len(positions) >= 0xffff:
                        idxCompType = 'UNSIGNED_INT'
                    else:
                        idxCompType = 'UNSIGNED_SHORT'

                    indicesAccessor = gltf.generateAccessor(self.output, self.binary, indices,
                            idxCompType, len(indices), 'SCALAR', 'ELEMENT_ARRAY_BUFFER')

                    vcount = len(positions) // 3

                    positionAccessor = gltf.generateAccessor(self.output, self.binary, positions,
                            'FLOAT', vcount, 'VEC3', 'ARRAY_BUFFER')

                    primitive = {
                        'mode': mode,
                        'indices' : indicesAccessor,
                        'attributes' : {
                            'POSITION' : positionAccessor,
                        }
                    }

                    if normals:
                        normalAccessor = gltf.generateAccessor(self.output, self.binary, normals,
                            'FLOAT', vcount, 'VEC3', 'ARRAY_BUFFER')
                        primitive['attributes']['NORMAL'] = normalAccessor

                    if materialIndex >= 0:
                        primitive['material'] = materialIndex

                    for uvMapIdx in range(len(uvs)):
                        texcoordAccessor = gltf.generateAccessor(self.output, self.binary, uvs[uvMapIdx],
                                'FLOAT', vcount, 'VEC2', 'ARRAY_BUFFER')
                        primitive['attributes']['TEXCOORD_' + str(uvMapIdx)] = texcoordAccessor

                    if tangents:
                        tangentAccessor = gltf.generateAccessor(self.output, self.binary, tangents,
                                'FLOAT', vcount, 'VEC4', 'ARRAY_BUFFER')
                        primitive['attributes']['TANGENT'] = tangentAccessor

                    for colorIdx in range(len(colors)):
                        vColorAccessor = gltf.generateAccessor(self.output, self.binary, colors[colorIdx],
                                'FLOAT', vcount, 'VEC4', 'ARRAY_BUFFER')
                        primitive['attributes']['COLOR_' + str(colorIdx)] = vColorAccessor
                        colorLayers[colorLayersNames[colorIdx]] = 'COLOR_' + str(colorIdx)

                    if targets:

                        primitive['targets'] = []

                        doTargetWeightsNames = True if len(targetWeights) == 0 else False

                        for target in targets:

                            positions = target['positions']
                            normals = target['normals']

                            positionAccessor = gltf.generateAccessor(self.output, self.binary, positions,
                                    'FLOAT', vcount, 'VEC3', 'ARRAY_BUFFER')
                            normalAccessor = gltf.generateAccessor(self.output, self.binary, normals,
                                    'FLOAT', vcount, 'VEC3', 'ARRAY_BUFFER')

                            primitive['targets'].append({
                                'POSITION': positionAccessor,
                                'NORMAL': normalAccessor
                            })

                            if doTargetWeightsNames:
                                targetWeights.append(target['weight'])
                                targetNames.append(target['name'])

                    if joints:

                        jointAccessor = gltf.generateAccessor(self.output, self.binary, joints,
                                'UNSIGNED_SHORT', vcount, 'VEC4', 'ARRAY_BUFFER')
                        primitive['attributes']['JOINTS_0'] = jointAccessor

                        weightAccessor = gltf.generateAccessor(self.output, self.binary, weights,
                                'FLOAT', vcount, 'VEC4', 'ARRAY_BUFFER')
                        primitive['attributes']['WEIGHTS_0'] = weightAccessor

                    primitives.append(primitive)

            mesh = {
                'name': getName(mMesh),
                'id': mMesh,
                'primitives' : primitives
            }

            v3dExt = {}

            if lineSettings and lineSettings['enableLineRendering']:
                v3dExt['lineColor'] = lineSettings['lineColor']
                v3dExt['lineWidth'] = lineSettings['lineWidth']

            if len(colorLayers) > 0:
                v3dExt['colorLayers'] = colorLayers

            if v3dExt:
                gltf.appendExtension(self.output, 'S8S_v3d_mesh', mesh, v3dExt)

            if targetWeights:
                mesh['weights'] = targetWeights
            if targetNames:
                mesh['extras'] = {
                    'targetNames': targetNames
                }

            meshes.append(mesh)

        if len(meshes) > 0:
            self.output['meshes'] = meshes

    def createFont(self, mType):

        name = cmds.getAttr(mType + '.currentFont')

        font = {
            'name': name,
            'id': name
        }

        style = cmds.getAttr(mType + '.currentStyle')

        bold = False
        italic = False

        if style == 'Bold':
            bold = True
        elif style == 'Italic':
            italic = True
        elif style == 'Bold Italic':
            bold = True
            italic = True

        filePath = sysfont.match_font(name, bold, italic)
        if filePath and os.path.splitext(filePath)[1] != '.ttc':
            fileNameExp = os.path.basename(filePath)
            mimeType = 'font/ttf'
        else:
            log.warning('Font {} not found, switching to Arial'.format(name))
            filePath = join(os.path.dirname(os.path.abspath(__file__)), '..', 'fonts', 'liberation_sans.woff')
            fileNameExp = 'liberation_sans.woff'
            mimeType = 'font/woff'

        if self.exportSettings['format'] == 'ASCII':
            destPath = join(self.exportSettings['filedirectory'], fileNameExp)
            if filePath != destPath:
                shutil.copyfile(filePath, destPath)
            font['uri'] = fileNameExp
        else:
            with open(filePath, 'rb') as f:
                fontBytes = f.read()

            bufferView = gltf.generateBufferView(self.output, self.binary, fontBytes, 0, 0)
            font['bufferView'] = bufferView
            font['mimeType'] = mimeType

        return font

    def generateFonts(self, collector):

        fonts = []

        for mMeshType in collector.curves:
            mType = mayaUtils.getMeshType(mMeshType)
            fonts.append(self.createFont(mType))

        if len(fonts) > 0:
            gltf.appendExtension(self.output, 'S8S_v3d_curves', self.output, { 'fonts': fonts })

    def generateCurves(self, collector):
        curves = []

        for mMeshType in collector.curves:

            mType = mayaUtils.getMeshType(mMeshType)

            curve = {
                'name': getName(mMeshType),
                'id': mMeshType,
                'type': 'font' # NOTE: currently only font curves supported
            }

            def extractTextInput(numStr):
                chars = []
                for num in numStr.split():
                    chars.append(chr(int(num, 16)))
                return ''.join(chars)

            curve['text'] = extractTextInput(cmds.getAttr(mType + '.textInput'))

            name = cmds.getAttr(mType + '.currentFont')
            fontIndex = gltf.getFontIndex(self.output, name)
            if fontIndex >= 0:
                curve['font'] = fontIndex

            typeExtrude = cmds.listConnections('type1.extrudeMessage')[0]

            curve['size'] = cmds.getAttr(mType + '.fontSize') * 0.88

            if cmds.getAttr(typeExtrude + '.enableExtrusion'):
                curve['height'] = cmds.getAttr(typeExtrude + '.extrudeDistance') * mayaUtils.getScaleFactor()
            else:
                curve['height'] = 0

            curve['curveSegments'] = cmds.getAttr(mType + '.curveResolution')

            if cmds.getAttr('typeExtrude1.enableOuterBevel'):
                curve['bevelThickness'] = cmds.getAttr(typeExtrude + '.bevelDistance') * mayaUtils.getScaleFactor()
                curve['bevelSize'] = cmds.getAttr(typeExtrude + '.bevelOffset') * mayaUtils.getScaleFactor()
                curve['bevelSegments'] = cmds.getAttr(typeExtrude + '.bevelDivisions')
            else:
                curve['bevelThickness'] = 0
                curve['bevelSize'] = 0
                curve['bevelSegments'] = 5

            alignX = cmds.getAttr(mType + '.alignmentMode')

            if alignX == 1:
                curve['alignX'] = 'left'
            elif alignX == 2:
                curve['alignX'] = 'center'
            elif alignX == 3:
                curve['alignX'] = 'right'
            else:
                log.warning('Unsupported font alignment: ' + str(alignX))
                curve['alignX'] = 'left'

            curve['alignY'] = 'topBaseline'

            shadingGrps = mayaUtils.getUniqueShadingGroups(mMeshType)
            if len(shadingGrps):
                groupMaterials = cmds.ls(cmds.listConnections(shadingGrps[0]), materials=True)
                mMat = groupMaterials[0] if groupMaterials else None

                materialIndex = gltf.getMaterialIndex(self.output, mMat)
                if materialIndex == -1 and mMat is not None:
                    materialIndex = gltf.getMaterialIndex(self.output, getName(mMeshType) + '_' + mMat)

                if materialIndex >= 0:
                    curve['material'] = materialIndex
                else:
                    log.warning('Material ' + mMat + ' not found')

            curves.append(curve)

        if len(curves) > 0:
            gltf.appendExtension(self.output, 'S8S_v3d_curves', self.output, { 'curves': curves })

    def generateSkins(self, collector):
        skins = []

        for mNode in collector.nodes:

            skinCluster = mayaUtils.getSkinCluster(mNode)
            if skinCluster:

                skin = {}

                mJoints = mayaUtils.getSkinJoints(skinCluster)

                invBindMatrices = []
                joints = []

                for mJoint in mJoints:

                    mat = cmds.getAttr(mJoint + '.bindPose')
                    if mat is None:
                        continue

                    jointMatrixInv = OpenMaya2.MMatrix(mat).inverse()

                    meshMatrix = OpenMaya2.MMatrix(cmds.getAttr(mNode + '.worldMatrix'))

                    jointMatrixInv = meshMatrix * jointMatrixInv

                    invBindMatrices.extend(mayaUtils.extractTransMatrix(jointMatrixInv))

                    nodeIdx = gltf.getNodeIndex(self.output, mJoint)
                    if nodeIdx > -1:
                        joints.append(nodeIdx)

                skin['inverseBindMatrices'] = gltf.generateAccessor(
                        self.output, self.binary, invBindMatrices,
                        'FLOAT', len(invBindMatrices) // 16, 'MAT4', None)
                skin['joints'] = joints

                skins.append(skin)

                skinnedNodeIdx = gltf.getNodeIndex(self.output, mNode)
                skinnedNode = self.output['nodes'][skinnedNodeIdx]
                skinnedMeshIdx = skinnedNode.get('mesh', -1)

                if skinnedMeshIdx > -1:
                    skinnedMesh = self.output['meshes'][skinnedMeshIdx]
                    meshCanBeSkinned = True

                    for prim in skinnedMesh['primitives']:
                        if ('JOINTS_0' not in prim['attributes']
                                or 'WEIGHTS_0' not in prim['attributes']):
                            meshCanBeSkinned = False
                            break

                    if meshCanBeSkinned:
                        skinnedNode['skin'] = len(skins) - 1

        if len(skins) > 0:
            self.output['skins'] = skins

    def createNode(self, mNode):

        node = {}

        node['name'] = getName(mNode)
        node['id'] = mNode

        mMatrix = OpenMaya2.MMatrix(cmds.getAttr(mNode + '.matrix'))
        trans, rot, scale = mayaUtils.decomposeMMatrix(mMatrix)

        if not pU.math.vec3IsIdentity(trans):
            node['translation'] = trans
        if not pU.math.quatIsIdentity(rot):
            node['rotation'] = rot
        if not pU.math.vec3IsAllOnes(scale):
            node['scale'] = scale

        v3dExt = {}

        childObjs = cmds.listRelatives(mNode, children=True, fullPath=True)
        if childObjs:
            for child in childObjs:
                if not self.exportSettings['bakeText'] and self.exportSettings['format'] != 'HTML' and mayaUtils.isMeshType(child):
                    curve = gltf.getCurveIndex(self.output, child)
                    if curve >= 0:
                        v3dCurveExt = gltf.appendExtension(self.output, 'S8S_v3d_curves', node)
                        v3dCurveExt['curve'] = curve

                        v3dExt['useShadows'] = cmds.getAttr(child + '.receiveShadows')
                        v3dExt['useCastShadows'] = cmds.getAttr(child + '.castsShadows')

                        meshSettings = customAttrs.parseMeshSettings(child)

                        v3dExt['renderOrder'] = meshSettings['renderOrder']
                        v3dExt['frustumCulling'] = meshSettings['frustumCulling']

                elif isMesh(child):
                    mesh = gltf.getMeshIndex(self.output, child)
                    if mesh >= 0:
                        node['mesh'] = mesh

                        v3dExt['useShadows'] = cmds.getAttr(child + '.receiveShadows')
                        v3dExt['useCastShadows'] = cmds.getAttr(child + '.castsShadows')

                        meshSettings = customAttrs.parseMeshSettings(child)

                        v3dExt['renderOrder'] = meshSettings['renderOrder']
                        v3dExt['frustumCulling'] = meshSettings['frustumCulling']

                elif mayaUtils.isNurbsCurve(child) or mayaUtils.isBezierCurve(child):
                    mesh = gltf.getMeshIndex(self.output, child)
                    if mesh >= 0:
                        node['mesh'] = mesh

                        v3dExt['useShadows'] = True
                        v3dExt['useCastShadows'] = True

                        v3dExt['renderOrder'] = 0
                        v3dExt['frustumCulling'] = True

                elif isCamera(child):
                    camera = gltf.getCameraIndex(self.output, child)
                    if camera >= 0:
                        node['camera'] = camera

                        v3dCamExt = gltf.getAssetExtension(self.output['cameras'][camera], 'S8S_v3d_camera')
                        if v3dCamExt and v3dCamExt['controls'] == 'FIRST_PERSON':

                            camSettings = customAttrs.parseCameraSettings(child)

                            mat = gltf.getMaterialIndex(self.output, camSettings['fpsCollisionMaterial'])
                            if mat >= 0:
                                v3dCamExt['fpsCollisionMaterial'] = mat

                elif isLight(child):
                    light = gltf.getLightIndex(self.output, child)
                    if light >= 0:
                        v3dLightExt = gltf.appendExtension(self.output, 'S8S_v3d_lights', node)
                        v3dLightExt['light'] = light

                elif isLightProbe(child):
                    lightProbe = gltf.getLightProbeIndex(self.output, child)
                    if lightProbe >= 0:
                        v3dProbeExt = gltf.appendExtension(self.output, 'S8S_v3d_light_probes', node)
                        v3dProbeExt['lightProbe'] = lightProbe

                elif isClippingPlane(child):
                    clippingPlane = gltf.getClippingPlaneIndex(self.output, child)
                    if clippingPlane >= 0:
                        v3dClipExt = gltf.appendExtension(self.output, 'S8S_v3d_clipping_planes', node)
                        v3dClipExt['clippingPlane'] = clippingPlane

        if not bool(cmds.ls(mNode, visible=True)):
            v3dExt['hidden'] = True

        advRenderSettings = customAttrs.parseAdvRenderSettings(mNode)
        if advRenderSettings['hidpiCompositing']:
            v3dExt['hidpiCompositing'] = True

        groupNames = mayaUtils.extractGroupNames(mNode) + mayaUtils.extractObjectSetNames(mNode)
        if len(groupNames):
            v3dExt['groupNames'] = groupNames

        customProps = mayaUtils.extractCustomProps(mNode)
        if customProps:
            node['extras'] = {
                'customProps': customProps
            }

        if v3dExt:
            gltf.appendExtension(self.output, 'S8S_v3d_node', node, v3dExt)

        return node

    def createNodeFromLocatorNode(self, mNode):
        node = {}

        node['name'] = getName(mNode)
        node['id'] = mNode

        locPos = cmds.getAttr(mNode + '.localPosition')[0]
        if not pU.math.vec3IsIdentity(locPos):
            node['translation'] = locPos

        v3dExt = {}

        if not bool(cmds.ls(mNode, visible=True)):
            v3dExt['hidden'] = True

        groupNames = mayaUtils.extractGroupNames(mNode) + mayaUtils.extractObjectSetNames(mNode)
        if len(groupNames):
            v3dExt['groupNames'] = groupNames

        if v3dExt:
            gltf.appendExtension(self.output, 'S8S_v3d_node', node, v3dExt)

        return node

    def generateNodes(self, collector):
        nodes = []

        for mNode in collector.nodes:
            node = self.createNode(mNode)
            nodes.append(node)

        for mNode in collector.locatorNodes:
            node = self.createNodeFromLocatorNode(mNode)
            nodes.append(node)

        self.output['nodes'] = nodes

        for mNode in collector.nodes:
            nodeIdx = gltf.getNodeIndex(self.output, mNode)
            if nodeIdx > -1:
                node = self.output['nodes'][nodeIdx]

                v3dExt = gltf.getAssetExtension(node, 'S8S_v3d_node')
                if v3dExt:
                    advRenderSettings = customAttrs.parseAdvRenderSettings(mNode)
                    constraints = mayaUtils.extractConstraints(self.output, mNode, advRenderSettings)
                    if len(constraints):
                        v3dExt['constraints'] = constraints

                children = []

                mChildren = cmds.listRelatives(mNode, children=True, fullPath=True)
                if mChildren:
                    for mChild in mChildren:
                        if mChild in collector.nodes or mChild in collector.locatorNodes:
                            children.append(gltf.getNodeIndex(self.output, mChild))

                if len(children):
                    node['children'] = children

        if gltf.getLightIndex(self.output, '__DEFAULT__') >= 0:
            for node in nodes:
                if 'camera' in node and node['camera'] == 0:
                    lightNode = self.createDefaultLightNode(node)

                    if 'children' in node:
                        node['children'].append(len(nodes))
                    else:
                        node['children'] = [len(nodes)]

                    nodes.append(lightNode)

    def getPostprocessingEffects(self, settings):
        ppEffects = []

        if settings['aoEnabled']:
            ppEffects.append({
                'type': 'gtao',
                'distance': settings['aoDistance'],
                'factor': settings['aoFactor'],
                'precision': settings['aoTracePrecision'],
                'bentNormals': settings['aoBentNormals']
            })

        if settings['outlineEnabled']:
            ppEffects.append({
                'type': 'outline',
                'edgeStrength': settings['edgeStrength'],
                'edgeGlow': settings['edgeGlow'],
                'edgeThickness': settings['edgeThickness'],
                'pulsePeriod': settings['pulsePeriod'],
                'visibleEdgeColor': settings['visibleEdgeColor'] + [1],
                'hiddenEdgeColor': settings['hiddenEdgeColor'] + [1],
                'renderHiddenEdge': settings['renderHiddenEdge']
            })

        return ppEffects

    def generateScenes(self, collector):
        scenes = []

        scene = {}
        scene['name'] = 'Scene'
        scene['nodes'] = []

        for mNode in collector.topLevelNodes:
            scene['nodes'].append(gltf.getNodeIndex(self.output, mNode))

        v3dExt = gltf.appendExtension(self.output, 'S8S_v3d_scene', scene)

        scene['extras'] = {
            'animFrameRate': getFPS(),
            'coordSystem': 'Y_UP_RIGHT'
        }

        settings = self.exportSettings

        worldMatIdx = gltf.getMaterialIndex(self.output, WORLD_NODE_MAT_NAME)
        if worldMatIdx >= 0:
            v3dExt['worldMaterial'] = worldMatIdx

        v3dExt['unitsScaleFactor'] = 1 / mayaUtils.getScaleFactor()

        v3dExt['useHDR'] = settings['useHDR']
        v3dExt['aaMethod'] = settings['aaMethod']
        v3dExt['useOIT'] = settings['useOIT']

        v3dExt['shadowMap'] = {
            'type': settings['shadowFiltering'],
            'renderReverseSided': False,
            'renderSingleSided': True,
            'esmDistanceScale': settings['esmDistanceScale'] / mayaUtils.getScaleFactor(),
        }

        ppEffects = self.getPostprocessingEffects(settings)
        if len(ppEffects):
            v3dExt['postprocessing'] = ppEffects

        if len(collector.skyDomeLights):
            v3dExt['pmremMaxTileSize'] = pU.math.clamp(getSkyDomeLightTexRes(
                    collector.skyDomeLights[0]), PMREM_SIZE_MIN, PMREM_SIZE_MAX)

        v3dExt['iblEnvironmentMode'] = settings['iblEnvironment']

        scenes.append(scene)

        if len(scenes) > 0:
            self.output['scenes'] = scenes

    def generateScene(self):
        self.output['scene'] = 0

    def createWorldMaterial(self, skyDomeLight, bgColor):

        if skyDomeLight:

            nodeGraph = matNodes.extractNodeGraph(skyDomeLight, self.output)

        else:

            nodes = []
            edges = []

            nodes.append({
                'name' : 'aiRaySwitchColor',
                'type' : 'RAY_SWITCH_AR',
                'inputs': [bgColor, [0,0,0], [0,0,0], [0,0,0], [0,0,0], [0,0,0]],
                'outputs': [[0,0,0], 0]
            })

            nodes.append({
                'name' : 'aiSkyDomeLightShapeColor',
                'type' : 'SKYDOME_LIGHT_AR',
                'inputs': [[0,0,0], 1],
                'outputs': [],
                'is_active_output': True
            })

            edges.append({
                'fromNode' : 0,
                'fromOutput' : 0,
                'toNode' : 1,
                'toInput' : 0
            })

            nodeGraph = { 'nodes': nodes, 'edges': edges }

        worldMat = {
            'name': WORLD_NODE_MAT_NAME,
            'id': WORLD_NODE_MAT_NAME,
            'extensions': {
                'S8S_v3d_materials': {
                    'profile': 'maya',
                    'nodeGraph': nodeGraph
                }
            }
        }

        gltf.appendExtension(self.output, 'S8S_v3d_materials')

        return worldMat

    def saveSceneState(self):
        self.selectionSave = OpenMaya.MSelectionList()
        OpenMaya.MGlobal.getActiveSelectionList(self.selectionSave)

        self.currentTimeSave = cmds.currentTime(query=True)

    def restoreSceneState(self):
        OpenMaya.MGlobal.setActiveSelectionList(self.selectionSave)

        cmds.currentTime(self.currentTimeSave, edit=True)

    #@profile(immediate=True)
    def run(self, settings, progressBar):
        self.saveSceneState()

        collector = Collector(settings)
        self.collector = collector

        progressBar.update(0, 'Verge3D: Collecting Data...')

        self.exportSettings = settings

        if not os.path.exists(settings['filedirectory']):
            os.makedirs(settings['filedirectory'])

        collector.collect()

        progressBar.update(10, 'Verge3D: Exporting Metadata...')

        self.generateAsset()
        self.generateImages(collector)
        self.generateTextures(collector)
        self.generateMaterials(collector)
        self.generateLights(collector)
        self.generateLightProbes(collector)
        self.generateClippingPlanes(collector)
        self.generateCameras(collector)
        self.generateFonts(collector)

        progressBar.update(20, 'Verge3D: Exporting Geometry...')

        self.generateMeshes(collector)
        self.generateCurves(collector)
        self.generateNodes(collector)

        progressBar.update(60, 'Verge3D: Exporting Skins and Animations...')

        self.generateSkins(collector)
        self.generateAnimations(collector)

        progressBar.update(90, 'Verge3D: Finalizing and Saving glTF...')

        self.generateScenes(collector)
        self.generateScene()
        self.generateBuffers()

        self.cleanupDataKeys(self.output)

        indent = None
        separators = separators=(',', ':')

        if (settings['format'] == 'ASCII' and not settings['strip']) or settings['sneakPeek']:
            indent = 4
            separators = separators=(', ', ' : ')

        if settings['format'] == 'ASCII':
            gltfEncoded = json.dumps(self.output, sort_keys=True, indent=indent,
                                     separators=separators, ensure_ascii=False)

            with open(settings['filepath'], 'w', encoding='utf8', newline='\n') as outfile:
                outfile.write(gltfEncoded)

            if not settings['sneakPeek'] and settings['lzmaEnabled']:
                pU.convert.compressLZMA(settings['filepath'])

            if len(self.binary):
                with open(settings['filedirectory'] + settings['binaryfilename'], 'wb') as outfile:
                    outfile.write(self.binary)

                if not settings['sneakPeek'] and settings['lzmaEnabled']:
                    pU.convert.compressLZMA(settings['filedirectory'] + settings['binaryfilename'])
        else:

            json_str = json.dumps(self.output, sort_keys=True, indent=indent,
                                  separators=separators, ensure_ascii=False)
            json_bin = bytearray(json_str.encode(encoding='utf8'))
            alignedLen = (len(json_bin) + 3) & ~3
            for i in range(alignedLen - len(json_bin)):
                json_bin.extend(b' ')

            bin_out = bytearray()

            file_length = 12 + 8 + len(json_bin)
            if len(self.binary):
                file_length += 8 + len(self.binary)

            bin_out.extend(struct.pack('<I', 0x46546C67)) # glTF in binary
            bin_out.extend(struct.pack('<I', 2)) # version number
            bin_out.extend(struct.pack('<I', file_length))
            bin_out.extend(struct.pack('<I', len(json_bin)))
            bin_out.extend(struct.pack('<I', 0x4E4F534A)) # JSON in binary
            bin_out += json_bin

            if len(self.binary):
                bin_out.extend(struct.pack('<I', len(self.binary)))
                bin_out.extend(struct.pack('<I', 0x004E4942)) # BIN in binary
                bin_out += self.binary

            if settings['format'] == 'BINARY':
                with open(settings['filepath'], 'wb') as outfile:
                    outfile.write(bin_out)

                if not settings['sneakPeek'] and settings['lzmaEnabled']:
                    pU.convert.compressLZMA(settings['filepath'])

            else: # HTML
                outfile = tempfile.NamedTemporaryFile(delete=False, suffix='.glb')
                outfile.write(bin_out)

                mayaname = os.path.splitext(os.path.basename(cmds.file(query=True, sceneName=True)))[0]
                title = mayaname.replace('_', ' ').title() or 'Maya scene exported to HTML'

                if self.exportSettings['copyright']:
                    title += ' (Copyright {})'.format(self.exportSettings['copyright'])

                try:
                    pU.convert.composeSingleHTML(settings['filepath'], outfile.name, title)
                except PermissionError:
                    log.error('Permission denied when attempting to access the engine runtime. '
                            'Make sure that you\'re not running the App Manager under different user.')
                    raise
                finally:
                    outfile.close()
                    os.unlink(outfile.name)

        collector.cleanup()
        self.restoreSceneState()

        progressBar.update(100, 'Verge3D: Done!')

    def cleanupDataKeys(self, output):
        """
        Remove "id" keys used in the exporter to assign entity indices
        """
        for key, val in output.items():
            if type(val) == list:
                for entity in val:
                    if 'id' in entity:
                        del entity['id']
            elif key == 'extensions' and 'S8S_v3d_lights' in val:
                self.cleanupDataKeys(val['S8S_v3d_lights'])
            elif key == 'extensions' and 'S8S_v3d_light_probes' in val:
                self.cleanupDataKeys(val['S8S_v3d_light_probes'])
            elif key == 'extensions' and 'S8S_v3d_curves' in val:
                self.cleanupDataKeys(val['S8S_v3d_curves'])
            elif key == 'extensions' and 'S8S_v3d_clipping_planes' in val:
                self.cleanupDataKeys(val['S8S_v3d_clipping_planes'])

        if 'materials' in output:
            for mat in output['materials']:
                nodeGraph = gltf.getNodeGraph(mat)
                if nodeGraph:
                    for matNode in nodeGraph['nodes']:
                        if 'id' in matNode:
                            del matNode['id']
