Esempio n. 1
0
def create_flip():
    name = "FlipX"
    # only create the material if we haven't already created it, then just grab it
    if name not in bpy.data.node_groups:
        # create a group
        test_group = bpy.data.node_groups.new(name, 'ShaderNodeTree')

    else:
        test_group = bpy.data.node_groups[name]
        for node in test_group.nodes:
            test_group.nodes.remove(node)
        for node in test_group.inputs:
            test_group.inputs.remove(node)
        for node in test_group.outputs:
            test_group.outputs.remove(node)

    # create group inputs
    group_inputs = test_group.nodes.new('NodeGroupInput')
    group_inputs.location = (-350, 0)
    test_group.inputs.new('NodeSocketVectorXYZ', 'in')

    # create group outputs
    group_outputs = test_group.nodes.new('NodeGroupOutput')
    group_outputs.location = (300, 0)
    test_group.outputs.new('NodeSocketVectorXYZ', 'out')

    split = test_group.nodes.new('ShaderNodeSeparateXYZ')
    split.label = "Split"
    test_group.links.new(group_inputs.outputs["in"], split.inputs[0])

    flip = test_group.nodes.new('ShaderNodeMath')
    flip.operation = 'MULTIPLY'
    test_group.links.new(split.outputs[0], flip.inputs[0])
    flip.inputs[1].default_value = -1.0

    join = test_group.nodes.new('ShaderNodeCombineXYZ')
    join.label = "Join"
    test_group.links.new(flip.outputs[0], join.inputs[0])
    test_group.links.new(split.outputs[1], join.inputs[1])
    test_group.links.new(split.outputs[2], join.inputs[2])

    # #link output
    test_group.links.new(join.outputs[0], group_outputs.inputs['out'])

    nodes_iterate(test_group, group_outputs)
    return test_group
