OverviewFilesCommitsIssuesDocsReleasesPeers

iqm-blender-importer / iqe_import.py

1254 lines · python ref:
# Inter-Quake Import

bl_info = {
	"name": "Import Inter-Quake Model (.iqm, .iqe)",
	"description": "Import Inter-Quake Model.",
	"author": "Tor Andersson",
	"version": (2012, 12, 2),
	"blender": (2, 80, 0),
	"location": "File > Import > Inter-Quake Model",
	"wiki_url": "http://github.com/ccxvii/asstools",
	"category": "Import-Export",
}

import bpy, math, shlex, struct, os, sys, glob

from bpy.props import *
from bpy_extras.io_utils import ImportHelper
from bpy_extras.image_utils import load_image
from mathutils import Matrix, Quaternion, Vector

# see blenkernel/intern/armature.c for vec_roll_to_mat3
# see blenkernel/intern/armature.c for mat3_to_vec_roll
# see makesrna/intern/rna_armature.c for rna_EditBone_matrix_get

# Compatiblity shims
if bpy.app.version >= (2, 80, 0):
	def matmul(x, y): return x.__matmul__(y)
	def make_group(name): return bpy.data.collections.new(name)
	def link_object(ob): bpy.context.scene.collection.objects.link(ob)
	def select_ob(ob, state=True): ob.select_set(state=state)
else:
	def matmul(x, y): return x * y
	def make_group(name): return bpy.data.groups.new(name)
	def link_object(ob): bpy.context.scene.objects.link(ob)
	def select_ob(ob, state=True): ob.select = state

def vec_roll_to_mat3(vec, roll):
	target = Vector((0,1,0))
	nor = vec.normalized()
	axis = target.cross(nor)
	if axis.dot(axis) > 0.000001:
		axis.normalize()
		theta = target.angle(nor)
		bMatrix = Matrix.Rotation(theta, 3, axis)
	else:
		updown = 1 if target.dot(nor) > 0 else -1
		bMatrix = Matrix.Scale(updown, 3)
	rMatrix = Matrix.Rotation(roll, 3, nor)
	mat = matmul(rMatrix, bMatrix)
	return mat

def mat3_to_vec_roll(mat):
	vec = mat.col[1]
	vecmat = vec_roll_to_mat3(mat.col[1], 0)
	vecmatinv = vecmat.copy()
	vecmatinv.invert()
	rollmat = matmul(vecmatinv, mat)
	roll = math.atan2(rollmat[0][2], rollmat[2][2])
	return vec, roll

#
# Inter-Quake Model structs
#

class IQMesh:
	def __init__(self, name):
		self.name = name
		self.material = ["unknown"]
		self.faces = []
		self.vp = []
		self.vn = []
		self.vt = []
		self.vc = []
		self.vbi = []
		self.vbw = []
		self.v0 = []
		self.v1 = []
		self.v2 = []
		self.v3 = []
		self.v4 = []
		self.v5 = []
		self.v6 = []
		self.v7 = []
		self.v8 = []
		self.v9 = []

class IQBone:
	def __init__(self, name, parent):
		self.name = name
		self.parent = parent

class IQPose:
	def __init__(self, data):
		self.translate = data[0:3]
		self.rotate = data[3:7]
		if len(data) > 7:
			self.scale = data[7:10]
		else:
			self.scale = (1,1,1)

class IQAnimation:
	def __init__(self, name):
		self.name = name
		self.framerate = 30
		self.loop = False
		self.frames = []

class IQModel:
	def __init__(self, name):
		self.name = name
		self.bones = []
		self.bindpose = []
		self.meshes = []
		self.anims = []
		self.custom_name = {}
		self.custom_size = {}

#
# IQE parser
#

def load_iqe(filename):
	name = filename.split("/")[-1].split("\\")[-1].split(".")[0]
	model = IQModel(name)
	file = open(filename)
	line = file.readline()
	if not line.startswith("# Inter-Quake Export"):
		raise Exception("Not an IQE file!")
	curpose = model.bindpose
	curmesh = None
	curanim = None
	for line in file.readlines():
		if "#" in line or "\"" in line:
			line = shlex.split(line, "#")
		else:
			line = line.split()
		if len(line) == 0:
			pass
		elif line[0] == "vertexarray":
			if line[1].startswith("custom"):
				N = int(line[1][6:])
				model.custom_name[N] = line[4] if len(line) > 4 else line[1]
				model.custom_size[N] = int(line[3])
		elif line[0] == "joint":
			model.bones.append(IQBone(line[1], int(line[2])))
		elif line[0] == "pq":
			curpose.append(IQPose([float(x) for x in line[1:]]))
		elif line[0] == "pm": raise Exception("pm style poses not implemented yet")
		elif line[0] == "pa": raise Exception("pa style poses not implemented yet")
		elif line[0] == "mesh":
			curmesh = IQMesh(line[1])
			model.meshes.append(curmesh)
		elif line[0] == "material":
			curmesh.material = line[1].split("+")
		elif line[0] == "vp": curmesh.vp.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "vn": curmesh.vn.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "vt": curmesh.vt.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "vc": curmesh.vc.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "v0": curmesh.v0.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "v1": curmesh.v1.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "v2": curmesh.v2.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "v3": curmesh.v3.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "v4": curmesh.v4.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "v5": curmesh.v5.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "v6": curmesh.v6.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "v7": curmesh.v7.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "v8": curmesh.v8.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "v9": curmesh.v9.append(tuple([float(x) for x in line[1:]]))
		elif line[0] == "vb":
			vbi = []
			vbw = []
			i = 1
			while i + 1 < len(line):
				vbi.append(int(line[i]))
				vbw.append(float(line[i+1]))
				i = i + 2
			curmesh.vbi.append(tuple(vbi))
			curmesh.vbw.append(tuple(vbw))
		elif line[0] == "fm":
			f = [int(x) for x in line[1:]]
			if len(f) > 4:
				print("triangulating n-gon with %d sides %s" % (len(f), f))
				i = 1
				for j in range(2,len(f)):
					curmesh.faces.append((f[0], f[i], f[j]))
					i = j
			else:
				curmesh.faces.append(tuple(f))
		elif line[0] == "fa": raise Exception("fa style faces not implemented yet")
		elif line[0] == "animation":
			curanim = IQAnimation(line[1])
			model.anims.append(curanim)
		elif line[0] == "framerate":
			curanim.framerate = float(line[1])
		elif line[0] == "loop":
			curanim.loop = True
		elif line[0] == "frame":
			curpose = []
			curanim.frames.append(curpose)
	return model

