FBX IO: Speed up transformation animation import #104870

Merged
Thomas Barlow merged 6 commits from Mysteryem/blender-addons:fbx_import_anim_numpy_p1.5 into main 2023-09-11 13:53:12 +02:00
31 changed files with 218 additions and 188 deletions
Showing only changes of commit 56bcc06c6f - Show all commits

View File

@ -641,7 +641,6 @@ class discombobulator_dodads_list(Menu):
bl_idname = "OBJECT_MT_discombobulator_dodad_list"
bl_label = "List of saved Doodads"
bl_description = "List of the saved Doodad Object Names"
bl_options = {"REGISTER"}
def draw(self, context):
layout = self.layout
@ -660,7 +659,6 @@ class discombob_help(Menu):
bl_idname = "HELP_MT_discombobulator"
bl_label = "Usage Information"
bl_description = "Help"
bl_options = {"REGISTER"}
def draw(self, context):
layout = self.layout

View File

@ -7,7 +7,7 @@
bl_info = {
"name": "Is key Free",
"author": "Antonio Vazquez (antonioya)",
"version": (1, 1, 2),
"version": (1, 1, 3),
"blender": (2, 80, 0),
"location": "Text Editor > Sidebar > Dev Tab",
"description": "Find free shortcuts, inform about used and print a key list",
@ -16,6 +16,7 @@ bl_info = {
}
import bpy
from bpy.props import (
BoolProperty,
EnumProperty,
@ -28,6 +29,7 @@ from bpy.types import (
PropertyGroup,
)
import unicodedata
# ------------------------------------------------------
# Class to find keymaps
@ -498,6 +500,15 @@ class IsKeyFreeRunExportKeys(Operator):
except:
return None
def unicodelen(self, string):
n = 0
for c in string:
if unicodedata.east_asian_width(c) in 'FWA':
n += 2
else:
n += 1
return n
def execute(self, context):
wm = bpy.context.window_manager
from collections import defaultdict
@ -536,7 +547,7 @@ class IsKeyFreeRunExportKeys(Operator):
textblock.write("\n[%s]\nEntries: %s\n\n" % (ctx, len(mykeys[ctx])))
line_k = sorted(mykeys[ctx])
for keys in line_k:
add_ticks = "-" * (max_line - (len(keys[0]) + len(keys[1])))
add_ticks = "-" * (max_line - (self.unicodelen(keys[0]) + len(keys[1])))
entries = "{ticks} {entry}".format(ticks=add_ticks, entry=keys[1])
textblock.write("{name} {entry}\n".format(name=keys[0], entry=entries))

View File

@ -12,6 +12,7 @@ class StormHydraRenderEngine(bpy.types.HydraRenderEngine):
bl_use_preview = True
bl_use_gpu_context = True
bl_use_materialx = True
bl_delegate_id = 'HdStormRendererPlugin'

View File

@ -5,7 +5,7 @@
bl_info = {
"name": "FBX format",
"author": "Campbell Barton, Bastien Montagne, Jens Restemeier, @Mysteryem",
"version": (5, 7, 0),
"version": (5, 7, 2),
"blender": (3, 6, 0),
"location": "File > Import-Export",
"description": "FBX IO meshes, UVs, vertex colors, materials, textures, cameras, lamps and actions",

View File

@ -698,39 +698,6 @@ def _transformation_curves_gen(item, values_arrays, channel_keys):
yield from sca
def blen_read_animation_channel_curves(curves):
"""Read one or (very rarely) more animation curves, that affect a single channel of a single property, from FBX
data.
When there are multiple curves, they will be combined into a single sorted animation curve with later curves taking
precedence when the curves contain duplicate times.
It is expected that there will almost never be more than a single curve to read because FBX's default animation
system only uses the first curve assigned to a channel.
Returns an array of sorted, unique FBX keyframe times and an array of values for each of those keyframe times."""
if len(curves) > 1:
times_and_values_tuples = list(map(blen_read_single_animation_curve, curves))
# The FBX animation system's default implementation only uses the first curve assigned to a channel.
# Additional curves per channel are allowed by the FBX specification, but the handling of these curves is
# considered the responsibility of the application that created them. Note that each curve node is expected to
# have a unique set of channels, so these additional curves with the same channel would have to belong to
# separate curve nodes. See the FBX SDK documentation for FbxAnimCurveNode.
# Combine the curves together to produce a single array of sorted keyframe times and a single array of values.
# The arrays are concatenated in reverse so that if there are duplicate times in the read curves, then only the
# value of the last occurrence is kept.
all_times = np.concatenate([t[0] for t in reversed(times_and_values_tuples)])
all_values = np.concatenate([t[1] for t in reversed(times_and_values_tuples)])
# Get the unique, sorted times and the index in all_times of the first occurrence of each unique value.
sorted_unique_times, unique_indices_in_all_times = np.unique(all_times, return_index=True)
values_of_sorted_unique_times = all_values[unique_indices_in_all_times]
return sorted_unique_times, values_of_sorted_unique_times
else:
return blen_read_single_animation_curve(curves[0])
def _combine_curve_keyframe_times(times_and_values_tuples, initial_values):
"""Combine multiple parsed animation curves, that affect different channels, such that every animation curve
contains the keyframes from every other curve, interpolating the values for the newly inserted keyframes in each
@ -740,7 +707,8 @@ def _combine_curve_keyframe_times(times_and_values_tuples, initial_values):
interpolating the keyframe values is a TODO."""
if len(times_and_values_tuples) == 1:
# Nothing to do when there is only a single curve.
return times_and_values_tuples[0]
times, values = times_and_values_tuples[0]
return times, [values]
all_times = [t[0] for t in times_and_values_tuples]
@ -850,8 +818,8 @@ def _convert_fbx_time_to_blender_time(key_times, blen_start_offset, fbx_start_of
return key_times
def blen_read_single_animation_curve(fbx_curve):
"""Read a single animation curve from FBX data.
def blen_read_animation_curve(fbx_curve):
"""Read an animation curve from FBX data.
The parsed keyframe times are guaranteed to be strictly increasing."""
key_times = parray_as_ndarray(elem_prop_first(elem_find_first(fbx_curve, b'KeyTime')))
@ -922,11 +890,19 @@ def blen_read_animations_action_item(action, item, cnodes, fps, anim_offset, glo
"""
from bpy.types import Object, PoseBone, ShapeKey, Material, Camera
fbx_curves: dict[bytes, dict[int, list[FBXElem]]] = {}
fbx_curves: dict[bytes, dict[int, FBXElem]] = {}
for curves, fbxprop in cnodes.values():
channels_dict = fbx_curves.setdefault(fbxprop, {})
for (fbx_acdata, _blen_data), channel in curves.values():
channels_dict.setdefault(channel, []).append(fbx_acdata)
if channel in channels_dict:
# Ignore extra curves when one has already been found for this channel because FBX's default animation
# system implementation only uses the first curve assigned to a channel.
# Additional curves per channel are allowed by the FBX specification, but the handling of these curves
# is considered the responsibility of the application that created them. Note that each curve node is
# expected to have a unique set of channels, so these additional curves with the same channel would have
# to belong to separate curve nodes. See the FBX SDK documentation for FbxAnimCurveNode.
continue
channels_dict[channel] = fbx_acdata
# Leave if no curves are attached (if a blender curve is attached to scale but without keys it defaults to 0).
if len(fbx_curves) == 0:
@ -965,23 +941,23 @@ def blen_read_animations_action_item(action, item, cnodes, fps, anim_offset, glo
for prop, nbr_channels, grpname in props for channel in range(nbr_channels)]
if isinstance(item, Material):
for fbxprop, channel_to_curves in fbx_curves.items():
for fbxprop, channel_to_curve in fbx_curves.items():
assert(fbxprop == b'DiffuseColor')
for channel, curves in channel_to_curves.items():
for channel, curve in channel_to_curve.items():
assert(channel in {0, 1, 2})
blen_curve = blen_curves[channel]
fbx_key_times, values = blen_read_animation_channel_curves(curves)
fbx_key_times, values = blen_read_animation_curve(curve)
blen_store_keyframes(fbx_key_times, blen_curve, values, anim_offset, fps)
elif isinstance(item, ShapeKey):
deform_values = shape_key_deforms.setdefault(item, [])
for fbxprop, channel_to_curves in fbx_curves.items():
for fbxprop, channel_to_curve in fbx_curves.items():
assert(fbxprop == b'DeformPercent')
for channel, curves in channel_to_curves.items():
for channel, curve in channel_to_curve.items():
assert(channel == 0)
blen_curve = blen_curves[channel]
fbx_key_times, values = blen_read_animation_channel_curves(curves)
fbx_key_times, values = blen_read_animation_curve(curve)
# A fully activated shape key in FBX DeformPercent is 100.0 whereas it is 1.0 in Blender.
values = values / 100.0
blen_store_keyframes(fbx_key_times, blen_curve, values, anim_offset, fps)
@ -992,15 +968,15 @@ def blen_read_animations_action_item(action, item, cnodes, fps, anim_offset, glo
deform_values.append(values.max())
elif isinstance(item, Camera):
for fbxprop, channel_to_curves in fbx_curves.items():
for fbxprop, channel_to_curve in fbx_curves.items():
is_focus_distance = fbxprop == b'FocusDistance'
assert(fbxprop == b'FocalLength' or is_focus_distance)
for channel, curves in channel_to_curves.items():
for channel, curve in channel_to_curve.items():
assert(channel == 0)
# The indices are determined by the creation of the `props` list above.
blen_curve = blen_curves[1 if is_focus_distance else 0]
fbx_key_times, values = blen_read_animation_channel_curves(curves)
fbx_key_times, values = blen_read_animation_curve(curve)
if is_focus_distance:
# Remap the imported values from FBX to Blender.
values = values / 1000.0
@ -1021,13 +997,13 @@ def blen_read_animations_action_item(action, item, cnodes, fps, anim_offset, glo
times_and_values_tuples = []
initial_values = []
channel_keys = []
for fbxprop, channel_to_curves in fbx_curves.items():
for fbxprop, channel_to_curve in fbx_curves.items():
if fbxprop not in transform_prop_to_attr:
# Currently, we only care about transformation curves.
continue
for channel, curves in channel_to_curves.items():
for channel, curve in channel_to_curve.items():
assert(channel in {0, 1, 2})
fbx_key_times, values = blen_read_animation_channel_curves(curves)
fbx_key_times, values = blen_read_animation_curve(curve)
channel_keys.append((fbxprop, channel))

View File

@ -5,7 +5,7 @@
bl_info = {
'name': 'glTF 2.0 format',
'author': 'Julien Duroure, Scurest, Norbert Nopper, Urs Hanselmann, Moritz Becher, Benjamin Schmithüsen, Jim Eckerlein, and many external contributors',
"version": (4, 0, 7),
"version": (4, 0, 15),
'blender': (4, 0, 0),
'location': 'File > Import-Export',
'description': 'Import-Export as glTF 2.0',

View File

@ -119,7 +119,8 @@ def get_numpy_type(attribute_component_type):
def get_attribute_type(component_type, data_type):
if gltf2_io_constants.DataType.num_elements(data_type) == 1:
return {
gltf2_io_constants.ComponentType.Float: "FLOAT"
gltf2_io_constants.ComponentType.Float: "FLOAT",
gltf2_io_constants.ComponentType.UnsignedByte: "INT" # What is the best for compatibility?
}[component_type]
elif gltf2_io_constants.DataType.num_elements(data_type) == 2:
return {
@ -132,7 +133,8 @@ def get_attribute_type(component_type, data_type):
elif gltf2_io_constants.DataType.num_elements(data_type) == 4:
return {
gltf2_io_constants.ComponentType.Float: "FLOAT_COLOR",
gltf2_io_constants.ComponentType.UnsignedShort: "BYTE_COLOR"
gltf2_io_constants.ComponentType.UnsignedShort: "BYTE_COLOR",
gltf2_io_constants.ComponentType.UnsignedByte: "BYTE_COLOR" # What is the best for compatibility?
}[component_type]
else:
pass

View File

@ -45,10 +45,19 @@ def is_bone_anim_channel(data_path: str) -> bool:
def get_sk_exported(key_blocks):
return [
key_block
for key_block in key_blocks
if not skip_sk(key_block)
k
for k in key_blocks
if not skip_sk(key_blocks, k)
]
def skip_sk(k):
return k == k.relative_key or k.mute
def skip_sk(key_blocks, k):
# Do not export:
# - if muted
# - if relative key is SK itself (this avoid exporting Basis too if user didn't change order)
# - the Basis (the first SK of the list)
return k == k.relative_key \
or k.mute \
or is_first_index(key_blocks, k) is True
def is_first_index(key_blocks, k):
return key_blocks[0].name == k.name

View File

@ -30,7 +30,7 @@ def gather_animation_fcurves_channels(
custom_range = (blender_action.frame_start, blender_action.frame_end)
channels = []
for chan in [chan for chan in channels_to_perform.values() if len(chan['properties']) != 0]:
for chan in [chan for chan in channels_to_perform.values() if len(chan['properties']) != 0 and chan['type'] != "EXTRA"]:
for channel_group in chan['properties'].values():
channel = __gather_animation_fcurve_channel(chan['obj_uuid'], channel_group, chan['bone'], custom_range, export_settings)
if channel is not None:
@ -73,10 +73,13 @@ def get_channel_groups(obj_uuid: str, blender_action: bpy.types.Action, export_s
else:
try:
target = get_object_from_datapath(blender_object, object_path)
if blender_object.type == "ARMATURE" and fcurve.data_path.startswith("pose.bones["):
type_ = "BONE"
else:
type_ = "EXTRA"
if blender_object.type == "MESH" and object_path.startswith("key_blocks"):
shape_key = blender_object.data.shape_keys.path_resolve(object_path)
if skip_sk(shape_key):
if skip_sk(blender_object.data.shape_keys.key_blocks, shape_key):
continue
target = blender_object.data.shape_keys
type_ = "SK"
@ -86,7 +89,7 @@ def get_channel_groups(obj_uuid: str, blender_action: bpy.types.Action, export_s
if blender_object.type == "MESH":
try:
shape_key = blender_object.data.shape_keys.path_resolve(object_path)
if skip_sk(shape_key):
if skip_sk(blender_object.data.shape_keys.key_blocks, shape_key):
continue
target = blender_object.data.shape_keys
type_ = "SK"
@ -181,7 +184,7 @@ def __get_channel_group_sorted(channels: typing.Tuple[bpy.types.FCurve], blender
shapekeys_idx = {}
cpt_sk = 0
for sk in blender_object.data.shape_keys.key_blocks:
if skip_sk(sk):
if skip_sk(blender_object.data.shape_keys.key_blocks, sk):
continue
shapekeys_idx[sk.name] = cpt_sk
cpt_sk += 1

View File

@ -4,7 +4,7 @@
import bpy
import typing
from .....blender.com.gltf2_blender_data_path import skip_sk
from .....blender.com.gltf2_blender_data_path import get_sk_exported
from ....com.gltf2_blender_data_path import get_target_object_path
from ...gltf2_blender_gather_cache import cached
from ..gltf2_blender_gather_keyframes import Keyframe
@ -165,9 +165,7 @@ def __gather_non_keyed_values(
if object_path:
shapekeys_idx = {}
cpt_sk = 0
for sk in blender_object.data.shape_keys.key_blocks:
if skip_sk(sk):
continue
for sk in get_sk_exported(blender_object.data.shape_keys.key_blocks):
shapekeys_idx[cpt_sk] = sk.name
cpt_sk += 1

View File

@ -294,6 +294,8 @@ def gather_action_animations( obj_uuid: int,
channel = gather_sampled_object_channel(obj_uuid, prop, blender_action.name, True, get_gltf_interpolation("LINEAR"), export_settings)
elif type_ == "SK":
channel = gather_sampled_sk_channel(obj_uuid, blender_action.name, export_settings)
elif type_ == "EXTRA":
channel = None
else:
print("Type unknown. Should not happen")

View File

@ -61,7 +61,7 @@ def get_sk_drivers(blender_armature_uuid, export_settings):
sk_name = child.data.shape_keys.path_resolve(get_target_object_path(sk_c.data_path)).name
except:
continue
if skip_sk(child.data.shape_keys.key_blocks[sk_name]):
if skip_sk(child.data.shape_keys.key_blocks, child.data.shape_keys.key_blocks[sk_name]):
continue
idx_channel_mapping.append((shapekeys_idx[sk_name], sk_c))
existing_idx = dict(idx_channel_mapping)

View File

@ -6,6 +6,7 @@ import math
import bpy
from mathutils import Matrix, Quaternion, Vector
from ...io.com.gltf2_io_debug import print_console
from ...io.com import gltf2_io
from ...io.com import gltf2_io_extensions
from ...io.exp.gltf2_io_user_extensions import export_user_extensions
@ -181,7 +182,9 @@ def __gather_mesh(vnode, blender_object, export_settings):
return None
# Be sure that object is valid (no NaN for example)
blender_object.data.validate()
res = blender_object.data.validate()
if res is True:
print_console("WARNING", "Mesh " + blender_object.data.name + " is not valid, and may be exported wrongly")
modifiers = blender_object.modifiers
if len(modifiers) == 0:

View File

@ -35,7 +35,7 @@ def gather_primitive_attributes(blender_primitive, export_settings):
return attributes
def array_to_accessor(array, component_type, data_type, include_max_and_min=False):
def array_to_accessor(array, component_type, data_type, include_max_and_min=False, normalized=None):
amax = None
amin = None
@ -53,7 +53,7 @@ def array_to_accessor(array, component_type, data_type, include_max_and_min=Fals
max=amax,
min=amin,
name=None,
normalized=None,
normalized=normalized,
sparse=None,
type=data_type,
)
@ -183,6 +183,7 @@ def __gather_attribute(blender_primitive, attribute, export_settings):
data['data'],
component_type=data['component_type'],
data_type=data['data_type'],
include_max_and_min=include_max_and_mins.get(attribute, False)
include_max_and_min=include_max_and_mins.get(attribute, False),
normalized=data.get('normalized')
)
}

View File

@ -6,7 +6,7 @@ import numpy as np
from mathutils import Vector
from ...blender.com.gltf2_blender_data_path import get_sk_exported
from ...io.com.gltf2_io_debug import print_console
from ...io.com.gltf2_io_constants import NORMALS_ROUNDING_DIGIT
from ...io.com.gltf2_io_constants import ROUNDING_DIGIT
from ...io.exp.gltf2_io_user_extensions import export_user_extensions
from ...io.com import gltf2_io_constants
from ..com import gltf2_blender_conversion
@ -357,9 +357,16 @@ class PrimitiveCreator:
def primitive_split(self):
# Calculate triangles and sort them into primitives.
try:
self.blender_mesh.calc_loop_triangles()
loop_indices = np.empty(len(self.blender_mesh.loop_triangles) * 3, dtype=np.uint32)
self.blender_mesh.loop_triangles.foreach_get('loops', loop_indices)
except:
# For some not valid meshes, we can't get loops without errors
# We already displayed a Warning message after validate() check, so here
# we can return without a new one
self.prim_indices = {}
return
self.prim_indices = {} # maps material index to TRIANGLES-style indices into dots
@ -715,7 +722,7 @@ class PrimitiveCreator:
self.normals = self.normals.reshape(len(self.blender_mesh.loops), 3)
self.normals = np.round(self.normals, NORMALS_ROUNDING_DIGIT)
self.normals = np.round(self.normals, ROUNDING_DIGIT)
# Force normalization of normals in case some normals are not (why ?)
PrimitiveCreator.normalize_vecs(self.normals)
@ -723,7 +730,7 @@ class PrimitiveCreator:
for key_block in key_blocks:
ns = np.array(key_block.normals_split_get(), dtype=np.float32)
ns = ns.reshape(len(self.blender_mesh.loops), 3)
ns = np.round(ns, NORMALS_ROUNDING_DIGIT)
ns = np.round(ns, ROUNDING_DIGIT)
self.morph_normals.append(ns)
# Transform for skinning
@ -782,6 +789,7 @@ class PrimitiveCreator:
self.tangents = np.empty(len(self.blender_mesh.loops) * 3, dtype=np.float32)
self.blender_mesh.loops.foreach_get('tangent', self.tangents)
self.tangents = self.tangents.reshape(len(self.blender_mesh.loops), 3)
self.tangents = np.round(self.tangents, ROUNDING_DIGIT)
# Transform for skinning
if self.armature and self.blender_object:
@ -789,6 +797,7 @@ class PrimitiveCreator:
tangent_transform = apply_matrix.to_quaternion().to_matrix()
self.tangents = PrimitiveCreator.apply_mat_to_all(tangent_transform, self.tangents)
PrimitiveCreator.normalize_vecs(self.tangents)
self.tangents = np.round(self.tangents, ROUNDING_DIGIT)
if self.export_settings['gltf_yup']:
PrimitiveCreator.zup2yup(self.tangents)

View File

@ -308,11 +308,19 @@ def previous_node(socket):
return prev_socket.node
return None
#TODOExt is this the same as __get_tex_from_socket from gather_image ?
def has_image_node_from_socket(socket):
def get_tex_from_socket(socket):
result = gltf2_blender_search_node_tree.from_socket(
socket,
gltf2_blender_search_node_tree.FilterByType(bpy.types.ShaderNodeTexImage))
if not result:
return False
return True
return None
if result[0].shader_node.image is None:
return None
return result[0]
def has_image_node_from_socket(socket):
return get_tex_from_socket(socket) is not None
def image_tex_is_valid_from_socket(socket):
res = get_tex_from_socket(socket)
return res is not None and res.shader_node.image is not None and res.shader_node.image.channels != 0

View File

@ -55,6 +55,7 @@ def export_clearcoat(blender_material, export_settings):
clearcoat_texture, clearcoat_texture_use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(
clearcoat_socket,
clearcoat_roughness_slots,
(),
export_settings,
)
clearcoat_extension['clearcoatTexture'] = clearcoat_texture
@ -64,6 +65,7 @@ def export_clearcoat(blender_material, export_settings):
clearcoat_roughness_texture, clearcoat_roughness_texture_use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(
clearcoat_roughness_socket,
clearcoat_roughness_slots,
(),
export_settings,
)
clearcoat_extension['clearcoatRoughnessTexture'] = clearcoat_roughness_texture

View File

@ -52,7 +52,7 @@ def export_emission_texture(blender_material, export_settings):
emissive = gltf2_blender_get.get_socket(blender_material, "Emissive")
if emissive is None:
emissive = gltf2_blender_get.get_socket_old(blender_material, "Emissive")
emissive_texture, use_actives_uvmap_emissive, _ = gltf2_blender_gather_texture_info.gather_texture_info(emissive, (emissive,), export_settings)
emissive_texture, use_actives_uvmap_emissive, _ = gltf2_blender_gather_texture_info.gather_texture_info(emissive, (emissive,), (), export_settings)
return emissive_texture, ["emissiveTexture"] if use_actives_uvmap_emissive else None
def export_emission_strength_extension(emissive_factor, export_settings):

View File

@ -40,6 +40,7 @@ def export_sheen(blender_material, export_settings):
original_sheenColor_texture, original_sheenColor_use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(
sheenColor_socket,
(sheenColor_socket,),
(),
export_settings,
)
sheen_extension['sheenColorTexture'] = original_sheenColor_texture
@ -64,6 +65,7 @@ def export_sheen(blender_material, export_settings):
original_sheenRoughness_texture, original_sheenRoughness_use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(
sheenRoughness_socket,
(sheenRoughness_socket,),
(),
export_settings,
)
sheen_extension['sheenRoughnessTexture'] = original_sheenRoughness_texture

View File

@ -7,7 +7,8 @@ from .....io.com.gltf2_io_extensions import Extension
from .....io.com.gltf2_io_constants import GLTF_IOR
from ....exp import gltf2_blender_get
from ....com.gltf2_blender_default import BLENDER_SPECULAR, BLENDER_SPECULAR_TINT
from ...material import gltf2_blender_gather_texture_info
from ...material.gltf2_blender_gather_texture_info import gather_texture_info
from ...gltf2_blender_get import image_tex_is_valid_from_socket
def export_original_specular(blender_material, export_settings):
specular_extension = {}
@ -36,9 +37,10 @@ def export_original_specular(blender_material, export_settings):
# Texture
if gltf2_blender_get.has_image_node_from_socket(original_specular_socket):
original_specular_texture, original_specular_use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(
original_specular_texture, original_specular_use_active_uvmap, _ = gather_texture_info(
original_specular_socket,
(original_specular_socket,),
(),
export_settings,
)
specular_extension['specularTexture'] = original_specular_texture
@ -58,9 +60,10 @@ def export_original_specular(blender_material, export_settings):
# Texture
if gltf2_blender_get.has_image_node_from_socket(original_specularcolor_socket):
original_specularcolor_texture, original_specularcolor_use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(
original_specularcolor_texture, original_specularcolor_use_active_uvmap, _ = gather_texture_info(
original_specularcolor_socket,
(original_specularcolor_socket,),
(),
export_settings,
)
specular_extension['specularColorTexture'] = original_specularcolor_texture
@ -86,12 +89,11 @@ def export_specular(blender_material, export_settings):
if base_color_socket is None:
return None, None
# TODOExt replace by __has_image_node_from_socket calls
specular_not_linked = isinstance(specular_socket, bpy.types.NodeSocket) and not specular_socket.is_linked
specular_tint_not_linked = isinstance(specular_tint_socket, bpy.types.NodeSocket) and not specular_tint_socket.is_linked
base_color_not_linked = isinstance(base_color_socket, bpy.types.NodeSocket) and not base_color_socket.is_linked
transmission_not_linked = isinstance(transmission_socket, bpy.types.NodeSocket) and not transmission_socket.is_linked
ior_not_linked = isinstance(ior_socket, bpy.types.NodeSocket) and not ior_socket.is_linked
specular_not_linked = not image_tex_is_valid_from_socket(specular_socket)
specular_tint_not_linked = not image_tex_is_valid_from_socket(specular_tint_socket)
base_color_not_linked = not image_tex_is_valid_from_socket(base_color_socket)
transmission_not_linked = not image_tex_is_valid_from_socket(transmission_socket)
ior_not_linked = not image_tex_is_valid_from_socket(ior_socket)
specular = specular_socket.default_value if specular_not_linked else None
specular_tint = specular_tint_socket.default_value if specular_tint_not_linked else None
@ -149,9 +151,10 @@ def export_specular(blender_material, export_settings):
if base_color_not_linked:
primary_socket = transmission_socket
specularColorTexture, use_active_uvmap, specularColorFactor = gltf2_blender_gather_texture_info.gather_texture_info(
specularColorTexture, use_active_uvmap, specularColorFactor = gather_texture_info(
primary_socket,
sockets,
(),
export_settings,
filter_type='ANY')
if specularColorTexture is None:

View File

@ -38,6 +38,7 @@ def export_transmission(blender_material, export_settings):
combined_texture, use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(
transmission_socket,
transmission_slots,
(),
export_settings,
)
if has_transmission_texture:

View File

@ -66,6 +66,7 @@ def export_volume(blender_material, export_settings):
combined_texture, use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(
thicknesss_socket,
thickness_slots,
(),
export_settings,
)
if has_thickness_texture:

View File

@ -28,6 +28,11 @@ class FillWhite:
"""Fills a channel with all ones (1.0)."""
pass
class FillWith:
"""Fills a channel with all same values"""
def __init__(self, value):
self.value = value
class StoreData:
def __init__(self, data):
"""Store numeric data (not an image channel"""
@ -99,6 +104,9 @@ class ExportImage:
def fill_white(self, dst_chan: Channel):
self.fills[dst_chan] = FillWhite()
def fill_with(self, dst_chan, value):
self.fills[dst_chan] = FillWith(value)
def is_filled(self, chan: Channel) -> bool:
return chan in self.fills
@ -183,6 +191,8 @@ class ExportImage:
for dst_chan, fill in self.fills.items():
if isinstance(fill, FillImage) and fill.image == image:
out_buf[int(dst_chan)::4] = tmp_buf[int(fill.src_chan)::4]
elif isinstance(fill, FillWith):
out_buf[int(dst_chan)::4] = fill.value
tmp_buf = None # GC this

View File

@ -12,17 +12,18 @@ from ....io.exp import gltf2_io_binary_data, gltf2_io_image_data
from ....io.com import gltf2_io_debug
from ....io.exp.gltf2_io_user_extensions import export_user_extensions
from ..gltf2_blender_gather_cache import cached
from . import gltf2_blender_search_node_tree
from .extensions.gltf2_blender_image import Channel, ExportImage, FillImage
from ..gltf2_blender_get import get_tex_from_socket
@cached
def gather_image(
blender_shader_sockets: typing.Tuple[bpy.types.NodeSocket],
default_sockets: typing.Tuple[bpy.types.NodeSocket],
export_settings):
if not __filter_image(blender_shader_sockets, export_settings):
return None, None
image_data = __get_image_data(blender_shader_sockets, export_settings)
image_data = __get_image_data(blender_shader_sockets, default_sockets, export_settings)
if image_data.empty():
# The export image has no data
return None, None
@ -174,24 +175,32 @@ def __gather_uri(image_data, mime_type, name, export_settings):
return None, None
def __get_image_data(sockets, export_settings) -> ExportImage:
def __get_image_data(sockets, default_sockets, export_settings) -> ExportImage:
# For shared resources, such as images, we just store the portion of data that is needed in the glTF property
# in a helper class. During generation of the glTF in the exporter these will then be combined to actual binary
# resources.
results = [__get_tex_from_socket(socket, export_settings) for socket in sockets]
results = [get_tex_from_socket(socket) for socket in sockets]
# Check if we need a simple mapping or more complex calculation
if any([socket.name == "Specular" and socket.node.type == "BSDF_PRINCIPLED" for socket in sockets]):
return __get_image_data_specular(sockets, results, export_settings)
else:
return __get_image_data_mapping(sockets, results, export_settings)
return __get_image_data_mapping(sockets, default_sockets, results, export_settings)
def __get_image_data_mapping(sockets, results, export_settings) -> ExportImage:
def __get_image_data_mapping(sockets, default_sockets, results, export_settings) -> ExportImage:
"""
Simple mapping
Will fit for most of exported textures : RoughnessMetallic, Basecolor, normal, ...
"""
composed_image = ExportImage()
default_metallic = None
default_roughness = None
if "Metallic" in [s.name for s in default_sockets]:
default_metallic = [s for s in default_sockets if s.name == "Metallic"][0].default_value
if "Roughness" in [s.name for s in default_sockets]:
default_roughness = [s for s in default_sockets if s.name == "Roughness"][0].default_value
for result, socket in zip(results, sockets):
# Assume that user know what he does, and that channels/images are already combined correctly for pbr
# If not, we are going to keep only the first texture found
@ -242,8 +251,14 @@ def __get_image_data_mapping(sockets, results, export_settings) -> ExportImage:
# Since metal/roughness are always used together, make sure
# the other channel is filled.
if socket.name == 'Metallic' and not composed_image.is_filled(Channel.G):
if default_roughness is not None:
composed_image.fill_with(Channel.G, default_roughness)
else:
composed_image.fill_white(Channel.G)
elif socket.name == 'Roughness' and not composed_image.is_filled(Channel.B):
if default_metallic is not None:
composed_image.fill_with(Channel.B, default_metallic)
else:
composed_image.fill_white(Channel.B)
else:
# copy full image...eventually following sockets might overwrite things
@ -271,7 +286,7 @@ def __get_image_data_specular(sockets, results, export_settings) -> ExportImage:
composed_image.store_data("ior", sockets[4].default_value, type="Data")
results = [__get_tex_from_socket(socket, export_settings) for socket in sockets[:-1]] #Do not retrieve IOR --> No texture allowed
results = [get_tex_from_socket(socket) for socket in sockets[:-1]] #Do not retrieve IOR --> No texture allowed
mapping = {
0: "specular",
@ -281,7 +296,7 @@ def __get_image_data_specular(sockets, results, export_settings) -> ExportImage:
}
for idx, result in enumerate(results):
if __get_tex_from_socket(sockets[idx], export_settings):
if get_tex_from_socket(sockets[idx]):
composed_image.store_data(mapping[idx], result.shader_node.image, type="Image")
@ -308,16 +323,6 @@ def __get_image_data_specular(sockets, results, export_settings) -> ExportImage:
return composed_image
# TODOExt deduplicate
@cached
def __get_tex_from_socket(blender_shader_socket: bpy.types.NodeSocket, export_settings):
result = gltf2_blender_search_node_tree.from_socket(
blender_shader_socket,
gltf2_blender_search_node_tree.FilterByType(bpy.types.ShaderNodeTexImage))
if not result:
return None
return result[0]
def __is_blender_image_a_jpeg(image: bpy.types.Image) -> bool:
if image.source != 'FILE':

View File

@ -52,13 +52,13 @@ def gather_material(blender_material, active_uvmap_index, export_settings):
export_user_extensions('gather_material_hook', export_settings, mat_unlit, blender_material)
return mat_unlit
orm_texture = __gather_orm_texture(blender_material, export_settings)
orm_texture, default_sockets = __gather_orm_texture(blender_material, export_settings)
emissive_factor = __gather_emissive_factor(blender_material, export_settings)
emissive_texture, uvmap_actives_emissive_texture = __gather_emissive_texture(blender_material, export_settings)
extensions, uvmap_actives_extensions = __gather_extensions(blender_material, emissive_factor, export_settings)
normal_texture, uvmap_actives_normal_texture = __gather_normal_texture(blender_material, export_settings)
occlusion_texture, uvmap_actives_occlusion_texture = __gather_occlusion_texture(blender_material, orm_texture, export_settings)
occlusion_texture, uvmap_actives_occlusion_texture = __gather_occlusion_texture(blender_material, orm_texture, default_sockets, export_settings)
pbr_metallic_roughness, uvmap_actives_pbr_metallic_roughness = __gather_pbr_metallic_roughness(blender_material, orm_texture, export_settings)
if any([i>1.0 for i in emissive_factor or []]) is True:
@ -303,7 +303,7 @@ def __gather_orm_texture(blender_material, export_settings):
if occlusion is None or not gltf2_blender_get.has_image_node_from_socket(occlusion):
occlusion = gltf2_blender_get.get_socket_old(blender_material, "Occlusion")
if occlusion is None or not gltf2_blender_get.has_image_node_from_socket(occlusion):
return None
return None, None
metallic_socket = gltf2_blender_get.get_socket(blender_material, "Metallic")
roughness_socket = gltf2_blender_get.get_socket(blender_material, "Roughness")
@ -311,38 +311,43 @@ def __gather_orm_texture(blender_material, export_settings):
hasMetal = metallic_socket is not None and gltf2_blender_get.has_image_node_from_socket(metallic_socket)
hasRough = roughness_socket is not None and gltf2_blender_get.has_image_node_from_socket(roughness_socket)
default_sockets = ()
if not hasMetal and not hasRough:
metallic_roughness = gltf2_blender_get.get_socket_old(blender_material, "MetallicRoughness")
if metallic_roughness is None or not gltf2_blender_get.has_image_node_from_socket(metallic_roughness):
return None
return None, default_sockets
result = (occlusion, metallic_roughness)
elif not hasMetal:
result = (occlusion, roughness_socket)
default_sockets = (metallic_socket,)
elif not hasRough:
result = (occlusion, metallic_socket)
default_sockets = (roughness_socket,)
else:
result = (occlusion, roughness_socket, metallic_socket)
default_sockets = ()
if not gltf2_blender_gather_texture_info.check_same_size_images(result):
print_console("INFO",
"Occlusion and metal-roughness texture will be exported separately "
"(use same-sized images if you want them combined)")
return None
return None, ()
# Double-check this will past the filter in texture_info
info, info_use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(result[0], result, export_settings)
info, info_use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(result[0], result, default_sockets, export_settings)
if info is None:
return None
return None, ()
return result
return result, default_sockets
def __gather_occlusion_texture(blender_material, orm_texture, export_settings):
def __gather_occlusion_texture(blender_material, orm_texture, default_sockets, export_settings):
occlusion = gltf2_blender_get.get_socket(blender_material, "Occlusion")
if occlusion is None:
occlusion = gltf2_blender_get.get_socket_old(blender_material, "Occlusion")
occlusion_texture, use_active_uvmap_occlusion, _ = gltf2_blender_gather_texture_info.gather_material_occlusion_texture_info_class(
occlusion,
orm_texture or (occlusion,),
default_sockets,
export_settings)
return occlusion_texture, ["occlusionTexture"] if use_active_uvmap_occlusion else None

View File

@ -8,8 +8,8 @@ from ....io.com import gltf2_io
from ....io.exp.gltf2_io_user_extensions import export_user_extensions
from ...exp import gltf2_blender_get
from ..gltf2_blender_gather_cache import cached
from . import gltf2_blender_search_node_tree
from . import gltf2_blender_gather_texture_info
from ..gltf2_blender_get import image_tex_is_valid_from_socket
from .gltf2_blender_gather_texture_info import gather_texture_info
@cached
def gather_material_pbr_metallic_roughness(blender_material, orm_texture, export_settings):
@ -93,12 +93,12 @@ def __gather_base_color_texture(blender_material, export_settings):
# keep sockets that have some texture : color and/or alpha
inputs = tuple(
socket for socket in [base_color_socket, alpha_socket]
if socket is not None and __has_image_node_from_socket(socket)
if socket is not None and image_tex_is_valid_from_socket(socket)
)
if not inputs:
return None, None, None
return gltf2_blender_gather_texture_info.gather_texture_info(inputs[0], inputs, export_settings)
return gather_texture_info(inputs[0], inputs, (), export_settings)
def __gather_extensions(blender_material, export_settings):
@ -126,24 +126,29 @@ def __gather_metallic_roughness_texture(blender_material, orm_texture, export_se
metallic_socket = gltf2_blender_get.get_socket(blender_material, "Metallic")
roughness_socket = gltf2_blender_get.get_socket(blender_material, "Roughness")
hasMetal = metallic_socket is not None and __has_image_node_from_socket(metallic_socket)
hasRough = roughness_socket is not None and __has_image_node_from_socket(roughness_socket)
hasMetal = metallic_socket is not None and image_tex_is_valid_from_socket(metallic_socket)
hasRough = roughness_socket is not None and image_tex_is_valid_from_socket(roughness_socket)
default_sockets = ()
if not hasMetal and not hasRough:
metallic_roughness = gltf2_blender_get.get_socket_old(blender_material, "MetallicRoughness")
if metallic_roughness is None or not __has_image_node_from_socket(metallic_roughness):
if metallic_roughness is None or not image_tex_is_valid_from_socket(metallic_roughness):
return None, None, None
texture_input = (metallic_roughness,)
elif not hasMetal:
texture_input = (roughness_socket,)
default_sockets = (metallic_socket,)
elif not hasRough:
texture_input = (metallic_socket,)
default_sockets = (roughness_socket,)
else:
texture_input = (metallic_socket, roughness_socket)
default_sockets = ()
return gltf2_blender_gather_texture_info.gather_texture_info(
return gather_texture_info(
texture_input[0],
orm_texture or texture_input,
default_sockets,
export_settings,
)
@ -160,14 +165,6 @@ def __gather_roughness_factor(blender_material, export_settings):
return fac if fac != 1 else None
return None
def __has_image_node_from_socket(socket):
result = gltf2_blender_search_node_tree.from_socket(
socket,
gltf2_blender_search_node_tree.FilterByType(bpy.types.ShaderNodeTexImage))
if not result:
return False
return True
def get_default_pbr_for_emissive_node():
return gltf2_io.MaterialPBRMetallicRoughness(
base_color_factor=[0.0,0.0,0.0,1.0],

View File

@ -131,6 +131,7 @@ def gather_base_color_texture(info, export_settings):
unlit_texture, unlit_use_active_uvmap, _ = gltf2_blender_gather_texture_info.gather_texture_info(
sockets[0],
sockets,
(),
export_settings,
)
return unlit_texture, ["unlitTexture"] if unlit_use_active_uvmap else None

View File

@ -9,12 +9,13 @@ from ....io.exp.gltf2_io_user_extensions import export_user_extensions
from ....io.com import gltf2_io
from ..gltf2_blender_gather_cache import cached
from ..gltf2_blender_gather_sampler import gather_sampler
from . import gltf2_blender_search_node_tree
from ..gltf2_blender_get import get_tex_from_socket
from . import gltf2_blender_gather_image
@cached
def gather_texture(
blender_shader_sockets: typing.Tuple[bpy.types.NodeSocket],
default_sockets: typing.Tuple[bpy.types.NodeSocket],
export_settings):
"""
Gather texture sampling information and image channels from a blender shader texture attached to a shader socket.
@ -27,7 +28,7 @@ def gather_texture(
if not __filter_texture(blender_shader_sockets, export_settings):
return None, None
source, factor = __gather_source(blender_shader_sockets, export_settings)
source, factor = __gather_source(blender_shader_sockets, default_sockets, export_settings)
texture = gltf2_io.Texture(
extensions=__gather_extensions(blender_shader_sockets, export_settings),
@ -67,7 +68,7 @@ def __gather_name(blender_shader_sockets, export_settings):
def __gather_sampler(blender_shader_sockets, export_settings):
shader_nodes = [__get_tex_from_socket(socket) for socket in blender_shader_sockets]
shader_nodes = [get_tex_from_socket(socket) for socket in blender_shader_sockets]
if len(shader_nodes) > 1:
gltf2_io_debug.print_console("WARNING",
"More than one shader node tex image used for a texture. "
@ -78,16 +79,5 @@ def __gather_sampler(blender_shader_sockets, export_settings):
export_settings)
def __gather_source(blender_shader_sockets, export_settings):
return gltf2_blender_gather_image.gather_image(blender_shader_sockets, export_settings)
# Helpers
# TODOExt deduplicate
def __get_tex_from_socket(socket):
result = gltf2_blender_search_node_tree.from_socket(
socket,
gltf2_blender_search_node_tree.FilterByType(bpy.types.ShaderNodeTexImage))
if not result:
return None
return result[0]
def __gather_source(blender_shader_sockets, default_sockets, export_settings):
return gltf2_blender_gather_image.gather_image(blender_shader_sockets, default_sockets, export_settings)

View File

@ -8,7 +8,7 @@ from ....io.com import gltf2_io
from ....io.com.gltf2_io_extensions import Extension
from ....io.exp.gltf2_io_user_extensions import export_user_extensions
from ...exp import gltf2_blender_get
from ..gltf2_blender_get import previous_node
from ..gltf2_blender_get import previous_node, get_tex_from_socket
from ..gltf2_blender_gather_sampler import detect_manual_uv_wrapping
from ..gltf2_blender_gather_cache import cached
from . import gltf2_blender_gather_texture
@ -19,20 +19,25 @@ from . import gltf2_blender_search_node_tree
# occlusion the primary_socket would be the occlusion socket, and
# blender_shader_sockets would be the (O,R,M) sockets.
def gather_texture_info(primary_socket, blender_shader_sockets, export_settings, filter_type='ALL'):
return __gather_texture_info_helper(primary_socket, blender_shader_sockets, 'DEFAULT', filter_type, export_settings)
# Default socket parameter is used when there is a mapping between channels, and one of the channel is not a texture
# In that case, we will create a texture with one channel from texture, other from default socket value
# Example: MetallicRoughness
def gather_texture_info(primary_socket, blender_shader_sockets, default_sockets, export_settings, filter_type='ALL'):
return __gather_texture_info_helper(primary_socket, blender_shader_sockets, default_sockets, 'DEFAULT', filter_type, export_settings)
def gather_material_normal_texture_info_class(primary_socket, blender_shader_sockets, export_settings, filter_type='ALL'):
return __gather_texture_info_helper(primary_socket, blender_shader_sockets, 'NORMAL', filter_type, export_settings)
return __gather_texture_info_helper(primary_socket, blender_shader_sockets, (), 'NORMAL', filter_type, export_settings)
def gather_material_occlusion_texture_info_class(primary_socket, blender_shader_sockets, export_settings, filter_type='ALL'):
return __gather_texture_info_helper(primary_socket, blender_shader_sockets, 'OCCLUSION', filter_type, export_settings)
def gather_material_occlusion_texture_info_class(primary_socket, blender_shader_sockets, default_sockets, export_settings, filter_type='ALL'):
return __gather_texture_info_helper(primary_socket, blender_shader_sockets, default_sockets, 'OCCLUSION', filter_type, export_settings)
@cached
def __gather_texture_info_helper(
primary_socket: bpy.types.NodeSocket,
blender_shader_sockets: typing.Tuple[bpy.types.NodeSocket],
default_sockets: typing.Tuple[bpy.types.NodeSocket],
kind: str,
filter_type: str,
export_settings):
@ -41,7 +46,7 @@ def __gather_texture_info_helper(
tex_transform, tex_coord, use_active_uvmap = __gather_texture_transform_and_tex_coord(primary_socket, export_settings)
index, factor = __gather_index(blender_shader_sockets, export_settings)
index, factor = __gather_index(blender_shader_sockets, default_sockets, export_settings)
fields = {
'extensions': __gather_extensions(tex_transform, export_settings),
@ -72,7 +77,7 @@ def __gather_texture_info_helper(
def __filter_texture_info(primary_socket, blender_shader_sockets, filter_type, export_settings):
if primary_socket is None:
return False
if __get_tex_from_socket(primary_socket) is None:
if get_tex_from_socket(primary_socket) is None:
return False
if not blender_shader_sockets:
return False
@ -80,12 +85,12 @@ def __filter_texture_info(primary_socket, blender_shader_sockets, filter_type, e
return False
if filter_type == "ALL":
# Check that all sockets link to texture
if any([__get_tex_from_socket(socket) is None for socket in blender_shader_sockets]):
if any([get_tex_from_socket(socket) is None for socket in blender_shader_sockets]):
# sockets do not lead to a texture --> discard
return False
elif filter_type == "ANY":
# Check that at least one socket link to texture
if all([__get_tex_from_socket(socket) is None for socket in blender_shader_sockets]):
if all([get_tex_from_socket(socket) is None for socket in blender_shader_sockets]):
return False
elif filter_type == "NONE":
# No check
@ -136,9 +141,9 @@ def __gather_occlusion_strength(primary_socket, export_settings):
return None
def __gather_index(blender_shader_sockets, export_settings):
def __gather_index(blender_shader_sockets, default_sockets, export_settings):
# We just put the actual shader into the 'index' member
return gltf2_blender_gather_texture.gather_texture(blender_shader_sockets, export_settings)
return gltf2_blender_gather_texture.gather_texture(blender_shader_sockets, default_sockets, export_settings)
def __gather_texture_transform_and_tex_coord(primary_socket, export_settings):
@ -148,7 +153,7 @@ def __gather_texture_transform_and_tex_coord(primary_socket, export_settings):
#
# The [UV Wrapping] is for wrap modes like MIRROR that use nodes,
# [Mapping] is for KHR_texture_transform, and [UV Map] is for texCoord.
blender_shader_node = __get_tex_from_socket(primary_socket).shader_node
blender_shader_node = get_tex_from_socket(primary_socket).shader_node
# Skip over UV wrapping stuff (it goes in the sampler)
result = detect_manual_uv_wrapping(blender_shader_node)
@ -178,17 +183,6 @@ def __gather_texture_transform_and_tex_coord(primary_socket, export_settings):
return texture_transform, texcoord_idx or None, use_active_uvmap
# TODOExt deduplicate
def __get_tex_from_socket(socket):
result = gltf2_blender_search_node_tree.from_socket(
socket,
gltf2_blender_search_node_tree.FilterByType(bpy.types.ShaderNodeTexImage))
if not result:
return None
if result[0].shader_node.image is None:
return None
return result[0]
def check_same_size_images(
blender_shader_sockets: typing.Tuple[bpy.types.NodeSocket],
@ -199,7 +193,7 @@ def check_same_size_images(
sizes = set()
for socket in blender_shader_sockets:
tex = __get_tex_from_socket(socket)
tex = get_tex_from_socket(socket)
if tex is None:
return False
size = tex.shader_node.image.size

View File

@ -152,5 +152,5 @@ GLTF_DATA_TYPE_MAT4 = "MAT4"
GLTF_IOR = 1.5
# Rounding digit used for normal rounding
NORMALS_ROUNDING_DIGIT = 4
# Rounding digit used for normal/tangent rounding
ROUNDING_DIGIT = 4

View File

@ -113,7 +113,6 @@ class PIE_MT_SelectionsEM(Menu):
class PIE_MT_SelectAllBySelection(Menu):
bl_idname = "PIE_MT_selectallbyselection"
bl_label = "Verts Edges Faces"
bl_options = {'REGISTER', 'UNDO'}
def draw(self, context):
layout = self.layout
@ -194,7 +193,6 @@ class PIE_OT_vertsedgesfacesop(Operator):
class PIE_MT_SelectLoopSelection(Menu):
bl_idname = "OBJECT_MT_selectloopselection"
bl_label = "Verts Edges Faces"
bl_options = {'REGISTER', 'UNDO'}
def draw(self, context):
layout = self.layout