Esempio n. 2
0
def create_material(in_dir, matname):

    print(f"Importing material {matname}")
    # only create the material if it doesn't exist in the blend file, then just grab it
    # but we overwrite its contents anyway
    if matname not in bpy.data.materials:
        b_mat = bpy.data.materials.new(matname)
    else:
        b_mat = bpy.data.materials[matname]

    fgm_path = os.path.join(in_dir, matname + ".fgm")
    # print(fgm_path)
    try:
        fgm_data = FgmFile()
        fgm_data.load(fgm_path)
    except FileNotFoundError:
        print(f"{fgm_path} does not exist!")
        return b_mat
    # base_index = fgm_data.textures[0].layers[1]
    # height_index = fgm_data.textures[1].layers[1]
    tree = get_tree(b_mat)
    output = tree.nodes.new('ShaderNodeOutputMaterial')
    principled = tree.nodes.new('ShaderNodeBsdfPrincipled')

    all_textures = [
        file for file in os.listdir(in_dir) if file.lower().endswith(".png")
    ]
    # map texture names to node
    tex_dic = {}
    for fgm_texture in fgm_data.textures:
        png_base = fgm_texture.name.lower()
        if "blendweights" in png_base or "warpoffset" in png_base:
            continue
        textures = [
            file for file in all_textures if file.lower().startswith(png_base)
        ]
        if not textures:
            png_base = png_base.lower().replace("_eyes", "").replace(
                "_fin", "").replace("_shell", "")
            textures = [
                file for file in all_textures
                if file.lower().startswith(png_base)
            ]
        if not textures:
            textures = [
                png_base + ".png",
            ]
        # print(textures)
        for png_name in textures:
            png_path = os.path.join(in_dir, png_name)
            b_tex = load_tex(tree, png_path)
            k = png_name.lower().split(".")[1]
            tex_dic[k] = b_tex

    # get diffuse and AO
    for diffuse_name in ("pdiffusetexture", "pbasediffusetexture",
                         "pbasecolourtexture", "pbasecolourandmasktexture",
                         "pdiffusealphatexture", "pdiffuse_alphatexture",
                         "palbinobasecolourandmasktexture"):
        # get diffuse
        if diffuse_name in tex_dic:
            diffuse = tex_dic[diffuse_name]
            # get AO
            for ao_name in ("paotexture", "pbasepackedtexture_03"):
                if ao_name in tex_dic:
                    ao = tex_dic[ao_name]
                    ao.image.colorspace_settings.name = "Non-Color"

                    # apply AO to diffuse
                    diffuse_premix = tree.nodes.new('ShaderNodeMixRGB')
                    diffuse_premix.blend_type = "MULTIPLY"
                    diffuse_premix.inputs["Fac"].default_value = .25
                    tree.links.new(diffuse.outputs[0],
                                   diffuse_premix.inputs["Color1"])
                    tree.links.new(ao.outputs[0],
                                   diffuse_premix.inputs["Color2"])
                    diffuse = diffuse_premix
                    break
            # get marking
            fur_names = [
                k for k in tex_dic.keys()
                if "marking" in k and "noise" not in k and "patchwork" not in k
            ]
            lut_names = [k for k in tex_dic.keys() if "pclut" in k]
            if fur_names and lut_names:
                marking = tex_dic[sorted(fur_names)[0]]
                lut = tex_dic[sorted(lut_names)[0]]
                marking.image.colorspace_settings.name = "Non-Color"

                # PZ LUTs usually occupy half of the texture, so scale the incoming greyscale coordinates so that
                # 1 lands in the center of the LUT
                scaler = tree.nodes.new('ShaderNodeMath')
                scaler.operation = "MULTIPLY"
                tree.links.new(marking.outputs[0], scaler.inputs[0])
                scaler.inputs[1].default_value = 0.5
                tree.links.new(scaler.outputs[0], lut.inputs[0])

                # apply AO to diffuse
                diffuse_premix = tree.nodes.new('ShaderNodeMixRGB')
                diffuse_premix.blend_type = "MIX"
                tree.links.new(diffuse.outputs[0],
                               diffuse_premix.inputs["Color1"])
                tree.links.new(lut.outputs[0], diffuse_premix.inputs["Color2"])
                tree.links.new(marking.outputs[0],
                               diffuse_premix.inputs["Fac"])
                diffuse = diffuse_premix
            #  link finished diffuse to shader
            tree.links.new(diffuse.outputs[0], principled.inputs["Base Color"])
            break

    if "pnormaltexture" in tex_dic:
        normal = tex_dic["pnormaltexture"]
        normal.image.colorspace_settings.name = "Non-Color"
        normal_map = tree.nodes.new('ShaderNodeNormalMap')
        tree.links.new(normal.outputs[0], normal_map.inputs[1])
        # normal_map.inputs["Strength"].default_value = 1.0
        tree.links.new(normal_map.outputs[0], principled.inputs["Normal"])

    # PZ - specularity?
    for spec_name in (
            "proughnesspackedtexture_02",
            "pspecularmaptexture_00",
    ):
        if spec_name in tex_dic:
            specular = tex_dic[spec_name]
            specular.image.colorspace_settings.name = "Non-Color"
            tree.links.new(specular.outputs[0], principled.inputs["Specular"])

    # PZ - roughness?
    for roughness_name in (
            "proughnesspackedtexture_01", ):  # "pspecularmaptexture_01" ?
        if roughness_name in tex_dic:
            roughness = tex_dic[roughness_name]
            roughness.image.colorspace_settings.name = "Non-Color"
            tree.links.new(roughness.outputs[0],
                           principled.inputs["Roughness"])

    # JWE dinos - metalness
    for metal_name in ("pbasepackedtexture_02", ):
        if metal_name in tex_dic:
            metal = tex_dic[metal_name]
            metal.image.colorspace_settings.name = "Non-Color"
            tree.links.new(metal.outputs[0], principled.inputs["Metallic"])

    # alpha
    alpha = None
    # JWE billboard: Foliage_Billboard
    if "pdiffusealphatexture" in tex_dic:
        alpha = tex_dic["pdiffusealphatexture"]
        alpha_pass = alpha.outputs[1]
    elif "pdiffuse_alphatexture" in tex_dic:
        alpha = tex_dic["pdiffuse_alphatexture"]
        alpha_pass = alpha.outputs[1]
    # PZ penguin
    elif "popacitytexture" in tex_dic:
        alpha = tex_dic["popacitytexture"]
        alpha_pass = alpha.outputs[0]
    elif "proughnesspackedtexture_00" in tex_dic and "Foliage_Clip" in fgm_data.shader_name:
        alpha = tex_dic["proughnesspackedtexture_00"]
        alpha_pass = alpha.outputs[0]
    # parrot: Metallic_Roughness_Clip -> 03
    elif "proughnesspackedtexture_03" in tex_dic and "Foliage_Clip" not in fgm_data.shader_name:
        alpha = tex_dic["proughnesspackedtexture_03"]
        alpha_pass = alpha.outputs[0]
    if alpha:
        # transparency
        b_mat.blend_method = "CLIP"
        b_mat.shadow_method = "CLIP"
        for attrib in fgm_data.attributes:
            if attrib.name.lower() == "palphatestref":
                b_mat.alpha_threshold = attrib.value[0]
                break
        transp = tree.nodes.new('ShaderNodeBsdfTransparent')
        alpha_mixer = tree.nodes.new('ShaderNodeMixShader')
        tree.links.new(alpha_pass, alpha_mixer.inputs[0])

        tree.links.new(transp.outputs[0], alpha_mixer.inputs[1])
        tree.links.new(principled.outputs[0], alpha_mixer.inputs[2])
        tree.links.new(alpha_mixer.outputs[0], output.inputs[0])
        alpha_mixer.update()
    # no alpha
    else:
        b_mat.blend_method = "OPAQUE"
        tree.links.new(principled.outputs[0], output.inputs[0])

    nodes_iterate(tree, output)
    return b_mat