#
# IQM Parser
#

IQM_POSITION = 0
IQM_TEXCOORD = 1
IQM_NORMAL = 2
IQM_BLENDINDEXES = 4
IQM_BLENDWEIGHTS = 5
IQM_COLOR = 6
IQM_CUSTOM = 0x10

IQM_BYTE = 0
IQM_UBYTE = 1
IQM_SHORT = 2
IQM_USHORT = 3
IQM_INT = 4
IQM_UINT = 5
IQM_HALF = 6
IQM_FLOAT = 7
IQM_DOUBLE = 8

IQM_FORMAT = {
	IQM_BYTE: "b",
	IQM_UBYTE: "B",
	IQM_SHORT: "h",
	IQM_USHORT: "H",
	IQM_INT: "i",
	IQM_UINT: "I",
	IQM_FLOAT: "f",
	IQM_DOUBLE: "d"
}

def cstr(text, ofs):
	len = 0
	while text[ofs+len] != 0:
		len += 1
	return str(text[ofs:ofs+len], encoding='utf-8')

def load_iqm_structs(file, fmt, count, offset):
	size = struct.calcsize(fmt)
	file.seek(offset)
	return [struct.unpack(fmt, file.read(size)) for n in range(count)]

def load_iqm(filename):
	name = filename.split("/")[-1].split("\\")[-1].split(".")[0]
	model = IQModel(name)
	file = open(filename, "rb")

	hdr = struct.unpack("<16s27I", file.read(124));
	( magic, version, filesize, flags,
		num_text, ofs_text,
		num_meshes, ofs_meshes,
		num_vertexarrays, num_vertexes, ofs_vertexarrays,
		num_triangles, ofs_triangles, ofs_adjacency,
		num_joints, ofs_joints,
		num_poses, ofs_poses,
		num_anims, ofs_anims,
		num_frames, num_framechannels, ofs_frames, ofs_bounds,
		num_comment, ofs_comment,
		num_extensions, ofs_extensions ) = hdr

	if magic != b"INTERQUAKEMODEL\0":
		raise Exception("Not an IQM file: '%s'", magic)
	if version != 2:
		raise Exception("Not an IQMv2 file.")

	file.seek(ofs_text)
	text = file.read(num_text);

	load_iqm_joints(model, file, text, num_joints, ofs_joints)

	vadata = load_iqm_vertexarrays(model, file, text, num_vertexarrays, num_vertexes, ofs_vertexarrays)
	triangles = load_iqm_structs(file, "<3I", num_triangles, ofs_triangles)
	load_iqm_meshes(model, file, text, num_meshes, ofs_meshes, vadata, triangles)

	poses = load_iqm_structs(file, "<iI20f", num_poses, ofs_poses)
	frames = load_iqm_structs(file, "<" + "H" * num_framechannels, num_frames, ofs_frames)
	load_iqm_anims(model, file, text, num_anims, ofs_anims, poses, frames)

	return model

def load_iqm_joints(model, file, text, num_joints, ofs_joints):
	file.seek(ofs_joints)
	for n in range(num_joints):
		data = struct.unpack("<Ii10f", file.read(12*4))
		name = cstr(text, data[0])
		parent = data[1]
		pose = data[2:12]
		model.bones.append(IQBone(name, parent))
		model.bindpose.append(IQPose(data[2:12]))

def load_iqm_vertexarray(file, type, format, size, offset, count):
	if format not in IQM_FORMAT:
		raise Exception("unknown vertex array data type: %d" % format)
	data = load_iqm_structs(file, "<" + IQM_FORMAT[format] * size, count, offset)
	if type == IQM_BLENDINDEXES:
		return data
	if format == IQM_BYTE: return [ tuple([x/127.0 for x in v]) for v in data ]
	if format == IQM_UBYTE: return [ tuple([x/255.0 for x in v]) for v in data ]
	if format == IQM_SHORT: return [ tuple([x/32767.0 for x in v]) for v in data ]
	if format == IQM_USHORT: return [ tuple([x/65535.0 for x in v]) for v in data ]
	if format == IQM_INT: return [ tuple([x/2147483647.0 for x in v]) for v in data ]
	if format == IQM_UINT: return [ tuple([x/4294967295.0 for x in v]) for v in data ]
	return data

def load_iqm_vertexarrays(model, file, text, num_vertexarrays, num_vertexes, ofs_vertexarrays):
	va = load_iqm_structs(file, "<5I", num_vertexarrays, ofs_vertexarrays)
	model.custom_name = []
	model.custom_size = []
	vadata = {}
	for type, flags, format, size, offset in va:
		if type >= IQM_CUSTOM:
			name = cstr(text, type - IQM_CUSTOM)
			type = len(model.custom_name) + IQM_CUSTOM
			model.custom_name.append(name)
			model.custom_size.append(size)
		vadata[type] = load_iqm_vertexarray(file, type, format, size, offset, num_vertexes)
	return vadata

def copy_iqm_verts(mesh, vadata, first, count):
	for n in range(first, first+count):
		if IQM_POSITION in vadata: mesh.vp.append(vadata[IQM_POSITION][n])
		if IQM_NORMAL in vadata: mesh.vn.append(vadata[IQM_NORMAL][n])
		if IQM_TEXCOORD in vadata: mesh.vt.append(vadata[IQM_TEXCOORD][n])
		if IQM_COLOR in vadata: mesh.vc.append(vadata[IQM_COLOR][n])
		if IQM_BLENDINDEXES in vadata and IQM_BLENDWEIGHTS in vadata:
			vbi = []
			vbw = []
			for y in range(4):
				if vadata[IQM_BLENDWEIGHTS][n][y] > 0:
					vbi.append(vadata[IQM_BLENDINDEXES][n][y])
					vbw.append(vadata[IQM_BLENDWEIGHTS][n][y])
			mesh.vbi.append(tuple(vbi))
			mesh.vbw.append(tuple(vbw))
		if IQM_CUSTOM+0 in vadata: mesh.v0.append(vadata[IQM_CUSTOM+0][n])
		if IQM_CUSTOM+1 in vadata: mesh.v1.append(vadata[IQM_CUSTOM+1][n])
		if IQM_CUSTOM+2 in vadata: mesh.v2.append(vadata[IQM_CUSTOM+2][n])
		if IQM_CUSTOM+3 in vadata: mesh.v3.append(vadata[IQM_CUSTOM+3][n])
		if IQM_CUSTOM+4 in vadata: mesh.v4.append(vadata[IQM_CUSTOM+4][n])
		if IQM_CUSTOM+5 in vadata: mesh.v5.append(vadata[IQM_CUSTOM+5][n])
		if IQM_CUSTOM+6 in vadata: mesh.v6.append(vadata[IQM_CUSTOM+6][n])
		if IQM_CUSTOM+7 in vadata: mesh.v7.append(vadata[IQM_CUSTOM+7][n])
		if IQM_CUSTOM+8 in vadata: mesh.v8.append(vadata[IQM_CUSTOM+8][n])
		if IQM_CUSTOM+9 in vadata: mesh.v9.append(vadata[IQM_CUSTOM+9][n])

def load_iqm_meshes(model, file, text, num_meshes, ofs_meshes, vadata, triangles):
	file.seek(ofs_meshes)
	for n in range(num_meshes):
		name, material, vfirst, vcount, tfirst, tcount = struct.unpack("<6I", file.read(6*4))
		mesh = IQMesh(cstr(text, name))
		mesh.material = cstr(text, material).split("+")
		copy_iqm_verts(mesh, vadata, vfirst, vcount)
		mesh.faces = [(a-vfirst, b-vfirst, c-vfirst) for a,b,c in triangles[tfirst:tfirst+tcount]]
		model.meshes.append(mesh)

def copy_iqm_frame(poselist, frame):
	masktest = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x100, 0x200]
	out = []
	p = 0
	for pose in poselist:
		mask = pose[1]
		choffset = pose[2:2+10]
		chscale = pose[2+10:2+10+10]
		data = [x for x in choffset] # make a copy
		for n in range(10):
			if mask & masktest[n]:
				data[n] += chscale[n] * frame[p]
				p += 1
		out.append(IQPose(data))
	return out

def load_iqm_anims(model, file, text, num_anims, ofs_anims, poses, frames):
	file.seek(ofs_anims)
	for n in range(num_anims):
		name, first, count, framerate, loop = struct.unpack("<3IfI", file.read(5*4))
		anim = IQAnimation(cstr(text, name))
		anim.framerate = framerate
		anim.loop = loop
		anim.frames = [copy_iqm_frame(poses, frame) for frame in frames[first:first+count]]
		model.anims.append(anim)

#
# Create armature from joints and the bind pose.
#
# We preserve matrices and the bone orientations, by
# duplicating the conversion that blender does internally
# to map between bone head/tail/roll and a matrix.
#
# Blender assumes all bones extend in the Y-axis,
# but that's not true for all assets. Depending on
# the bone_axis setting we can rotate the bones from
# extending in the X-axis or Z-axis to the Y-axis,
# or leave them untouched.
#

# Calculate armature space matrices for all bones in a pose

def calc_pose_mats(iqmodel, iqpose, bone_axis):
	loc_pose_mat = [None] * len(iqmodel.bones)
	abs_pose_mat = [None] * len(iqmodel.bones)
	recalc = False

	# convert pose to local matrix and compute absolute matrix
	for n in range(len(iqmodel.bones)):
		iqbone = iqmodel.bones[n]

		pose_pos = iqpose[n].translate
		pose_rot = iqpose[n].rotate
		pose_scale = iqpose[n].scale

		local_pos = Vector(pose_pos)
		local_rot = Quaternion((pose_rot[3], pose_rot[0], pose_rot[1], pose_rot[2]))
		local_scale = Vector(pose_scale)

		mat_pos = Matrix.Translation(local_pos)
		mat_rot = local_rot.to_matrix().to_4x4()
		mat_scale = Matrix.Scale(local_scale.x, 3).to_4x4()
		loc_pose_mat[n] = matmul(matmul(mat_pos, mat_rot), mat_scale)

		if iqbone.parent >= 0:
			abs_pose_mat[n] = matmul(abs_pose_mat[iqbone.parent], loc_pose_mat[n])
		else:
			abs_pose_mat[n] = loc_pose_mat[n]

	# Remove negative scaling from bones.
	# Due to numerical instabilities in blender's matrix <-> head/tail/roll math
	# this isn't always stable when the bones are in the X axis. If the bones
	# end up rotated 90 degrees from what they should be, that's the reason.
	for n in range(len(iqmodel.bones)):
		if abs_pose_mat[n].is_negative:
			if not hasattr(iqmodel, 'abs_bind_mat'):
				print("warning: removing negative scale in bone", iqmodel.bones[n].name)
			abs_pose_mat[n] = matmul(abs_pose_mat[n], Matrix.Scale(-1, 4))
			recalc = True

	# flip bone axis (and recompute local matrix if needed)
	if bone_axis == 'X':
		axis_flip = Matrix.Rotation(math.radians(-90), 4, 'Z')
		abs_pose_mat = [matmul(m, axis_flip) for m in abs_pose_mat]
		recalc = True
	if bone_axis == 'Z':
		axis_flip = Matrix.Rotation(math.radians(-90), 4, 'X')
		abs_pose_mat = [matmul(m, axis_flip) for m in abs_pose_mat]
		recalc = True

	if recalc:
		inv_pose_mat = [m.inverted() for m in abs_pose_mat]
		for n in range(len(iqmodel.bones)):
			iqbone = iqmodel.bones[n]
			if iqbone.parent >= 0:
				loc_pose_mat[n] = matmul(inv_pose_mat[iqbone.parent], abs_pose_mat[n])
			else:
				loc_pose_mat[n] = abs_pose_mat[n]

	return loc_pose_mat, abs_pose_mat

def compute_bind_mats(iqmodel, bone_axis):
	# Derive the bind-pose matrices used to convert per-frame poses into bone
	# space. Stored on the model so both armature creation and (later)
	# animation application can reuse them.
	loc_bind_mat, abs_bind_mat = calc_pose_mats(iqmodel, iqmodel.bindpose, bone_axis)
	iqmodel.abs_bind_mat = abs_bind_mat
	iqmodel.loc_bind_mat = loc_bind_mat
	iqmodel.inv_loc_bind_mat = [m.inverted() for m in loc_bind_mat]