Esempio n. 3
0
def create_material(in_dir, matname):

    print(f"Importing material {matname}")
    # only create the material if it doesn't exist in the blend file, then just grab it
    # but we overwrite its contents anyway
    if matname not in bpy.data.materials:
        mat = bpy.data.materials.new(matname)
    else:
        mat = bpy.data.materials[matname]

    fgm_path = os.path.join(in_dir, matname + ".fgm")
    # print(fgm_path)
    try:
        fgm_data = FgmFile()
        fgm_data.load(fgm_path)
    except FileNotFoundError:
        print(f"{fgm_path} does not exist!")
        return mat
    # base_index = fgm_data.textures[0].layers[1]
    # height_index = fgm_data.textures[1].layers[1]
    tree = get_tree(mat)
    output = tree.nodes.new('ShaderNodeOutputMaterial')
    principled = tree.nodes.new('ShaderNodeBsdfPrincipled')

    all_textures = [
        file for file in os.listdir(in_dir) if file.lower().endswith(".png")
    ]
    # map texture names to node
    tex_dic = {}
    for fgm_texture in fgm_data.textures:
        png_base = f"{matname}.{fgm_texture.name}".lower()
        if "blendweights" in png_base or "warpoffset" in png_base:
            continue
        textures = [
            file for file in all_textures if file.lower().startswith(png_base)
        ]
        if not textures:
            png_base = png_base.lower().replace("_eyes", "").replace(
                "_fin", "").replace("_shell", "")
            textures = [
                file for file in all_textures
                if file.lower().startswith(png_base)
            ]
        if not textures:
            textures = [
                png_base + ".png",
            ]
        # print(textures)
        for png_name in textures:
            png_path = os.path.join(in_dir, png_name)
            b_tex = load_tex(tree, png_path)
            k = png_name.lower().split(".")[1]
            tex_dic[k] = b_tex

    # get diffuse and AO
    for diffuse_name in ("pbasediffusetexture", "pbasecolourtexture",
                         "pbasecolourandmasktexture"):
        # get diffuse
        if diffuse_name in tex_dic:
            diffuse = tex_dic[diffuse_name]
            # get AO
            for ao_name in ("paotexture", "pbasepackedtexture_03"):
                if ao_name in tex_dic:
                    ao = tex_dic[ao_name]
                    ao.image.colorspace_settings.name = "Non-Color"

                    # apply AO to diffuse
                    diffuse_premix = tree.nodes.new('ShaderNodeMixRGB')
                    diffuse_premix.blend_type = "MULTIPLY"
                    diffuse_premix.inputs["Fac"].default_value = .25
                    tree.links.new(diffuse.outputs[0],
                                   diffuse_premix.inputs["Color1"])
                    tree.links.new(ao.outputs[0],
                                   diffuse_premix.inputs["Color2"])
                    diffuse = diffuse_premix
                    break
            #  link finished diffuse to shader
            tree.links.new(diffuse.outputs[0], principled.inputs["Base Color"])
            break

    if "pnormaltexture" in tex_dic:
        normal = tex_dic["pnormaltexture"]
        normal.image.colorspace_settings.name = "Non-Color"
        normal_map = tree.nodes.new('ShaderNodeNormalMap')
        tree.links.new(normal.outputs[0], normal_map.inputs[1])
        # normal_map.inputs["Strength"].default_value = 1.0
        tree.links.new(normal_map.outputs[0], principled.inputs["Normal"])

    # PZ - specularity?
    for spec_name in ("proughnesspackedtexture_02", ):
        if spec_name in tex_dic:
            specular = tex_dic[spec_name]
            specular.image.colorspace_settings.name = "Non-Color"
            tree.links.new(specular.outputs[0], principled.inputs["Specular"])

    # PZ - roughness?
    for roughness_name in ("proughnesspackedtexture_01", ):
        if roughness_name in tex_dic:
            roughness = tex_dic[roughness_name]
            roughness.image.colorspace_settings.name = "Non-Color"
            tree.links.new(roughness.outputs[0],
                           principled.inputs["Roughness"])

    # JWE dinos - metalness
    for metal_name in ("pbasepackedtexture_02", ):
        if metal_name in tex_dic:
            metal = tex_dic[metal_name]
            metal.image.colorspace_settings.name = "Non-Color"
            tree.links.new(metal.outputs[0], principled.inputs["Metallic"])

    # alpha
    if "proughnesspackedtexture_03" in tex_dic:
        # transparency
        mat.blend_method = "CLIP"
        mat.shadow_method = "CLIP"
        for attrib in fgm_data.attributes:
            if attrib.name.lower() == "palphatestref":
                mat.alpha_threshold = attrib.value[0]
                break
        # if material.AlphaBlendEnable:
        # 	mat.blend_method = "BLEND"
        transp = tree.nodes.new('ShaderNodeBsdfTransparent')
        alpha_mixer = tree.nodes.new('ShaderNodeMixShader')
        alpha = tex_dic["proughnesspackedtexture_03"]
        tree.links.new(alpha.outputs[0], alpha_mixer.inputs[0])

        tree.links.new(transp.outputs[0], alpha_mixer.inputs[1])
        tree.links.new(principled.outputs[0], alpha_mixer.inputs[2])
        tree.links.new(alpha_mixer.outputs[0], output.inputs[0])
        alpha_mixer.update()
    # no alpha
    else:
        mat.blend_method = "OPAQUE"
        tree.links.new(principled.outputs[0], output.inputs[0])

    nodes_iterate(tree, output)
    return mat