def make_armature(iqmodel, bone_axis):

	if len(iqmodel.bones) == 0: return None
	if len(iqmodel.bindpose) != len(iqmodel.bones): return None

	print("importing armature with %d bones" % len(iqmodel.bones))

	# Need to be in object mode before we can enter edit mode; this throws if
	# we're already in object mode
	try:
		bpy.ops.object.mode_set(mode="OBJECT")
	except RuntimeError:
		pass

	bpy.ops.object.add(type="ARMATURE", enter_editmode=True)
	obj, amt = bpy.context.object, bpy.context.object.data
	amt.name = iqmodel.name
	obj.name = iqmodel.name + ".amt"

	compute_bind_mats(iqmodel, bone_axis)
	abs_bind_mat = iqmodel.abs_bind_mat

	for n in range(len(iqmodel.bones)):
		iqbone = iqmodel.bones[n]

		bone = amt.edit_bones.new(iqbone.name)
		parent = None
		if iqbone.parent >= 0:
			parent = amt.edit_bones[iqbone.parent]
			bone.parent = parent

		# TODO: bone scaling
		pos = abs_bind_mat[n].to_translation()
		axis, roll = mat3_to_vec_roll(abs_bind_mat[n].to_3x3())
		axis *= 0.125 # short bones
		bone.roll = roll
		bone.head = pos
		bone.tail = pos + axis

		# extend parent and connect if we are aligned
		if parent:
			a = (bone.head - parent.head).normalized()
			b = (parent.tail - parent.head).normalized()
			if a.dot(b) > 0.9999:
				parent.tail = bone.head
				bone.use_connect = True

	bpy.ops.object.mode_set(mode='OBJECT')

	return obj

#
# Strike a pose.
#

def make_pose(iqmodel, frame, amtobj, bone_axis, tick):
	loc_pose_mat, _ = calc_pose_mats(iqmodel, frame, bone_axis)
	for n in range(len(iqmodel.bones)):
		name = iqmodel.bones[n].name
		pose_bone = amtobj.pose.bones.get(name)
		if pose_bone is None:
			continue
		pose_bone.matrix_basis = matmul(iqmodel.inv_loc_bind_mat[n], loc_pose_mat[n])
		pose_bone.keyframe_insert(group=name, frame=tick, data_path='location')
		pose_bone.keyframe_insert(group=name, frame=tick, data_path='rotation_quaternion')
		pose_bone.keyframe_insert(group=name, frame=tick, data_path='scale')

def make_anim(iqmodel, anim, amtobj, bone_axis):
	print("importing animation %s with %d frames" % (anim.name, len(anim.frames)))
	action = bpy.data.actions.new(anim.name)
	# id_root was removed in Blender 4.4 (slotted actions); the root type is
	# inferred from the id the action is assigned to, so only set it if present.
	if hasattr(action, "id_root"):
		action.id_root = 'OBJECT'
	action.use_fake_user = True
	amtobj.animation_data.action = action
	for n in range(len(anim.frames)):
		make_pose(iqmodel, anim.frames[n], amtobj, bone_axis, n)
	return action

def make_actions(iqmodel, amtobj, bone_axis):
	bpy.context.scene.frame_start = 0
	amtobj.animation_data_create()
	for anim in iqmodel.anims:
		action = make_anim(iqmodel, anim, amtobj, bone_axis)

#
# Create simple material by looking at the magic words.
# Use the last word as a texture name by appending ".png".
#

images = {}
of_textures_cache = {}

def load_of_textures(dir):
	# OctaForge / Tesseract models name their textures in an iqm.cfg sidecar,
	# not in the IQM material strings, and they pack data into channels very
	# deliberately. Parse iqm.cfg so we can build a faithful material. See
	# config/glsl/model.cfg in the engine for the exact channel semantics:
	#
	#   iqmskin   * <tex> <masks> ...  tex0 -> base colour (rgb), alpha=alphatest
	#                                  tex1 -> masks: R=spec  G=glow  B=envmap
	#   iqmdecal  * <decal>            tex4 -> decal mixed over base by decal.a
	#   iqmbumpmap* <normalmap>        tex3 -> tangent-space normal map (rgb)
	#   mdlspec/iqmspec <percent>      spec multiplier (maskscale.x)
	#
	# Returns { material-name-or-'*': {tex,masks,decal,normal,spec} }.
	if dir in of_textures_cache:
		return of_textures_cache[dir]
	cfg = {}
	path = os.path.join(dir, "iqm.cfg")
	if os.path.exists(path):
		for line in open(path):
			try:
				parts = shlex.split(line, comments=True)
			except ValueError:
				continue
			if not parts: continue
			cmd = parts[0]
			if cmd == "iqmskin" and len(parts) >= 3:
				ent = cfg.setdefault(parts[1], {})
				ent['tex'] = parts[2]
				if len(parts) >= 4 and parts[3]: ent['masks'] = parts[3]
			elif cmd == "iqmdecal" and len(parts) >= 3:
				cfg.setdefault(parts[1], {})['decal'] = parts[2]
			elif cmd == "iqmbumpmap" and len(parts) >= 3:
				cfg.setdefault(parts[1], {})['normal'] = parts[2]
			elif cmd == "iqmspec" and len(parts) >= 3:
				try: cfg.setdefault(parts[1], {})['spec'] = float(parts[2]) / 100.0
				except ValueError: pass
			elif cmd == "mdlspec" and len(parts) >= 2:
				try: cfg.setdefault('*', {})['spec'] = float(parts[1]) / 100.0
				except ValueError: pass
	of_textures_cache[dir] = cfg
	return cfg

def of_entry(dir, name):
	# Merge the wildcard '*' entry with any material-specific overrides.
	cfg = load_of_textures(dir)
	ent = dict(cfg.get('*', {}))
	ent.update(cfg.get(name, {}))
	return ent

def cached_image(key, path, dir, non_color=False):
	if key not in images:
		print("load image", path)
		images[key] = load_image(path, dir, place_holder=True, recursive=True)
	img = images[key]
	if img and non_color:
		img.colorspace_settings.name = 'Non-Color'
	return img