Esempio n. 4
0
def create_material(matcol_path):
    slots = load_matcol(matcol_path)

    matdir, mat_ext = os.path.split(matcol_path)
    matname = os.path.splitext(mat_ext)[0]
    print("MATERIAL:", matname)
    #only create the material if we haven't already created it, then just grab it
    if matname not in bpy.data.materials:
        mat = bpy.data.materials.new(matname)
    #only create the material if we haven't already created it, then just grab it
    else:
        mat = bpy.data.materials[matname]

    tree = get_tree(mat)
    height_group = create_height()
    transform_group = create_group()
    output = tree.nodes.new('ShaderNodeOutputMaterial')
    principled = tree.nodes.new('ShaderNodeBsdfPrincipled')

    last_mixer = None
    textures = []
    for i, (infos, texture) in enumerate(slots):
        # skip default materials that have no fgm assigned
        if not texture:
            textures.append(None)
            continue
        print("Slot", i)
        slotnum = i
        # load the tiled texture
        tex = load_tex(tree, texture)
        # load the blendweights layer mask
        mask_path = os.path.join(
            matdir, matname + ".playered_blendweights_{:02}.png".format(i))
        mask = load_tex(tree, mask_path)

        # height offset attribute
        print([i for i in infos[1].info.value][:2])
        heightscale_lower, heightscale_upper = sorted(
            [i for i in infos[1].info.value][:2])

        if not heightscale_lower and not heightscale_upper:
            heightscale_upper = 1.0
        heightoffset = infos[2].info.value[0]
        heightscale = infos[3].info.value[0]

        height = tree.nodes.new("ShaderNodeGroup")
        height.node_tree = height_group
        height.inputs["heightScale"].default_value = heightscale
        height.inputs["heightOffset"].default_value = heightoffset
        height.inputs[
            "heightBlendScale.lower"].default_value = heightscale_lower
        height.inputs[
            "heightBlendScale.upper"].default_value = heightscale_upper
        tree.links.new(tex.outputs[0], height.inputs[0])

        textures.append((height, mask))

        transform = tree.nodes.new("ShaderNodeGroup")
        transform.node_tree = transform_group

        # m_uvRotationPosition
        uvrotpos = list(i for i in infos[6].info.value)[:3]
        transform.inputs["uvRotationPosition"].default_value = uvrotpos

        # m_UVOffset
        uvoffset = list(i for i in infos[4].info.value)[:3]
        transform.inputs["UVOffset"].default_value = uvoffset

        # m_uvTile
        uvscale = list(i for i in infos[7].info.value)[:3]
        transform.inputs["uvTile"].default_value = uvscale

        # m_uvRotationAngle
        # matcol stores it as fraction of 180°
        # in radians for blender internally even though it displays as degree
        rot = math.radians(infos[5].info.value[0] * 180)
        # flip since blender flips V coord
        transform.inputs["uvRotationAngle"].default_value = -rot
        tree.links.new(transform.outputs[0], tex.inputs[0])

        tex.update()
        mask.update()

    indices = []
    for i_a in range(4):
        for i_b in range(4):
            indices.append(i_a + i_b * 4)

    indices = list(i for i in range(slotnum))
    print(indices)

    normal_path = os.path.join(matdir, matname + ".pnormaltexture.png")
    normal = load_tex(tree, normal_path)
    normal.image.colorspace_settings.name = "Non-Color"
    normal_map = tree.nodes.new('ShaderNodeNormalMap')
    tree.links.new(normal.outputs[0], normal_map.inputs[1])
    normal_map.inputs["Strength"].default_value = 2.0
    #
    # bump = tree.nodes.new('ShaderNodeBump')
    # bump.inputs["Strength"].default_value = 0.5
    # bump.inputs["Distance"].default_value = 0.1

    #tree.links.new(normal_map.outputs[0], bump.inputs["Normal"])
    last_mixer = normal_map
    for i in indices:
        # skip empty slots
        if textures[i]:
            height, mask = textures[i]
            bump = tree.nodes.new('ShaderNodeBump')
            tree.links.new(mask.outputs[0], bump.inputs[0])
            tree.links.new(last_mixer.outputs[0], bump.inputs["Normal"])
            tree.links.new(height.outputs[0], bump.inputs["Height"])
            last_mixer = bump

    # tree.links.new(mixRGB.outputs[0], bump.inputs[2])

    diffuse_path = os.path.join(matdir, matname + ".pbasediffusetexture.png")
    diffuse = load_tex(tree, diffuse_path)

    roughness_path = os.path.join(matdir,
                                  matname + ".pbasepackedtexture_01.png")
    roughness = load_tex(tree, roughness_path)
    roughness.image.colorspace_settings.name = "Non-Color"

    ao_path = os.path.join(matdir, matname + ".pbasepackedtexture_03.png")
    ao = load_tex(tree, ao_path)
    ao.image.colorspace_settings.name = "Non-Color"

    # apply AO to diffuse
    diffuse_premix = tree.nodes.new('ShaderNodeMixRGB')
    diffuse_premix.blend_type = "MULTIPLY"
    diffuse_premix.inputs["Fac"].default_value = .25
    tree.links.new(diffuse.outputs[0], diffuse_premix.inputs["Color1"])
    tree.links.new(ao.outputs[0], diffuse_premix.inputs["Color2"])

    tree.links.new(diffuse_premix.outputs[0], principled.inputs["Base Color"])
    tree.links.new(roughness.outputs[0], principled.inputs["Metallic"])
    tree.links.new(bump.outputs[0], principled.inputs["Normal"])
    tree.links.new(principled.outputs[0], output.inputs[0])

    nodes_iterate(tree, output)
    return mat