def build_pbr_nodes(mat, image, ent, dir):
	# Build a node material that mirrors how OctaForge uses the texture
	# channels (see load_of_textures for the mapping).
	mat.use_nodes = True
	nt = mat.node_tree
	prin = nt.nodes['Principled BSDF']
	lk = nt.links.new
	x = -900

	# Base colour: skin texture, with the decal mixed over it by the decal's
	# alpha (model.cfg: gcolor.rgb = mix(tex0.rgb, decal.rgb, decal.a)).
	aotex = nt.nodes.new('ShaderNodeTexImage'); aotex.image = image; aotex.location = (x, 340)
	basecol = aotex.outputs['Color']
	decal = ent.get('decal') and cached_image(ent['decal'], ent['decal'], dir)
	if decal:
		dtex = nt.nodes.new('ShaderNodeTexImage'); dtex.image = decal; dtex.location = (x, 60)
		mix = nt.nodes.new('ShaderNodeMixRGB'); mix.location = (x + 300, 300)
		lk(dtex.outputs['Alpha'], mix.inputs['Factor'])
		lk(aotex.outputs['Color'], mix.inputs['Color1'])
		lk(dtex.outputs['Color'], mix.inputs['Color2'])
		basecol = mix.outputs['Color']
	lk(basecol, prin.inputs['Base Color'])

	# Tesseract armour is reflective; a fully-rough default looks wrong.
	prin.inputs['Roughness'].default_value = 0.4

	# Masks texture: R=spec, G=glow, B=envmap reflection.
	masks = ent.get('masks') and cached_image(ent['masks'], ent['masks'], dir, non_color=True)
	if masks:
		mtex = nt.nodes.new('ShaderNodeTexImage'); mtex.image = masks; mtex.location = (x, -240)
		msep = nt.nodes.new('ShaderNodeSeparateColor'); msep.location = (x + 300, -240)
		lk(mtex.outputs['Color'], msep.inputs['Color'])
		# R -> specular level (scaled by the model's spec percentage)
		spec = ent.get('spec', 1.0)
		if abs(spec - 1.0) > 1e-3:
			sm = nt.nodes.new('ShaderNodeMath'); sm.operation = 'MULTIPLY'
			sm.inputs[1].default_value = spec; sm.location = (x + 500, -160)
			lk(msep.outputs['Red'], sm.inputs[0]); lk(sm.outputs[0], prin.inputs['Specular IOR Level'])
		else:
			lk(msep.outputs['Red'], prin.inputs['Specular IOR Level'])
		# B -> metallic (envmap reflection)
		lk(msep.outputs['Blue'], prin.inputs['Metallic'])
		# G -> glow, as emission tinted by the base colour
		lk(basecol, prin.inputs['Emission Color'])
		lk(msep.outputs['Green'], prin.inputs['Emission Strength'])

	# Tangent-space normal map with the GREEN channel inverted. The importer
	# flips UV V to match Blender's bottom-up convention, so a normal map
	# authored for the engine's top-down V needs the standard OpenGL<->DirectX
	# green flip. Verified numerically against the OctaForge renderer: with the
	# flip the shading normal matches to <0.1 deg; without it the error is ~10
	# deg and shows up as per-piece inverted shading on mirrored-UV parts.
	normal = ent.get('normal') and cached_image(ent['normal'], ent['normal'], dir, non_color=True)
	if normal:
		ntex = nt.nodes.new('ShaderNodeTexImage'); ntex.image = normal; ntex.location = (x, -560)
		sep = nt.nodes.new('ShaderNodeSeparateColor'); sep.location = (x + 300, -560)
		invg = nt.nodes.new('ShaderNodeMath'); invg.operation = 'SUBTRACT'
		invg.inputs[0].default_value = 1.0; invg.location = (x + 500, -620)
		comb = nt.nodes.new('ShaderNodeCombineColor'); comb.location = (x + 680, -560)
		nmap = nt.nodes.new('ShaderNodeNormalMap'); nmap.location = (x + 860, -560)
		lk(ntex.outputs['Color'], sep.inputs['Color'])
		lk(sep.outputs['Red'], comb.inputs['Red'])
		lk(sep.outputs['Green'], invg.inputs[1])
		lk(invg.outputs[0], comb.inputs['Green'])
		lk(sep.outputs['Blue'], comb.inputs['Blue'])
		lk(comb.outputs['Color'], nmap.inputs['Color'])
		lk(nmap.outputs['Normal'], prin.inputs['Normal'])

	mat.blend_method = 'CLIP'

def make_material(iqmaterial, dir):
	print("importing material", iqmaterial)

	matname = ";".join(iqmaterial)
	texname = iqmaterial[-1]

	# reuse materials if possible
	if matname in bpy.data.materials:
		return bpy.data.materials[matname], images.get(texname)

	twosided = 'twosided' in iqmaterial
	alphatest = 'alphatest' in iqmaterial
	unlit = 'unlit' in iqmaterial

	# Prefer the OctaForge skin texture, else the legacy textures/<name>.png.
	ent = of_entry(dir, texname)
	if ent.get('tex'):
		image = cached_image(ent['tex'], ent['tex'], dir)
	else:
		image = cached_image(texname, "textures/" + texname + ".png", dir)

	mat = bpy.data.materials.new(matname)
	if bpy.app.version < (2, 80, 0):
		if texname in bpy.data.textures:
			tex = bpy.data.textures[texname]
		else:
			tex = bpy.data.textures.new(texname, type = 'IMAGE')
			tex.image = image
			tex.use_alpha = True

		mat.diffuse_intensity = 1
		mat.specular_intensity = 0
		mat.alpha = 0.0

		texslot = mat.texture_slots.add()
		texslot.texture = tex
		texslot.texture_coords = 'UV'
		texslot.uv_layer = "UVMap"
		texslot.use_map_color_diffuse = True
		texslot.use_map_alpha = True

		if unlit: mat.use_shadeless = True
		mat.use_transparency = True

		# blender game engine
		mat.game_settings.use_backface_culling = not twosided
		mat.game_settings.alpha_blend = 'CLIP'
		if alphatest and unlit: mat.game_settings.alpha_blend = 'ADD'

	else:
		build_pbr_nodes(mat, image, ent, dir)

	# return the material (and image so we can link the uvlayer faces)
	return mat, image

#
# Create mesh object with normals, texcoords, vertex colors,
# and an armature modifier if the model is skinned.
#

def gather_meshes(iqmodel):
	meshes = {}
	for mesh in iqmodel.meshes:
		if mesh.name not in meshes:
			meshes[mesh.name] = [ mesh ]
		else:
			meshes[mesh.name] += [ mesh ]
	return meshes

def reorder(f, ft, fc):
	# funny shit! see bpy_extras.io_utils.unpack_face_list()
	if len(f) == 3:
		if f[2] == 0:
			f = f[1], f[2], f[0]
			ft = ft[1], ft[2], ft[0]
			fc = fc[1], fc[2], fc[0]
	else: # assume quad
		if f[3] == 0 or f[2] == 0:
			f = f[2], f[3], f[0], f[1]
			ft = ft[2], ft[3], ft[0], ft[1]
			fc = fc[2], fc[3], fc[0], fc[1]
	return f, ft, fc

def isdegenerate(f):
	if len(f) == 3:
		a, b, c = f
		return a == b or a == c or b == c
	if len(f) == 4:
		a, b, c, d = f
		return a == b or a == c or a == d or b == c or b == d
	return True