Esempio n. 5
0
def create_height():
    name = "MatcolHeight"
    # only create the material if we haven't already created it, then just grab it
    if name not in bpy.data.node_groups:
        # create a group
        test_group = bpy.data.node_groups.new(name, 'ShaderNodeTree')

    else:
        test_group = bpy.data.node_groups[name]
        for node in test_group.nodes:
            test_group.nodes.remove(node)
        for node in test_group.inputs:
            test_group.inputs.remove(node)
        for node in test_group.outputs:
            test_group.outputs.remove(node)

    # create group inputs
    group_inputs = test_group.nodes.new('NodeGroupInput')
    group_inputs.location = (-350, 0)
    test_group.inputs.new('NodeSocketFloat', 'texture')
    test_group.inputs.new('NodeSocketFloat', 'heightBlendScale.lower')
    test_group.inputs.new('NodeSocketFloat', 'heightBlendScale.upper')
    test_group.inputs.new('NodeSocketFloat', 'heightOffset')
    test_group.inputs.new('NodeSocketFloat', 'heightScale')

    # create group outputs
    group_outputs = test_group.nodes.new('NodeGroupOutput')
    group_outputs.location = (300, 0)
    test_group.outputs.new('NodeSocketFloat', 'texture')

    # create three math nodes in a group
    heightScale = test_group.nodes.new('ShaderNodeMath')
    heightScale.label = "heightScale"
    heightScale.operation = 'MULTIPLY'
    test_group.links.new(group_inputs.outputs["texture"],
                         heightScale.inputs[0])
    test_group.links.new(group_inputs.outputs["heightScale"],
                         heightScale.inputs[1])

    heightOffset = test_group.nodes.new('ShaderNodeMath')
    heightOffset.label = "heightOffset"
    heightOffset.operation = 'ADD'
    test_group.links.new(heightScale.outputs[0], heightOffset.inputs[0])
    test_group.links.new(group_inputs.outputs["heightOffset"],
                         heightOffset.inputs[1])

    heightBlendScale = test_group.nodes.new('ShaderNodeMapRange')
    heightBlendScale.label = "heightBlendScale"
    heightBlendScale.clamp = False
    test_group.links.new(heightOffset.outputs[0], heightBlendScale.inputs[0])
    test_group.links.new(group_inputs.outputs["heightBlendScale.lower"],
                         heightBlendScale.inputs[1])
    test_group.links.new(group_inputs.outputs["heightBlendScale.upper"],
                         heightBlendScale.inputs[2])

    scale = test_group.nodes.new('ShaderNodeMath')
    scale.label = "scale"
    scale.operation = 'MULTIPLY'
    test_group.links.new(heightBlendScale.outputs[0], scale.inputs[0])
    scale.inputs[1].default_value = 2.0

    # #link output
    test_group.links.new(scale.outputs[0], group_outputs.inputs['texture'])

    nodes_iterate(test_group, group_outputs)
    return test_group