def make_mesh_data(iqmodel, name, meshes, amtobj, dir):
	print("importing mesh", name, "with", len(meshes), "parts")

	mesh = bpy.data.meshes.new(name)
	obj = bpy.data.objects.new(name, mesh)
	link_object(obj)

	# Set the mesh to single-sided to spot normal errors
	mesh.use_mirror_topology = False

	has_vn = len(iqmodel.meshes[0].vn) > 0
	has_vt = len(iqmodel.meshes[0].vt) > 0
	has_vc = len(iqmodel.meshes[0].vc) > 0
	has_vb = len(iqmodel.meshes[0].vbi) > 0 and len(iqmodel.meshes[0].vbw) == len(iqmodel.meshes[0].vbi)
	has_v0 = len(iqmodel.meshes[0].v0) > 0
	has_v1 = len(iqmodel.meshes[0].v1) > 0
	has_v2 = len(iqmodel.meshes[0].v2) > 0
	has_v3 = len(iqmodel.meshes[0].v3) > 0
	has_v4 = len(iqmodel.meshes[0].v4) > 0
	has_v5 = len(iqmodel.meshes[0].v5) > 0
	has_v6 = len(iqmodel.meshes[0].v6) > 0
	has_v7 = len(iqmodel.meshes[0].v7) > 0
	has_v8 = len(iqmodel.meshes[0].v8) > 0
	has_v9 = len(iqmodel.meshes[0].v9) > 0

	# Flip winding and UV coords.

	for iqmesh in meshes:
		iqmesh.faces = [x[::-1] for x in iqmesh.faces]
		iqmesh.vt = [(u,1-v) for (u,v) in iqmesh.vt]

	# Blender has texcoords and colors on faces rather than vertices.
	# Create material slots for all materials used.
	# Create new vertices from unique vp/vn/vb sets (vertex data).
	# Create new faces which index these new vertices, and has associated face data.

	vertex_map = {}

	new_f = []
	new_ft = []
	new_fc = []
	new_fm_m = []
	new_fm_i = []

	new_vp = []
	new_vn = []
	new_vbi = []
	new_vbw = []
	new_v0 = []
	new_v1 = []
	new_v2 = []
	new_v3 = []
	new_v4 = []
	new_v5 = []
	new_v6 = []
	new_v7 = []
	new_v8 = []
	new_v9 = []

	for iqmesh in meshes:
		material, image = make_material(iqmesh.material, dir)
		if material.name not in mesh.materials:
			mesh.materials.append(material)
		material_index = mesh.materials.find(material.name)

		for iqface in iqmesh.faces:
			f = []
			ft = []
			fc = []
			for iqvert in iqface:
				vp = iqmesh.vp[iqvert]
				vn = iqmesh.vn[iqvert] if has_vn else None
				vbi = iqmesh.vbi[iqvert] if has_vb else None
				vbw = iqmesh.vbw[iqvert] if has_vb else None
				v0 = iqmesh.v0[iqvert] if has_v0 else None
				v1 = iqmesh.v1[iqvert] if has_v1 else None
				v2 = iqmesh.v2[iqvert] if has_v2 else None
				v3 = iqmesh.v3[iqvert] if has_v3 else None
				v4 = iqmesh.v4[iqvert] if has_v4 else None
				v5 = iqmesh.v5[iqvert] if has_v5 else None
				v6 = iqmesh.v6[iqvert] if has_v6 else None
				v7 = iqmesh.v7[iqvert] if has_v7 else None
				v8 = iqmesh.v8[iqvert] if has_v8 else None
				v9 = iqmesh.v9[iqvert] if has_v9 else None
				vertex = (vp, vn, vbi, vbw, v0, v1, v2, v3, v4, v5, v6, v7, v8, v9)
				if not vertex in vertex_map:
					vertex_map[vertex] = len(new_vp)
					new_vp.append(vp)
					new_vn.append(vn)
					new_vbi.append(vbi)
					new_vbw.append(vbw)
					new_v0.append(v0)
					new_v1.append(v1)
					new_v2.append(v2)
					new_v3.append(v3)
					new_v4.append(v4)
					new_v5.append(v5)
					new_v6.append(v6)
					new_v7.append(v7)
					new_v8.append(v8)
					new_v9.append(v9)
				f.append(vertex_map[vertex])
				ft.append(iqmesh.vt[iqvert] if has_vt else None)
				fc.append(iqmesh.vc[iqvert] if has_vc else None)
			f, ft, fc = reorder(f, ft, fc)  # XXX: do we need this?
			if isdegenerate(f):
				print("degenerate face", iqface, f)
				continue
			# XXX: what about duplicate faces?
			new_f.append(f)
			new_ft.append(ft)
			new_fc.append(fc)
			new_fm_m.append(material_index)
			new_fm_i.append(image)

	print("\tcollected %d vertices and %d faces" % (len(new_vp), len(new_f)))

	# Create mesh vertex and face data

	mesh.from_pydata(new_vp, [], new_f)

	# Set up UV and Color layers.
	#
	# Create all custom-data layers first, remembering their names. In Blender
	# 2.9+ adding a loop layer (e.g. the color attribute) reallocates the loop
	# CustomData, which invalidates any previously-held layer handle. Writing UV
	# data through such a stale handle produces garbage, so we re-fetch live
	# handles by name *after* every layer has been created.

	uvtexture = None
	uvlayer_name = None
	clayer_name = None

	if has_vt:
		if getattr(mesh.uv_layers, 'new', None) is not None:
			uvlayer_name = mesh.uv_layers.new().name
		else:
			uvtexture = mesh.uv_textures.new()
			uvlayer_name = mesh.uv_layers[0].name
	if has_vc:
		clayer_name = mesh.vertex_colors.new().name

	uvlayer = mesh.uv_layers[uvlayer_name] if uvlayer_name else None
	clayer = mesh.vertex_colors[clayer_name] if clayer_name else None

	# Define function for switching to RGB/RGBA as appropriate

	if has_vc:
		num_colors = len(new_fc[0][0])
		num_blender_colors = len(clayer.data[0].color)
		if num_colors == 3 and num_blender_colors == 4:
			def color(rgb): return rgb + [1]
		elif num_colors == 4 and num_blender_colors == 3:
			def color(rgba): return rgba[0:3]
		elif num_colors == num_blender_colors:
			def color(c): return c

	for poly_i, poly in enumerate(mesh.polygons):
		poly.use_smooth = True
		poly.material_index = new_fm_m[poly_i]
		if uvlayer:
			for i, loop_i in enumerate(poly.loop_indices):
				uvlayer.data[loop_i].uv = new_ft[poly_i][i]
		if clayer:
			for i, loop_i in enumerate(poly.loop_indices):
				clayer.data[loop_i].color = color(new_fc[poly_i][i])

	if has_vt and uvtexture:
		for poly_i, poly in enumerate(mesh.polygons):
			uvtexture.data[poly_i].image = new_fm_i[poly_i]

	# Validate only after all per-face (poly_i indexed) data has been written.
	# validate() removes duplicate/degenerate faces and compacts polygon and
	# loop indices; per-loop UV/colour data already attached to the loops moves
	# with them, but data written afterwards by poly_i would be misaligned.
	mesh.validate()

	# Vertex groups and armature modifier for skinning

	if has_vb and amtobj:
		for iqbone in iqmodel.bones:
			obj.vertex_groups.new(name=iqbone.name)

		for vgroup in obj.vertex_groups:
			for v, vbi in enumerate(new_vbi):
				for i, bi in enumerate(vbi):
					bw = new_vbw[v][i]
					if bi == vgroup.index:
						vgroup.add([v], bw, 'REPLACE')

		mod = obj.modifiers.new("Armature", 'ARMATURE')
		mod.object = amtobj
		mod.use_vertex_groups = True

	# Vertex groups for custom attributes

	def make_custom_vgroup(obj, name, size, vdata):
		print("importing custom attribute as vertex group", name)
		if size == 1:
			xg = obj.vertex_groups.new(name=name)
			for i, v in enumerate(vdata):
				xg.add([i], v[0], 'REPLACE')
		if size == 2:
			xg = obj.vertex_groups.new(name=name + ".x")
			yg = obj.vertex_groups.new(name=name + ".y")
			for i, v in enumerate(vdata):
				xg.add([i], v[0], 'REPLACE')
				yg.add([i], v[1], 'REPLACE')
		if size == 3:
			xg = obj.vertex_groups.new(name=name + ".x")
			yg = obj.vertex_groups.new(name=name + ".y")
			zg = obj.vertex_groups.new(name=name + ".z")
			for i, v in enumerate(vdata):
				xg.add([i], v[0], 'REPLACE')
				yg.add([i], v[1], 'REPLACE')
				zg.add([i], v[2], 'REPLACE')
		if size == 4:
			xg = obj.vertex_groups.new(name=name + ".x")
			yg = obj.vertex_groups.new(name=name + ".y")
			zg = obj.vertex_groups.new(name=name + ".z")
			wg = obj.vertex_groups.new(name=name + ".z")
			for i, v in enumerate(vdata):
				xg.add([i], v[0], 'REPLACE')
				yg.add([i], v[1], 'REPLACE')
				zg.add([i], v[2], 'REPLACE')
				wg.add([i], v[3], 'REPLACE')

	if has_v0: make_custom_vgroup(obj, iqmodel.custom_name[0], iqmodel.custom_size[0], new_v0)
	if has_v1: make_custom_vgroup(obj, iqmodel.custom_name[1], iqmodel.custom_size[1], new_v1)
	if has_v2: make_custom_vgroup(obj, iqmodel.custom_name[2], iqmodel.custom_size[2], new_v2)
	if has_v3: make_custom_vgroup(obj, iqmodel.custom_name[3], iqmodel.custom_size[3], new_v3)
	if has_v4: make_custom_vgroup(obj, iqmodel.custom_name[4], iqmodel.custom_size[4], new_v4)
	if has_v5: make_custom_vgroup(obj, iqmodel.custom_name[5], iqmodel.custom_size[5], new_v5)
	if has_v6: make_custom_vgroup(obj, iqmodel.custom_name[6], iqmodel.custom_size[6], new_v6)
	if has_v7: make_custom_vgroup(obj, iqmodel.custom_name[7], iqmodel.custom_size[7], new_v7)
	if has_v8: make_custom_vgroup(obj, iqmodel.custom_name[8], iqmodel.custom_size[8], new_v8)
	if has_v9: make_custom_vgroup(obj, iqmodel.custom_name[9], iqmodel.custom_size[9], new_v9)

	# Update mesh polygons from tessfaces

	mesh.update()

	# Apply the authored vertex normals as custom split normals.
	#
	# mesh.vertices.foreach_set("normal", ...) no longer works: since Blender
	# 4.1 vertex normals are computed and read-only, so writing them is a silent
	# no-op and the mesh falls back to geometric normals (which, combined with
	# the flipped winding above, point the wrong way). The supported path is to
	# set custom split normals from the per-vertex normals.
	if has_vn:
		if bpy.app.version >= (4, 1, 0):
			mesh.normals_split_custom_set_from_vertices(new_vn)
		else:
			# Pre-4.1 needs auto-smooth enabled for custom normals to show.
			mesh.use_auto_smooth = True
			mesh.normals_split_custom_set_from_vertices(new_vn)

	return obj