Esempio n. 6
0
def create_group():
    flipgr = create_flip()
    name = "MatcolSlot"
    #only create the material if we haven't already created it, then just grab it
    if name not in bpy.data.node_groups:
        # create a group
        test_group = bpy.data.node_groups.new(name, 'ShaderNodeTree')

    else:
        test_group = bpy.data.node_groups[name]
        for node in test_group.nodes:
            test_group.nodes.remove(node)
        for node in test_group.inputs:
            test_group.inputs.remove(node)
        for node in test_group.outputs:
            test_group.outputs.remove(node)

    # create group inputs
    group_inputs = test_group.nodes.new('NodeGroupInput')
    test_group.inputs.new('NodeSocketVectorTranslation', 'UVOffset')
    test_group.inputs.new('NodeSocketFloatAngle', 'uvRotationAngle')
    test_group.inputs.new('NodeSocketVectorTranslation', 'uvRotationPosition')
    test_group.inputs.new('NodeSocketVectorXYZ', 'uvTile')

    # create group outputs
    group_outputs = test_group.nodes.new('NodeGroupOutput')
    group_outputs.location = (300, 0)
    test_group.outputs.new('NodeSocketVectorXYZ', 'out')

    offset_flipx = test_group.nodes.new("ShaderNodeGroup")
    offset_flipx.node_tree = flipgr
    test_group.links.new(group_inputs.outputs["UVOffset"],
                         offset_flipx.inputs[0])

    rotpos_flipx = test_group.nodes.new("ShaderNodeGroup")
    rotpos_flipx.node_tree = flipgr
    test_group.links.new(group_inputs.outputs["uvRotationPosition"],
                         rotpos_flipx.inputs[0])

    uv = test_group.nodes.new('ShaderNodeUVMap')
    uv.label = "UV Input"
    uv.uv_map = "UV0"

    scale_pivot = test_group.nodes.new('ShaderNodeMapping')
    scale_pivot.inputs[1].default_value[1] = -1.0
    scale_pivot.label = "Scale Pivot"
    test_group.links.new(uv.outputs[0], scale_pivot.inputs[0])

    uv_offset = test_group.nodes.new('ShaderNodeMapping')
    uv_offset.label = "UVOffset"
    test_group.links.new(scale_pivot.outputs[0], uv_offset.inputs[0])
    test_group.links.new(offset_flipx.outputs[0], uv_offset.inputs[1])

    uv_tile = test_group.nodes.new('ShaderNodeMapping')
    uv_tile.label = "uvTile"
    test_group.links.new(uv_offset.outputs[0], uv_tile.inputs[0])
    test_group.links.new(group_inputs.outputs["uvTile"], uv_tile.inputs[3])

    rot_pivot = test_group.nodes.new('ShaderNodeMapping')
    rot_pivot.inputs[1].default_value[1] = -1.0
    rot_pivot.label = "Rot Pivot"
    test_group.links.new(uv_tile.outputs[0], rot_pivot.inputs[0])

    uv_rot_pos_a = test_group.nodes.new('ShaderNodeMapping')
    uv_rot_pos_a.label = "uvRotationPosition"
    test_group.links.new(rot_pivot.outputs[0], uv_rot_pos_a.inputs[0])
    test_group.links.new(rotpos_flipx.outputs[0], uv_rot_pos_a.inputs[1])

    # extra step to create vector from float
    uv_rot_combine = test_group.nodes.new('ShaderNodeCombineXYZ')
    uv_rot_combine.label = "build uvRotation Vector"
    test_group.links.new(group_inputs.outputs["uvRotationAngle"],
                         uv_rot_combine.inputs[2])

    uv_rot = test_group.nodes.new('ShaderNodeMapping')
    uv_rot.label = "uvRotationAngle"
    test_group.links.new(uv_rot_pos_a.outputs[0], uv_rot.inputs[0])
    test_group.links.new(uv_rot_combine.outputs[0], uv_rot.inputs[2])

    # extra step to negate input
    uv_rot_pos_flip = test_group.nodes.new('ShaderNodeVectorMath')
    uv_rot_pos_flip.operation = "SCALE"
    uv_rot_pos_flip.label = "flip uvRotationPosition"
    # counter intuitive index for non-vector argument!
    try:
        uv_rot_pos_flip.inputs[2].default_value = -1.0
    except:
        print("bug with new blender 2.9, unsure how to solve")
        pass
    test_group.links.new(rotpos_flipx.outputs[0], uv_rot_pos_flip.inputs[0])

    uv_rot_pos_b = test_group.nodes.new('ShaderNodeMapping')
    uv_rot_pos_b.label = "undo uvRotationPosition"
    test_group.links.new(uv_rot_pos_flip.outputs[0], uv_rot_pos_b.inputs[1])
    test_group.links.new(uv_rot.outputs[0], uv_rot_pos_b.inputs[0])

    # #link output
    test_group.links.new(uv_rot_pos_b.outputs[0], group_outputs.inputs['out'])

    nodes_iterate(test_group, group_outputs)
    return test_group