#
# Import armature and meshes.
# If there is an armature, parent the meshes to it.
# Otherwise create an empty object and group the meshes in that.
#

def make_model(iqmodel, bone_axis, dir):
	print("importing model", iqmodel.name)

	for obj in bpy.context.scene.objects:
		select_ob(obj, state=False)

	group = make_group(iqmodel.name)

	amtobj = make_armature(iqmodel, bone_axis)
	meshes = gather_meshes(iqmodel)

	if amtobj:
		rootobj = amtobj
	elif meshes:
		bpy.ops.object.empty_add()
		rootobj = bpy.context.object
		rootobj.name = iqmodel.name
	else:
		# Animation-only IQM (no joints, no meshes): the skeleton lives in a
		# separate base model, so there is nothing to attach here. Bail out
		# clearly instead of crashing on an undefined rootobj.
		raise Exception("This IQM has no mesh and no skeleton (animation-only file). "
			"Import the base model that contains the skeleton instead.")

	group.objects.link(rootobj)
	select_ob(rootobj)

	for name in meshes:
		meshobj = make_mesh_data(iqmodel, name, meshes[name], amtobj, dir)
		meshobj.parent = rootobj
		group.objects.link(meshobj)
		select_ob(meshobj)

	if len(iqmodel.anims) > 0:
		make_actions(iqmodel, amtobj, bone_axis)

	print("all done.")

#
# Animation-only files (no joints, no meshes — e.g. OctaForge per-clip IQMs)
# carry pose/frame data that indexes the base model's skeleton by order. We
# can't build them standalone, but we can apply them onto an already-imported
# armature: reload the base model's skeleton + bind pose, graft the clip's
# frames on, and reuse the normal action-building path.
#

base_skeleton_cache = {}

def load_model(filename):
	if filename.endswith(".iqm") or filename.endswith(".IQM"):
		return load_iqm(filename)
	return load_iqe(filename)

def is_animation_only(iqmodel):
	return len(iqmodel.bones) == 0 and len(iqmodel.meshes) == 0 and len(iqmodel.anims) > 0

def find_base_skeleton(anim_filename, nbones):
	# Look for a sibling/parent model file whose skeleton matches the clip's
	# bone count (OctaForge keeps animation/<clip>.iqm next to <model>.iqm).
	anim_dir = os.path.dirname(anim_filename)
	for search_dir in [os.path.dirname(anim_dir), anim_dir]:
		if not search_dir:
			continue
		for cand in sorted(glob.glob(os.path.join(search_dir, "*.iqm")) +
				glob.glob(os.path.join(search_dir, "*.iqe"))):
			if os.path.abspath(cand) == os.path.abspath(anim_filename):
				continue
			key = os.path.abspath(cand)
			if key not in base_skeleton_cache:
				try:
					base_skeleton_cache[key] = load_model(cand)
				except Exception:
					base_skeleton_cache[key] = None
			base = base_skeleton_cache[key]
			if base and len(base.bones) == nbones and nbones > 0:
				print("using base skeleton", cand)
				return base
	return None

def find_target_armature(nbones):
	cands = [o for o in bpy.context.scene.objects if o.type == 'ARMATURE']
	matching = [o for o in cands if len(o.data.bones) == nbones]
	act = bpy.context.active_object
	if act in matching: return act
	if matching: return matching[0]
	if act in cands: return act
	return cands[0] if cands else None

def apply_animation(iqmodel, filename, bone_axis):
	nbones = len(iqmodel.anims[0].frames[0]) if iqmodel.anims[0].frames else 0
	amtobj = find_target_armature(nbones)
	if amtobj is None:
		raise Exception("No armature in the scene to apply this animation to. "
			"Import the base model (with the skeleton) first.")
	base = find_base_skeleton(filename, nbones)
	if base is None:
		raise Exception("Could not find the base model (%d-bone skeleton) for this "
			"animation. Keep the base .iqm next to it and import it first." % nbones)
	base.anims = iqmodel.anims
	compute_bind_mats(base, bone_axis)
	print("applying %d animation(s) onto armature '%s'" % (len(base.anims), amtobj.name))
	make_actions(base, amtobj, bone_axis)

def dispatch_iqmodel(iqmodel, filename, bone_axis):
	if is_animation_only(iqmodel):
		apply_animation(iqmodel, filename, bone_axis)
	else:
		make_model(iqmodel, bone_axis, os.path.dirname(filename))
		bpy.ops.screen.frame_jump()

def import_iqm(filename, bone_axis='Y'):
	dispatch_iqmodel(load_model(filename), filename, bone_axis)

def import_iqm_files(filenames, bone_axis='Y'):
	# Load everything, then process models (skeleton/mesh) before animation-only
	# clips so the armature exists when the clips are applied.
	loaded = [(f, load_model(f)) for f in filenames]
	models = [(f, m) for f, m in loaded if not is_animation_only(m)]
	anims = [(f, m) for f, m in loaded if is_animation_only(m)]
	for f, m in models + anims:
		dispatch_iqmodel(m, f, bone_axis)

#
# Register addon
#

class ImportIQM(bpy.types.Operator, ImportHelper):
	bl_idname = "import.iqm"
	bl_label = "Import IQM or IQE"

	filename_ext = ".iqe, .iqm"
	# NOTE: properties MUST use annotation syntax (name: Property(...)). Since
	# Blender 2.9x, assignment-style operator properties are silently ignored
	# when a base class (here ImportHelper) already declares annotations.
	filter_glob: StringProperty(default="*.iq[em]", options={'HIDDEN'})
	# Allow selecting several files at once (e.g. a model + its animation clips);
	# models are imported first so clips can attach to the new armature.
	files: CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement,
			options={'HIDDEN', 'SKIP_SAVE'})
	directory: StringProperty(subtype='DIR_PATH', options={'HIDDEN', 'SKIP_SAVE'})

	bone_axis: EnumProperty(name="Bone Axis",
			description="Flip bones to extend along the Y axis",
			items=[
				('Y', "Preserve", ""),
				('X', "Flip from X to Y", ""),
				('Z', "Flip from Z to Y", "")
			],
			default='Y')

	def execute(self, context):
		if self.files and self.directory:
			paths = [os.path.join(self.directory, f.name) for f in self.files if f.name]
		else:
			paths = [self.properties.filepath]
		import_iqm_files(paths, self.bone_axis)
		return {'FINISHED'}

def menu_func(self, context):
	self.layout.operator(ImportIQM.bl_idname, text="Inter-Quake Model + Anims (.iqm, .iqe)")

def register():
    if bpy.app.version >= (2, 80, 0):
        bpy.utils.register_class(ImportIQM)
        bpy.types.TOPBAR_MT_file_import.append(menu_func)
    else:
        bpy.utils.register_module(__name__)
        bpy.types.INFO_MT_file_import.append(menu_func)

def unregister():
    if bpy.app.version >= (2, 80, 0):
        bpy.types.TOPBAR_MT_file_import.remove(menu_func)
        bpy.utils.unregister_class(ImportIQM)
    else:
        bpy.utils.unregister_module(__name__)
        bpy.types.INFO_MT_file_import.remove(menu_func)

def batch_zap():
	if "Cube" in bpy.data.objects:
		obj = bpy.data.objects['Cube']
		bpy.context.scene.objects.unlink(obj)
		bpy.data.objects.remove(obj)

def batch(input):
	batch_zap()
	output = os.path.splitext(input)[0] + ".blend"
	import_iqm(input)
	print("Saving", output)
	bpy.ops.wm.save_mainfile(filepath=output, check_existing=False)

def batch_many(input_list):
	batch_zap()
	output = "output.blend"
	for input in input_list:
		import_iqm(input)
	print("Saving", output)
	bpy.ops.wm.save_mainfile(filepath=output, check_existing=False)

if __name__ == "__main__":
	register()
	if len(sys.argv) > 4 and sys.argv[-2] == '--':
		if "*" in sys.argv[-1]:
			batch_many(glob.glob(sys.argv[-1]))
		else:
			batch(sys.argv[-1])
	elif len(sys.argv) > 4 and sys.argv[4] == '--':
		batch_many(sys.argv[5:])