| … | … | @@ -0,0 +1,1158 @@ |
| 1 | # Inter-Quake Import |
| 2 | |
| 3 | bl_info = { |
| 4 | "name": "Import Inter-Quake Model (.iqm, .iqe)", |
| 5 | "description": "Import Inter-Quake Model.", |
| 6 | "author": "Tor Andersson", |
| 7 | "version": (2012, 12, 2), |
| 8 | "blender": (2, 80, 0), |
| 9 | "location": "File > Import > Inter-Quake Model", |
| 10 | "wiki_url": "http://github.com/ccxvii/asstools", |
| 11 | "category": "Import-Export", |
| 12 | } |
| 13 | |
| 14 | import bpy, math, shlex, struct, os, sys, glob |
| 15 | |
| 16 | from bpy.props import * |
| 17 | from bpy_extras.io_utils import ImportHelper |
| 18 | from bpy_extras.image_utils import load_image |
| 19 | from mathutils import Matrix, Quaternion, Vector |
| 20 | |
| 21 | # see blenkernel/intern/armature.c for vec_roll_to_mat3 |
| 22 | # see blenkernel/intern/armature.c for mat3_to_vec_roll |
| 23 | # see makesrna/intern/rna_armature.c for rna_EditBone_matrix_get |
| 24 | |
| 25 | # Compatiblity shims |
| 26 | if bpy.app.version >= (2, 80, 0): |
| 27 | def matmul(x, y): return x.__matmul__(y) |
| 28 | def make_group(name): return bpy.data.collections.new(name) |
| 29 | def link_object(ob): bpy.context.scene.collection.objects.link(ob) |
| 30 | def select_ob(ob, state=True): ob.select_set(state=state) |
| 31 | else: |
| 32 | def matmul(x, y): return x * y |
| 33 | def make_group(name): return bpy.data.groups.new(name) |
| 34 | def link_object(ob): bpy.context.scene.objects.link(ob) |
| 35 | def select_ob(ob, state=True): ob.select = state |
| 36 | |
| 37 | def vec_roll_to_mat3(vec, roll): |
| 38 | target = Vector((0,1,0)) |
| 39 | nor = vec.normalized() |
| 40 | axis = target.cross(nor) |
| 41 | if axis.dot(axis) > 0.000001: |
| 42 | axis.normalize() |
| 43 | theta = target.angle(nor) |
| 44 | bMatrix = Matrix.Rotation(theta, 3, axis) |
| 45 | else: |
| 46 | updown = 1 if target.dot(nor) > 0 else -1 |
| 47 | bMatrix = Matrix.Scale(updown, 3) |
| 48 | rMatrix = Matrix.Rotation(roll, 3, nor) |
| 49 | mat = matmul(rMatrix, bMatrix) |
| 50 | return mat |
| 51 | |
| 52 | def mat3_to_vec_roll(mat): |
| 53 | vec = mat.col[1] |
| 54 | vecmat = vec_roll_to_mat3(mat.col[1], 0) |
| 55 | vecmatinv = vecmat.copy() |
| 56 | vecmatinv.invert() |
| 57 | rollmat = matmul(vecmatinv, mat) |
| 58 | roll = math.atan2(rollmat[0][2], rollmat[2][2]) |
| 59 | return vec, roll |
| 60 | |
| 61 | # |
| 62 | # Inter-Quake Model structs |
| 63 | # |
| 64 | |
| 65 | class IQMesh: |
| 66 | def __init__(self, name): |
| 67 | self.name = name |
| 68 | self.material = ["unknown"] |
| 69 | self.faces = [] |
| 70 | self.vp = [] |
| 71 | self.vn = [] |
| 72 | self.vt = [] |
| 73 | self.vc = [] |
| 74 | self.vbi = [] |
| 75 | self.vbw = [] |
| 76 | self.v0 = [] |
| 77 | self.v1 = [] |
| 78 | self.v2 = [] |
| 79 | self.v3 = [] |
| 80 | self.v4 = [] |
| 81 | self.v5 = [] |
| 82 | self.v6 = [] |
| 83 | self.v7 = [] |
| 84 | self.v8 = [] |
| 85 | self.v9 = [] |
| 86 | |
| 87 | class IQBone: |
| 88 | def __init__(self, name, parent): |
| 89 | self.name = name |
| 90 | self.parent = parent |
| 91 | |
| 92 | class IQPose: |
| 93 | def __init__(self, data): |
| 94 | self.translate = data[0:3] |
| 95 | self.rotate = data[3:7] |
| 96 | if len(data) > 7: |
| 97 | self.scale = data[7:10] |
| 98 | else: |
| 99 | self.scale = (1,1,1) |
| 100 | |
| 101 | class IQAnimation: |
| 102 | def __init__(self, name): |
| 103 | self.name = name |
| 104 | self.framerate = 30 |
| 105 | self.loop = False |
| 106 | self.frames = [] |
| 107 | |
| 108 | class IQModel: |
| 109 | def __init__(self, name): |
| 110 | self.name = name |
| 111 | self.bones = [] |
| 112 | self.bindpose = [] |
| 113 | self.meshes = [] |
| 114 | self.anims = [] |
| 115 | self.custom_name = {} |
| 116 | self.custom_size = {} |
| 117 | |
| 118 | # |
| 119 | # IQE parser |
| 120 | # |
| 121 | |
| 122 | def load_iqe(filename): |
| 123 | name = filename.split("/")[-1].split("\\")[-1].split(".")[0] |
| 124 | model = IQModel(name) |
| 125 | file = open(filename) |
| 126 | line = file.readline() |
| 127 | if not line.startswith("# Inter-Quake Export"): |
| 128 | raise Exception("Not an IQE file!") |
| 129 | curpose = model.bindpose |
| 130 | curmesh = None |
| 131 | curanim = None |
| 132 | for line in file.readlines(): |
| 133 | if "#" in line or "\"" in line: |
| 134 | line = shlex.split(line, "#") |
| 135 | else: |
| 136 | line = line.split() |
| 137 | if len(line) == 0: |
| 138 | pass |
| 139 | elif line[0] == "vertexarray": |
| 140 | if line[1].startswith("custom"): |
| 141 | N = int(line[1][6:]) |
| 142 | model.custom_name[N] = line[4] if len(line) > 4 else line[1] |
| 143 | model.custom_size[N] = int(line[3]) |
| 144 | elif line[0] == "joint": |
| 145 | model.bones.append(IQBone(line[1], int(line[2]))) |
| 146 | elif line[0] == "pq": |
| 147 | curpose.append(IQPose([float(x) for x in line[1:]])) |
| 148 | elif line[0] == "pm": raise Exception("pm style poses not implemented yet") |
| 149 | elif line[0] == "pa": raise Exception("pa style poses not implemented yet") |
| 150 | elif line[0] == "mesh": |
| 151 | curmesh = IQMesh(line[1]) |
| 152 | model.meshes.append(curmesh) |
| 153 | elif line[0] == "material": |
| 154 | curmesh.material = line[1].split("+") |
| 155 | elif line[0] == "vp": curmesh.vp.append(tuple([float(x) for x in line[1:]])) |
| 156 | elif line[0] == "vn": curmesh.vn.append(tuple([float(x) for x in line[1:]])) |
| 157 | elif line[0] == "vt": curmesh.vt.append(tuple([float(x) for x in line[1:]])) |
| 158 | elif line[0] == "vc": curmesh.vc.append(tuple([float(x) for x in line[1:]])) |
| 159 | elif line[0] == "v0": curmesh.v0.append(tuple([float(x) for x in line[1:]])) |
| 160 | elif line[0] == "v1": curmesh.v1.append(tuple([float(x) for x in line[1:]])) |
| 161 | elif line[0] == "v2": curmesh.v2.append(tuple([float(x) for x in line[1:]])) |
| 162 | elif line[0] == "v3": curmesh.v3.append(tuple([float(x) for x in line[1:]])) |
| 163 | elif line[0] == "v4": curmesh.v4.append(tuple([float(x) for x in line[1:]])) |
| 164 | elif line[0] == "v5": curmesh.v5.append(tuple([float(x) for x in line[1:]])) |
| 165 | elif line[0] == "v6": curmesh.v6.append(tuple([float(x) for x in line[1:]])) |
| 166 | elif line[0] == "v7": curmesh.v7.append(tuple([float(x) for x in line[1:]])) |
| 167 | elif line[0] == "v8": curmesh.v8.append(tuple([float(x) for x in line[1:]])) |
| 168 | elif line[0] == "v9": curmesh.v9.append(tuple([float(x) for x in line[1:]])) |
| 169 | elif line[0] == "vb": |
| 170 | vbi = [] |
| 171 | vbw = [] |
| 172 | i = 1 |
| 173 | while i + 1 < len(line): |
| 174 | vbi.append(int(line[i])) |
| 175 | vbw.append(float(line[i+1])) |
| 176 | i = i + 2 |
| 177 | curmesh.vbi.append(tuple(vbi)) |
| 178 | curmesh.vbw.append(tuple(vbw)) |
| 179 | elif line[0] == "fm": |
| 180 | f = [int(x) for x in line[1:]] |
| 181 | if len(f) > 4: |
| 182 | print("triangulating n-gon with %d sides %s" % (len(f), f)) |
| 183 | i = 1 |
| 184 | for j in range(2,len(f)): |
| 185 | curmesh.faces.append((f[0], f[i], f[j])) |
| 186 | i = j |
| 187 | else: |
| 188 | curmesh.faces.append(tuple(f)) |
| 189 | elif line[0] == "fa": raise Exception("fa style faces not implemented yet") |
| 190 | elif line[0] == "animation": |
| 191 | curanim = IQAnimation(line[1]) |
| 192 | model.anims.append(curanim) |
| 193 | elif line[0] == "framerate": |
| 194 | curanim.framerate = float(line[1]) |
| 195 | elif line[0] == "loop": |
| 196 | curanim.loop = True |
| 197 | elif line[0] == "frame": |
| 198 | curpose = [] |
| 199 | curanim.frames.append(curpose) |
| 200 | return model |
| 201 | |
| 202 | # |
| 203 | # IQM Parser |
| 204 | # |
| 205 | |
| 206 | IQM_POSITION = 0 |
| 207 | IQM_TEXCOORD = 1 |
| 208 | IQM_NORMAL = 2 |
| 209 | IQM_BLENDINDEXES = 4 |
| 210 | IQM_BLENDWEIGHTS = 5 |
| 211 | IQM_COLOR = 6 |
| 212 | IQM_CUSTOM = 0x10 |
| 213 | |
| 214 | IQM_BYTE = 0 |
| 215 | IQM_UBYTE = 1 |
| 216 | IQM_SHORT = 2 |
| 217 | IQM_USHORT = 3 |
| 218 | IQM_INT = 4 |
| 219 | IQM_UINT = 5 |
| 220 | IQM_HALF = 6 |
| 221 | IQM_FLOAT = 7 |
| 222 | IQM_DOUBLE = 8 |
| 223 | |
| 224 | IQM_FORMAT = { |
| 225 | IQM_BYTE: "b", |
| 226 | IQM_UBYTE: "B", |
| 227 | IQM_SHORT: "h", |
| 228 | IQM_USHORT: "H", |
| 229 | IQM_INT: "i", |
| 230 | IQM_UINT: "I", |
| 231 | IQM_FLOAT: "f", |
| 232 | IQM_DOUBLE: "d" |
| 233 | } |
| 234 | |
| 235 | def cstr(text, ofs): |
| 236 | len = 0 |
| 237 | while text[ofs+len] != 0: |
| 238 | len += 1 |
| 239 | return str(text[ofs:ofs+len], encoding='utf-8') |
| 240 | |
| 241 | def load_iqm_structs(file, fmt, count, offset): |
| 242 | size = struct.calcsize(fmt) |
| 243 | file.seek(offset) |
| 244 | return [struct.unpack(fmt, file.read(size)) for n in range(count)] |
| 245 | |
| 246 | def load_iqm(filename): |
| 247 | name = filename.split("/")[-1].split("\\")[-1].split(".")[0] |
| 248 | model = IQModel(name) |
| 249 | file = open(filename, "rb") |
| 250 | |
| 251 | hdr = struct.unpack("<16s27I", file.read(124)); |
| 252 | ( magic, version, filesize, flags, |
| 253 | num_text, ofs_text, |
| 254 | num_meshes, ofs_meshes, |
| 255 | num_vertexarrays, num_vertexes, ofs_vertexarrays, |
| 256 | num_triangles, ofs_triangles, ofs_adjacency, |
| 257 | num_joints, ofs_joints, |
| 258 | num_poses, ofs_poses, |
| 259 | num_anims, ofs_anims, |
| 260 | num_frames, num_framechannels, ofs_frames, ofs_bounds, |
| 261 | num_comment, ofs_comment, |
| 262 | num_extensions, ofs_extensions ) = hdr |
| 263 | |
| 264 | if magic != b"INTERQUAKEMODEL\0": |
| 265 | raise Exception("Not an IQM file: '%s'", magic) |
| 266 | if version != 2: |
| 267 | raise Exception("Not an IQMv2 file.") |
| 268 | |
| 269 | file.seek(ofs_text) |
| 270 | text = file.read(num_text); |
| 271 | |
| 272 | load_iqm_joints(model, file, text, num_joints, ofs_joints) |
| 273 | |
| 274 | vadata = load_iqm_vertexarrays(model, file, text, num_vertexarrays, num_vertexes, ofs_vertexarrays) |
| 275 | triangles = load_iqm_structs(file, "<3I", num_triangles, ofs_triangles) |
| 276 | load_iqm_meshes(model, file, text, num_meshes, ofs_meshes, vadata, triangles) |
| 277 | |
| 278 | poses = load_iqm_structs(file, "<iI20f", num_poses, ofs_poses) |
| 279 | frames = load_iqm_structs(file, "<" + "H" * num_framechannels, num_frames, ofs_frames) |
| 280 | load_iqm_anims(model, file, text, num_anims, ofs_anims, poses, frames) |
| 281 | |
| 282 | return model |
| 283 | |
| 284 | def load_iqm_joints(model, file, text, num_joints, ofs_joints): |
| 285 | file.seek(ofs_joints) |
| 286 | for n in range(num_joints): |
| 287 | data = struct.unpack("<Ii10f", file.read(12*4)) |
| 288 | name = cstr(text, data[0]) |
| 289 | parent = data[1] |
| 290 | pose = data[2:12] |
| 291 | model.bones.append(IQBone(name, parent)) |
| 292 | model.bindpose.append(IQPose(data[2:12])) |
| 293 | |
| 294 | def load_iqm_vertexarray(file, type, format, size, offset, count): |
| 295 | if format not in IQM_FORMAT: |
| 296 | raise Exception("unknown vertex array data type: %d" % format) |
| 297 | data = load_iqm_structs(file, "<" + IQM_FORMAT[format] * size, count, offset) |
| 298 | if type == IQM_BLENDINDEXES: |
| 299 | return data |
| 300 | if format == IQM_BYTE: return [ tuple([x/127.0 for x in v]) for v in data ] |
| 301 | if format == IQM_UBYTE: return [ tuple([x/255.0 for x in v]) for v in data ] |
| 302 | if format == IQM_SHORT: return [ tuple([x/32767.0 for x in v]) for v in data ] |
| 303 | if format == IQM_USHORT: return [ tuple([x/65535.0 for x in v]) for v in data ] |
| 304 | if format == IQM_INT: return [ tuple([x/2147483647.0 for x in v]) for v in data ] |
| 305 | if format == IQM_UINT: return [ tuple([x/4294967295.0 for x in v]) for v in data ] |
| 306 | return data |
| 307 | |
| 308 | def load_iqm_vertexarrays(model, file, text, num_vertexarrays, num_vertexes, ofs_vertexarrays): |
| 309 | va = load_iqm_structs(file, "<5I", num_vertexarrays, ofs_vertexarrays) |
| 310 | model.custom_name = [] |
| 311 | model.custom_size = [] |
| 312 | vadata = {} |
| 313 | for type, flags, format, size, offset in va: |
| 314 | if type >= IQM_CUSTOM: |
| 315 | name = cstr(text, type - IQM_CUSTOM) |
| 316 | type = len(model.custom_name) + IQM_CUSTOM |
| 317 | model.custom_name.append(name) |
| 318 | model.custom_size.append(size) |
| 319 | vadata[type] = load_iqm_vertexarray(file, type, format, size, offset, num_vertexes) |
| 320 | return vadata |
| 321 | |
| 322 | def copy_iqm_verts(mesh, vadata, first, count): |
| 323 | for n in range(first, first+count): |
| 324 | if IQM_POSITION in vadata: mesh.vp.append(vadata[IQM_POSITION][n]) |
| 325 | if IQM_NORMAL in vadata: mesh.vn.append(vadata[IQM_NORMAL][n]) |
| 326 | if IQM_TEXCOORD in vadata: mesh.vt.append(vadata[IQM_TEXCOORD][n]) |
| 327 | if IQM_COLOR in vadata: mesh.vc.append(vadata[IQM_COLOR][n]) |
| 328 | if IQM_BLENDINDEXES in vadata and IQM_BLENDWEIGHTS in vadata: |
| 329 | vbi = [] |
| 330 | vbw = [] |
| 331 | for y in range(4): |
| 332 | if vadata[IQM_BLENDWEIGHTS][n][y] > 0: |
| 333 | vbi.append(vadata[IQM_BLENDINDEXES][n][y]) |
| 334 | vbw.append(vadata[IQM_BLENDWEIGHTS][n][y]) |
| 335 | mesh.vbi.append(tuple(vbi)) |
| 336 | mesh.vbw.append(tuple(vbw)) |
| 337 | if IQM_CUSTOM+0 in vadata: mesh.v0.append(vadata[IQM_CUSTOM+0][n]) |
| 338 | if IQM_CUSTOM+1 in vadata: mesh.v1.append(vadata[IQM_CUSTOM+1][n]) |
| 339 | if IQM_CUSTOM+2 in vadata: mesh.v2.append(vadata[IQM_CUSTOM+2][n]) |
| 340 | if IQM_CUSTOM+3 in vadata: mesh.v3.append(vadata[IQM_CUSTOM+3][n]) |
| 341 | if IQM_CUSTOM+4 in vadata: mesh.v4.append(vadata[IQM_CUSTOM+4][n]) |
| 342 | if IQM_CUSTOM+5 in vadata: mesh.v5.append(vadata[IQM_CUSTOM+5][n]) |
| 343 | if IQM_CUSTOM+6 in vadata: mesh.v6.append(vadata[IQM_CUSTOM+6][n]) |
| 344 | if IQM_CUSTOM+7 in vadata: mesh.v7.append(vadata[IQM_CUSTOM+7][n]) |
| 345 | if IQM_CUSTOM+8 in vadata: mesh.v8.append(vadata[IQM_CUSTOM+8][n]) |
| 346 | if IQM_CUSTOM+9 in vadata: mesh.v9.append(vadata[IQM_CUSTOM+9][n]) |
| 347 | |
| 348 | def load_iqm_meshes(model, file, text, num_meshes, ofs_meshes, vadata, triangles): |
| 349 | file.seek(ofs_meshes) |
| 350 | for n in range(num_meshes): |
| 351 | name, material, vfirst, vcount, tfirst, tcount = struct.unpack("<6I", file.read(6*4)) |
| 352 | mesh = IQMesh(cstr(text, name)) |
| 353 | mesh.material = cstr(text, material).split("+") |
| 354 | copy_iqm_verts(mesh, vadata, vfirst, vcount) |
| 355 | mesh.faces = [(a-vfirst, b-vfirst, c-vfirst) for a,b,c in triangles[tfirst:tfirst+tcount]] |
| 356 | model.meshes.append(mesh) |
| 357 | |
| 358 | def copy_iqm_frame(poselist, frame): |
| 359 | masktest = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x100, 0x200] |
| 360 | out = [] |
| 361 | p = 0 |
| 362 | for pose in poselist: |
| 363 | mask = pose[1] |
| 364 | choffset = pose[2:2+10] |
| 365 | chscale = pose[2+10:2+10+10] |
| 366 | data = [x for x in choffset] # make a copy |
| 367 | for n in range(10): |
| 368 | if mask & masktest[n]: |
| 369 | data[n] += chscale[n] * frame[p] |
| 370 | p += 1 |
| 371 | out.append(IQPose(data)) |
| 372 | return out |
| 373 | |
| 374 | def load_iqm_anims(model, file, text, num_anims, ofs_anims, poses, frames): |
| 375 | file.seek(ofs_anims) |
| 376 | for n in range(num_anims): |
| 377 | name, first, count, framerate, loop = struct.unpack("<3IfI", file.read(5*4)) |
| 378 | anim = IQAnimation(cstr(text, name)) |
| 379 | anim.framerate = framerate |
| 380 | anim.loop = loop |
| 381 | anim.frames = [copy_iqm_frame(poses, frame) for frame in frames[first:first+count]] |
| 382 | model.anims.append(anim) |
| 383 | |
| 384 | # |
| 385 | # Create armature from joints and the bind pose. |
| 386 | # |
| 387 | # We preserve matrices and the bone orientations, by |
| 388 | # duplicating the conversion that blender does internally |
| 389 | # to map between bone head/tail/roll and a matrix. |
| 390 | # |
| 391 | # Blender assumes all bones extend in the Y-axis, |
| 392 | # but that's not true for all assets. Depending on |
| 393 | # the bone_axis setting we can rotate the bones from |
| 394 | # extending in the X-axis or Z-axis to the Y-axis, |
| 395 | # or leave them untouched. |
| 396 | # |
| 397 | |
| 398 | # Calculate armature space matrices for all bones in a pose |
| 399 | |
| 400 | def calc_pose_mats(iqmodel, iqpose, bone_axis): |
| 401 | loc_pose_mat = [None] * len(iqmodel.bones) |
| 402 | abs_pose_mat = [None] * len(iqmodel.bones) |
| 403 | recalc = False |
| 404 | |
| 405 | # convert pose to local matrix and compute absolute matrix |
| 406 | for n in range(len(iqmodel.bones)): |
| 407 | iqbone = iqmodel.bones[n] |
| 408 | |
| 409 | pose_pos = iqpose[n].translate |
| 410 | pose_rot = iqpose[n].rotate |
| 411 | pose_scale = iqpose[n].scale |
| 412 | |
| 413 | local_pos = Vector(pose_pos) |
| 414 | local_rot = Quaternion((pose_rot[3], pose_rot[0], pose_rot[1], pose_rot[2])) |
| 415 | local_scale = Vector(pose_scale) |
| 416 | |
| 417 | mat_pos = Matrix.Translation(local_pos) |
| 418 | mat_rot = local_rot.to_matrix().to_4x4() |
| 419 | mat_scale = Matrix.Scale(local_scale.x, 3).to_4x4() |
| 420 | loc_pose_mat[n] = matmul(matmul(mat_pos, mat_rot), mat_scale) |
| 421 | |
| 422 | if iqbone.parent >= 0: |
| 423 | abs_pose_mat[n] = matmul(abs_pose_mat[iqbone.parent], loc_pose_mat[n]) |
| 424 | else: |
| 425 | abs_pose_mat[n] = loc_pose_mat[n] |
| 426 | |
| 427 | # Remove negative scaling from bones. |
| 428 | # Due to numerical instabilities in blender's matrix <-> head/tail/roll math |
| 429 | # this isn't always stable when the bones are in the X axis. If the bones |
| 430 | # end up rotated 90 degrees from what they should be, that's the reason. |
| 431 | for n in range(len(iqmodel.bones)): |
| 432 | if abs_pose_mat[n].is_negative: |
| 433 | if not hasattr(iqmodel, 'abs_bind_mat'): |
| 434 | print("warning: removing negative scale in bone", iqmodel.bones[n].name) |
| 435 | abs_pose_mat[n] = matmul(abs_pose_mat[n], Matrix.Scale(-1, 4)) |
| 436 | recalc = True |
| 437 | |
| 438 | # flip bone axis (and recompute local matrix if needed) |
| 439 | if bone_axis == 'X': |
| 440 | axis_flip = Matrix.Rotation(math.radians(-90), 4, 'Z') |
| 441 | abs_pose_mat = [matmul(m, axis_flip) for m in abs_pose_mat] |
| 442 | recalc = True |
| 443 | if bone_axis == 'Z': |
| 444 | axis_flip = Matrix.Rotation(math.radians(-90), 4, 'X') |
| 445 | abs_pose_mat = [matmul(m, axis_flip) for m in abs_pose_mat] |
| 446 | recalc = True |
| 447 | |
| 448 | if recalc: |
| 449 | inv_pose_mat = [m.inverted() for m in abs_pose_mat] |
| 450 | for n in range(len(iqmodel.bones)): |
| 451 | iqbone = iqmodel.bones[n] |
| 452 | if iqbone.parent >= 0: |
| 453 | loc_pose_mat[n] = matmul(inv_pose_mat[iqbone.parent], abs_pose_mat[n]) |
| 454 | else: |
| 455 | loc_pose_mat[n] = abs_pose_mat[n] |
| 456 | |
| 457 | return loc_pose_mat, abs_pose_mat |
| 458 | |
| 459 | def make_armature(iqmodel, bone_axis): |
| 460 | |
| 461 | if len(iqmodel.bones) == 0: return None |
| 462 | if len(iqmodel.bindpose) != len(iqmodel.bones): return None |
| 463 | |
| 464 | print("importing armature with %d bones" % len(iqmodel.bones)) |
| 465 | |
| 466 | # Need to be in object mode before we can enter edit mode; this throws if |
| 467 | # we're already in object mode |
| 468 | try: |
| 469 | bpy.ops.object.mode_set(mode="OBJECT") |
| 470 | except RuntimeError: |
| 471 | pass |
| 472 | |
| 473 | bpy.ops.object.add(type="ARMATURE", enter_editmode=True) |
| 474 | obj, amt = bpy.context.object, bpy.context.object.data |
| 475 | amt.name = iqmodel.name |
| 476 | obj.name = iqmodel.name + ".amt" |
| 477 | |
| 478 | loc_bind_mat, abs_bind_mat = calc_pose_mats(iqmodel, iqmodel.bindpose, bone_axis) |
| 479 | iqmodel.abs_bind_mat = abs_bind_mat |
| 480 | iqmodel.loc_bind_mat = loc_bind_mat |
| 481 | iqmodel.inv_loc_bind_mat = [m.inverted() for m in loc_bind_mat] |
| 482 | |
| 483 | for n in range(len(iqmodel.bones)): |
| 484 | iqbone = iqmodel.bones[n] |
| 485 | |
| 486 | bone = amt.edit_bones.new(iqbone.name) |
| 487 | parent = None |
| 488 | if iqbone.parent >= 0: |
| 489 | parent = amt.edit_bones[iqbone.parent] |
| 490 | bone.parent = parent |
| 491 | |
| 492 | # TODO: bone scaling |
| 493 | pos = abs_bind_mat[n].to_translation() |
| 494 | axis, roll = mat3_to_vec_roll(abs_bind_mat[n].to_3x3()) |
| 495 | axis *= 0.125 # short bones |
| 496 | bone.roll = roll |
| 497 | bone.head = pos |
| 498 | bone.tail = pos + axis |
| 499 | |
| 500 | # extend parent and connect if we are aligned |
| 501 | if parent: |
| 502 | a = (bone.head - parent.head).normalized() |
| 503 | b = (parent.tail - parent.head).normalized() |
| 504 | if a.dot(b) > 0.9999: |
| 505 | parent.tail = bone.head |
| 506 | bone.use_connect = True |
| 507 | |
| 508 | bpy.ops.object.mode_set(mode='OBJECT') |
| 509 | |
| 510 | return obj |
| 511 | |
| 512 | # |
| 513 | # Strike a pose. |
| 514 | # |
| 515 | |
| 516 | def make_pose(iqmodel, frame, amtobj, bone_axis, tick): |
| 517 | loc_pose_mat, _ = calc_pose_mats(iqmodel, frame, bone_axis) |
| 518 | for n in range(len(iqmodel.bones)): |
| 519 | name = iqmodel.bones[n].name |
| 520 | pose_bone = amtobj.pose.bones[name] |
| 521 | pose_bone.matrix_basis = matmul(iqmodel.inv_loc_bind_mat[n], loc_pose_mat[n]) |
| 522 | pose_bone.keyframe_insert(group=name, frame=tick, data_path='location') |
| 523 | pose_bone.keyframe_insert(group=name, frame=tick, data_path='rotation_quaternion') |
| 524 | pose_bone.keyframe_insert(group=name, frame=tick, data_path='scale') |
| 525 | |
| 526 | def make_anim(iqmodel, anim, amtobj, bone_axis): |
| 527 | print("importing animation %s with %d frames" % (anim.name, len(anim.frames))) |
| 528 | action = bpy.data.actions.new(anim.name) |
| 529 | # id_root was removed in Blender 4.4 (slotted actions); the root type is |
| 530 | # inferred from the id the action is assigned to, so only set it if present. |
| 531 | if hasattr(action, "id_root"): |
| 532 | action.id_root = 'OBJECT' |
| 533 | action.use_fake_user = True |
| 534 | amtobj.animation_data.action = action |
| 535 | for n in range(len(anim.frames)): |
| 536 | make_pose(iqmodel, anim.frames[n], amtobj, bone_axis, n) |
| 537 | return action |
| 538 | |
| 539 | def make_actions(iqmodel, amtobj, bone_axis): |
| 540 | bpy.context.scene.frame_start = 0 |
| 541 | amtobj.animation_data_create() |
| 542 | for anim in iqmodel.anims: |
| 543 | action = make_anim(iqmodel, anim, amtobj, bone_axis) |
| 544 | |
| 545 | # |
| 546 | # Create simple material by looking at the magic words. |
| 547 | # Use the last word as a texture name by appending ".png". |
| 548 | # |
| 549 | |
| 550 | images = {} |
| 551 | of_textures_cache = {} |
| 552 | |
| 553 | def load_of_textures(dir): |
| 554 | # OctaForge / Tesseract models name their textures in an iqm.cfg sidecar, |
| 555 | # not in the IQM material strings, and they pack data into channels very |
| 556 | # deliberately. Parse iqm.cfg so we can build a faithful material. See |
| 557 | # config/glsl/model.cfg in the engine for the exact channel semantics: |
| 558 | # |
| 559 | # iqmskin * <tex> <masks> ... tex0 -> base colour (rgb), alpha=alphatest |
| 560 | # tex1 -> masks: R=spec G=glow B=envmap |
| 561 | # iqmdecal * <decal> tex4 -> decal mixed over base by decal.a |
| 562 | # iqmbumpmap* <normalmap> tex3 -> tangent-space normal map (rgb) |
| 563 | # mdlspec/iqmspec <percent> spec multiplier (maskscale.x) |
| 564 | # |
| 565 | # Returns { material-name-or-'*': {tex,masks,decal,normal,spec} }. |
| 566 | if dir in of_textures_cache: |
| 567 | return of_textures_cache[dir] |
| 568 | cfg = {} |
| 569 | path = os.path.join(dir, "iqm.cfg") |
| 570 | if os.path.exists(path): |
| 571 | for line in open(path): |
| 572 | try: |
| 573 | parts = shlex.split(line, comments=True) |
| 574 | except ValueError: |
| 575 | continue |
| 576 | if not parts: continue |
| 577 | cmd = parts[0] |
| 578 | if cmd == "iqmskin" and len(parts) >= 3: |
| 579 | ent = cfg.setdefault(parts[1], {}) |
| 580 | ent['tex'] = parts[2] |
| 581 | if len(parts) >= 4 and parts[3]: ent['masks'] = parts[3] |
| 582 | elif cmd == "iqmdecal" and len(parts) >= 3: |
| 583 | cfg.setdefault(parts[1], {})['decal'] = parts[2] |
| 584 | elif cmd == "iqmbumpmap" and len(parts) >= 3: |
| 585 | cfg.setdefault(parts[1], {})['normal'] = parts[2] |
| 586 | elif cmd == "iqmspec" and len(parts) >= 3: |
| 587 | try: cfg.setdefault(parts[1], {})['spec'] = float(parts[2]) / 100.0 |
| 588 | except ValueError: pass |
| 589 | elif cmd == "mdlspec" and len(parts) >= 2: |
| 590 | try: cfg.setdefault('*', {})['spec'] = float(parts[1]) / 100.0 |
| 591 | except ValueError: pass |
| 592 | of_textures_cache[dir] = cfg |
| 593 | return cfg |
| 594 | |
| 595 | def of_entry(dir, name): |
| 596 | # Merge the wildcard '*' entry with any material-specific overrides. |
| 597 | cfg = load_of_textures(dir) |
| 598 | ent = dict(cfg.get('*', {})) |
| 599 | ent.update(cfg.get(name, {})) |
| 600 | return ent |
| 601 | |
| 602 | def cached_image(key, path, dir, non_color=False): |
| 603 | if key not in images: |
| 604 | print("load image", path) |
| 605 | images[key] = load_image(path, dir, place_holder=True, recursive=True) |
| 606 | img = images[key] |
| 607 | if img and non_color: |
| 608 | img.colorspace_settings.name = 'Non-Color' |
| 609 | return img |
| 610 | |
| 611 | def build_pbr_nodes(mat, image, ent, dir): |
| 612 | # Build a node material that mirrors how OctaForge uses the texture |
| 613 | # channels (see load_of_textures for the mapping). |
| 614 | mat.use_nodes = True |
| 615 | nt = mat.node_tree |
| 616 | prin = nt.nodes['Principled BSDF'] |
| 617 | lk = nt.links.new |
| 618 | x = -900 |
| 619 | |
| 620 | # Base colour: skin texture, with the decal mixed over it by the decal's |
| 621 | # alpha (model.cfg: gcolor.rgb = mix(tex0.rgb, decal.rgb, decal.a)). |
| 622 | aotex = nt.nodes.new('ShaderNodeTexImage'); aotex.image = image; aotex.location = (x, 340) |
| 623 | basecol = aotex.outputs['Color'] |
| 624 | decal = ent.get('decal') and cached_image(ent['decal'], ent['decal'], dir) |
| 625 | if decal: |
| 626 | dtex = nt.nodes.new('ShaderNodeTexImage'); dtex.image = decal; dtex.location = (x, 60) |
| 627 | mix = nt.nodes.new('ShaderNodeMixRGB'); mix.location = (x + 300, 300) |
| 628 | lk(dtex.outputs['Alpha'], mix.inputs['Factor']) |
| 629 | lk(aotex.outputs['Color'], mix.inputs['Color1']) |
| 630 | lk(dtex.outputs['Color'], mix.inputs['Color2']) |
| 631 | basecol = mix.outputs['Color'] |
| 632 | lk(basecol, prin.inputs['Base Color']) |
| 633 | |
| 634 | # Tesseract armour is reflective; a fully-rough default looks wrong. |
| 635 | prin.inputs['Roughness'].default_value = 0.4 |
| 636 | |
| 637 | # Masks texture: R=spec, G=glow, B=envmap reflection. |
| 638 | masks = ent.get('masks') and cached_image(ent['masks'], ent['masks'], dir, non_color=True) |
| 639 | if masks: |
| 640 | mtex = nt.nodes.new('ShaderNodeTexImage'); mtex.image = masks; mtex.location = (x, -240) |
| 641 | msep = nt.nodes.new('ShaderNodeSeparateColor'); msep.location = (x + 300, -240) |
| 642 | lk(mtex.outputs['Color'], msep.inputs['Color']) |
| 643 | # R -> specular level (scaled by the model's spec percentage) |
| 644 | spec = ent.get('spec', 1.0) |
| 645 | if abs(spec - 1.0) > 1e-3: |
| 646 | sm = nt.nodes.new('ShaderNodeMath'); sm.operation = 'MULTIPLY' |
| 647 | sm.inputs[1].default_value = spec; sm.location = (x + 500, -160) |
| 648 | lk(msep.outputs['Red'], sm.inputs[0]); lk(sm.outputs[0], prin.inputs['Specular IOR Level']) |
| 649 | else: |
| 650 | lk(msep.outputs['Red'], prin.inputs['Specular IOR Level']) |
| 651 | # B -> metallic (envmap reflection) |
| 652 | lk(msep.outputs['Blue'], prin.inputs['Metallic']) |
| 653 | # G -> glow, as emission tinted by the base colour |
| 654 | lk(basecol, prin.inputs['Emission Color']) |
| 655 | lk(msep.outputs['Green'], prin.inputs['Emission Strength']) |
| 656 | |
| 657 | # Tangent-space normal map with the GREEN channel inverted. The importer |
| 658 | # flips UV V to match Blender's bottom-up convention, so a normal map |
| 659 | # authored for the engine's top-down V needs the standard OpenGL<->DirectX |
| 660 | # green flip. Verified numerically against the OctaForge renderer: with the |
| 661 | # flip the shading normal matches to <0.1 deg; without it the error is ~10 |
| 662 | # deg and shows up as per-piece inverted shading on mirrored-UV parts. |
| 663 | normal = ent.get('normal') and cached_image(ent['normal'], ent['normal'], dir, non_color=True) |
| 664 | if normal: |
| 665 | ntex = nt.nodes.new('ShaderNodeTexImage'); ntex.image = normal; ntex.location = (x, -560) |
| 666 | sep = nt.nodes.new('ShaderNodeSeparateColor'); sep.location = (x + 300, -560) |
| 667 | invg = nt.nodes.new('ShaderNodeMath'); invg.operation = 'SUBTRACT' |
| 668 | invg.inputs[0].default_value = 1.0; invg.location = (x + 500, -620) |
| 669 | comb = nt.nodes.new('ShaderNodeCombineColor'); comb.location = (x + 680, -560) |
| 670 | nmap = nt.nodes.new('ShaderNodeNormalMap'); nmap.location = (x + 860, -560) |
| 671 | lk(ntex.outputs['Color'], sep.inputs['Color']) |
| 672 | lk(sep.outputs['Red'], comb.inputs['Red']) |
| 673 | lk(sep.outputs['Green'], invg.inputs[1]) |
| 674 | lk(invg.outputs[0], comb.inputs['Green']) |
| 675 | lk(sep.outputs['Blue'], comb.inputs['Blue']) |
| 676 | lk(comb.outputs['Color'], nmap.inputs['Color']) |
| 677 | lk(nmap.outputs['Normal'], prin.inputs['Normal']) |
| 678 | |
| 679 | mat.blend_method = 'CLIP' |
| 680 | |
| 681 | def make_material(iqmaterial, dir): |
| 682 | print("importing material", iqmaterial) |
| 683 | |
| 684 | matname = ";".join(iqmaterial) |
| 685 | texname = iqmaterial[-1] |
| 686 | |
| 687 | # reuse materials if possible |
| 688 | if matname in bpy.data.materials: |
| 689 | return bpy.data.materials[matname], images.get(texname) |
| 690 | |
| 691 | twosided = 'twosided' in iqmaterial |
| 692 | alphatest = 'alphatest' in iqmaterial |
| 693 | unlit = 'unlit' in iqmaterial |
| 694 | |
| 695 | # Prefer the OctaForge skin texture, else the legacy textures/<name>.png. |
| 696 | ent = of_entry(dir, texname) |
| 697 | if ent.get('tex'): |
| 698 | image = cached_image(ent['tex'], ent['tex'], dir) |
| 699 | else: |
| 700 | image = cached_image(texname, "textures/" + texname + ".png", dir) |
| 701 | |
| 702 | mat = bpy.data.materials.new(matname) |
| 703 | if bpy.app.version < (2, 80, 0): |
| 704 | if texname in bpy.data.textures: |
| 705 | tex = bpy.data.textures[texname] |
| 706 | else: |
| 707 | tex = bpy.data.textures.new(texname, type = 'IMAGE') |
| 708 | tex.image = image |
| 709 | tex.use_alpha = True |
| 710 | |
| 711 | mat.diffuse_intensity = 1 |
| 712 | mat.specular_intensity = 0 |
| 713 | mat.alpha = 0.0 |
| 714 | |
| 715 | texslot = mat.texture_slots.add() |
| 716 | texslot.texture = tex |
| 717 | texslot.texture_coords = 'UV' |
| 718 | texslot.uv_layer = "UVMap" |
| 719 | texslot.use_map_color_diffuse = True |
| 720 | texslot.use_map_alpha = True |
| 721 | |
| 722 | if unlit: mat.use_shadeless = True |
| 723 | mat.use_transparency = True |
| 724 | |
| 725 | # blender game engine |
| 726 | mat.game_settings.use_backface_culling = not twosided |
| 727 | mat.game_settings.alpha_blend = 'CLIP' |
| 728 | if alphatest and unlit: mat.game_settings.alpha_blend = 'ADD' |
| 729 | |
| 730 | else: |
| 731 | build_pbr_nodes(mat, image, ent, dir) |
| 732 | |
| 733 | # return the material (and image so we can link the uvlayer faces) |
| 734 | return mat, image |
| 735 | |
| 736 | # |
| 737 | # Create mesh object with normals, texcoords, vertex colors, |
| 738 | # and an armature modifier if the model is skinned. |
| 739 | # |
| 740 | |
| 741 | def gather_meshes(iqmodel): |
| 742 | meshes = {} |
| 743 | for mesh in iqmodel.meshes: |
| 744 | if mesh.name not in meshes: |
| 745 | meshes[mesh.name] = [ mesh ] |
| 746 | else: |
| 747 | meshes[mesh.name] += [ mesh ] |
| 748 | return meshes |
| 749 | |
| 750 | def reorder(f, ft, fc): |
| 751 | # funny shit! see bpy_extras.io_utils.unpack_face_list() |
| 752 | if len(f) == 3: |
| 753 | if f[2] == 0: |
| 754 | f = f[1], f[2], f[0] |
| 755 | ft = ft[1], ft[2], ft[0] |
| 756 | fc = fc[1], fc[2], fc[0] |
| 757 | else: # assume quad |
| 758 | if f[3] == 0 or f[2] == 0: |
| 759 | f = f[2], f[3], f[0], f[1] |
| 760 | ft = ft[2], ft[3], ft[0], ft[1] |
| 761 | fc = fc[2], fc[3], fc[0], fc[1] |
| 762 | return f, ft, fc |
| 763 | |
| 764 | def isdegenerate(f): |
| 765 | if len(f) == 3: |
| 766 | a, b, c = f |
| 767 | return a == b or a == c or b == c |
| 768 | if len(f) == 4: |
| 769 | a, b, c, d = f |
| 770 | return a == b or a == c or a == d or b == c or b == d |
| 771 | return True |
| 772 | |
| 773 | def make_mesh_data(iqmodel, name, meshes, amtobj, dir): |
| 774 | print("importing mesh", name, "with", len(meshes), "parts") |
| 775 | |
| 776 | mesh = bpy.data.meshes.new(name) |
| 777 | obj = bpy.data.objects.new(name, mesh) |
| 778 | link_object(obj) |
| 779 | |
| 780 | # Set the mesh to single-sided to spot normal errors |
| 781 | mesh.use_mirror_topology = False |
| 782 | |
| 783 | has_vn = len(iqmodel.meshes[0].vn) > 0 |
| 784 | has_vt = len(iqmodel.meshes[0].vt) > 0 |
| 785 | has_vc = len(iqmodel.meshes[0].vc) > 0 |
| 786 | has_vb = len(iqmodel.meshes[0].vbi) > 0 and len(iqmodel.meshes[0].vbw) == len(iqmodel.meshes[0].vbi) |
| 787 | has_v0 = len(iqmodel.meshes[0].v0) > 0 |
| 788 | has_v1 = len(iqmodel.meshes[0].v1) > 0 |
| 789 | has_v2 = len(iqmodel.meshes[0].v2) > 0 |
| 790 | has_v3 = len(iqmodel.meshes[0].v3) > 0 |
| 791 | has_v4 = len(iqmodel.meshes[0].v4) > 0 |
| 792 | has_v5 = len(iqmodel.meshes[0].v5) > 0 |
| 793 | has_v6 = len(iqmodel.meshes[0].v6) > 0 |
| 794 | has_v7 = len(iqmodel.meshes[0].v7) > 0 |
| 795 | has_v8 = len(iqmodel.meshes[0].v8) > 0 |
| 796 | has_v9 = len(iqmodel.meshes[0].v9) > 0 |
| 797 | |
| 798 | # Flip winding and UV coords. |
| 799 | |
| 800 | for iqmesh in meshes: |
| 801 | iqmesh.faces = [x[::-1] for x in iqmesh.faces] |
| 802 | iqmesh.vt = [(u,1-v) for (u,v) in iqmesh.vt] |
| 803 | |
| 804 | # Blender has texcoords and colors on faces rather than vertices. |
| 805 | # Create material slots for all materials used. |
| 806 | # Create new vertices from unique vp/vn/vb sets (vertex data). |
| 807 | # Create new faces which index these new vertices, and has associated face data. |
| 808 | |
| 809 | vertex_map = {} |
| 810 | |
| 811 | new_f = [] |
| 812 | new_ft = [] |
| 813 | new_fc = [] |
| 814 | new_fm_m = [] |
| 815 | new_fm_i = [] |
| 816 | |
| 817 | new_vp = [] |
| 818 | new_vn = [] |
| 819 | new_vbi = [] |
| 820 | new_vbw = [] |
| 821 | new_v0 = [] |
| 822 | new_v1 = [] |
| 823 | new_v2 = [] |
| 824 | new_v3 = [] |
| 825 | new_v4 = [] |
| 826 | new_v5 = [] |
| 827 | new_v6 = [] |
| 828 | new_v7 = [] |
| 829 | new_v8 = [] |
| 830 | new_v9 = [] |
| 831 | |
| 832 | for iqmesh in meshes: |
| 833 | material, image = make_material(iqmesh.material, dir) |
| 834 | if material.name not in mesh.materials: |
| 835 | mesh.materials.append(material) |
| 836 | material_index = mesh.materials.find(material.name) |
| 837 | |
| 838 | for iqface in iqmesh.faces: |
| 839 | f = [] |
| 840 | ft = [] |
| 841 | fc = [] |
| 842 | for iqvert in iqface: |
| 843 | vp = iqmesh.vp[iqvert] |
| 844 | vn = iqmesh.vn[iqvert] if has_vn else None |
| 845 | vbi = iqmesh.vbi[iqvert] if has_vb else None |
| 846 | vbw = iqmesh.vbw[iqvert] if has_vb else None |
| 847 | v0 = iqmesh.v0[iqvert] if has_v0 else None |
| 848 | v1 = iqmesh.v1[iqvert] if has_v1 else None |
| 849 | v2 = iqmesh.v2[iqvert] if has_v2 else None |
| 850 | v3 = iqmesh.v3[iqvert] if has_v3 else None |
| 851 | v4 = iqmesh.v4[iqvert] if has_v4 else None |
| 852 | v5 = iqmesh.v5[iqvert] if has_v5 else None |
| 853 | v6 = iqmesh.v6[iqvert] if has_v6 else None |
| 854 | v7 = iqmesh.v7[iqvert] if has_v7 else None |
| 855 | v8 = iqmesh.v8[iqvert] if has_v8 else None |
| 856 | v9 = iqmesh.v9[iqvert] if has_v9 else None |
| 857 | vertex = (vp, vn, vbi, vbw, v0, v1, v2, v3, v4, v5, v6, v7, v8, v9) |
| 858 | if not vertex in vertex_map: |
| 859 | vertex_map[vertex] = len(new_vp) |
| 860 | new_vp.append(vp) |
| 861 | new_vn.append(vn) |
| 862 | new_vbi.append(vbi) |
| 863 | new_vbw.append(vbw) |
| 864 | new_v0.append(v0) |
| 865 | new_v1.append(v1) |
| 866 | new_v2.append(v2) |
| 867 | new_v3.append(v3) |
| 868 | new_v4.append(v4) |
| 869 | new_v5.append(v5) |
| 870 | new_v6.append(v6) |
| 871 | new_v7.append(v7) |
| 872 | new_v8.append(v8) |
| 873 | new_v9.append(v9) |
| 874 | f.append(vertex_map[vertex]) |
| 875 | ft.append(iqmesh.vt[iqvert] if has_vt else None) |
| 876 | fc.append(iqmesh.vc[iqvert] if has_vc else None) |
| 877 | f, ft, fc = reorder(f, ft, fc) # XXX: do we need this? |
| 878 | if isdegenerate(f): |
| 879 | print("degenerate face", iqface, f) |
| 880 | continue |
| 881 | # XXX: what about duplicate faces? |
| 882 | new_f.append(f) |
| 883 | new_ft.append(ft) |
| 884 | new_fc.append(fc) |
| 885 | new_fm_m.append(material_index) |
| 886 | new_fm_i.append(image) |
| 887 | |
| 888 | print("\tcollected %d vertices and %d faces" % (len(new_vp), len(new_f))) |
| 889 | |
| 890 | # Create mesh vertex and face data |
| 891 | |
| 892 | mesh.from_pydata(new_vp, [], new_f) |
| 893 | |
| 894 | # Set up UV and Color layers. |
| 895 | # |
| 896 | # Create all custom-data layers first, remembering their names. In Blender |
| 897 | # 2.9+ adding a loop layer (e.g. the color attribute) reallocates the loop |
| 898 | # CustomData, which invalidates any previously-held layer handle. Writing UV |
| 899 | # data through such a stale handle produces garbage, so we re-fetch live |
| 900 | # handles by name *after* every layer has been created. |
| 901 | |
| 902 | uvtexture = None |
| 903 | uvlayer_name = None |
| 904 | clayer_name = None |
| 905 | |
| 906 | if has_vt: |
| 907 | if getattr(mesh.uv_layers, 'new', None) is not None: |
| 908 | uvlayer_name = mesh.uv_layers.new().name |
| 909 | else: |
| 910 | uvtexture = mesh.uv_textures.new() |
| 911 | uvlayer_name = mesh.uv_layers[0].name |
| 912 | if has_vc: |
| 913 | clayer_name = mesh.vertex_colors.new().name |
| 914 | |
| 915 | uvlayer = mesh.uv_layers[uvlayer_name] if uvlayer_name else None |
| 916 | clayer = mesh.vertex_colors[clayer_name] if clayer_name else None |
| 917 | |
| 918 | # Define function for switching to RGB/RGBA as appropriate |
| 919 | |
| 920 | if has_vc: |
| 921 | num_colors = len(new_fc[0][0]) |
| 922 | num_blender_colors = len(clayer.data[0].color) |
| 923 | if num_colors == 3 and num_blender_colors == 4: |
| 924 | def color(rgb): return rgb + [1] |
| 925 | elif num_colors == 4 and num_blender_colors == 3: |
| 926 | def color(rgba): return rgba[0:3] |
| 927 | elif num_colors == num_blender_colors: |
| 928 | def color(c): return c |
| 929 | |
| 930 | for poly_i, poly in enumerate(mesh.polygons): |
| 931 | poly.use_smooth = True |
| 932 | poly.material_index = new_fm_m[poly_i] |
| 933 | if uvlayer: |
| 934 | for i, loop_i in enumerate(poly.loop_indices): |
| 935 | uvlayer.data[loop_i].uv = new_ft[poly_i][i] |
| 936 | if clayer: |
| 937 | for i, loop_i in enumerate(poly.loop_indices): |
| 938 | clayer.data[loop_i].color = color(new_fc[poly_i][i]) |
| 939 | |
| 940 | if has_vt and uvtexture: |
| 941 | for poly_i, poly in enumerate(mesh.polygons): |
| 942 | uvtexture.data[poly_i].image = new_fm_i[poly_i] |
| 943 | |
| 944 | # Validate only after all per-face (poly_i indexed) data has been written. |
| 945 | # validate() removes duplicate/degenerate faces and compacts polygon and |
| 946 | # loop indices; per-loop UV/colour data already attached to the loops moves |
| 947 | # with them, but data written afterwards by poly_i would be misaligned. |
| 948 | mesh.validate() |
| 949 | |
| 950 | # Vertex groups and armature modifier for skinning |
| 951 | |
| 952 | if has_vb and amtobj: |
| 953 | for iqbone in iqmodel.bones: |
| 954 | obj.vertex_groups.new(name=iqbone.name) |
| 955 | |
| 956 | for vgroup in obj.vertex_groups: |
| 957 | for v, vbi in enumerate(new_vbi): |
| 958 | for i, bi in enumerate(vbi): |
| 959 | bw = new_vbw[v][i] |
| 960 | if bi == vgroup.index: |
| 961 | vgroup.add([v], bw, 'REPLACE') |
| 962 | |
| 963 | mod = obj.modifiers.new("Armature", 'ARMATURE') |
| 964 | mod.object = amtobj |
| 965 | mod.use_vertex_groups = True |
| 966 | |
| 967 | # Vertex groups for custom attributes |
| 968 | |
| 969 | def make_custom_vgroup(obj, name, size, vdata): |
| 970 | print("importing custom attribute as vertex group", name) |
| 971 | if size == 1: |
| 972 | xg = obj.vertex_groups.new(name=name) |
| 973 | for i, v in enumerate(vdata): |
| 974 | xg.add([i], v[0], 'REPLACE') |
| 975 | if size == 2: |
| 976 | xg = obj.vertex_groups.new(name=name + ".x") |
| 977 | yg = obj.vertex_groups.new(name=name + ".y") |
| 978 | for i, v in enumerate(vdata): |
| 979 | xg.add([i], v[0], 'REPLACE') |
| 980 | yg.add([i], v[1], 'REPLACE') |
| 981 | if size == 3: |
| 982 | xg = obj.vertex_groups.new(name=name + ".x") |
| 983 | yg = obj.vertex_groups.new(name=name + ".y") |
| 984 | zg = obj.vertex_groups.new(name=name + ".z") |
| 985 | for i, v in enumerate(vdata): |
| 986 | xg.add([i], v[0], 'REPLACE') |
| 987 | yg.add([i], v[1], 'REPLACE') |
| 988 | zg.add([i], v[2], 'REPLACE') |
| 989 | if size == 4: |
| 990 | xg = obj.vertex_groups.new(name=name + ".x") |
| 991 | yg = obj.vertex_groups.new(name=name + ".y") |
| 992 | zg = obj.vertex_groups.new(name=name + ".z") |
| 993 | wg = obj.vertex_groups.new(name=name + ".z") |
| 994 | for i, v in enumerate(vdata): |
| 995 | xg.add([i], v[0], 'REPLACE') |
| 996 | yg.add([i], v[1], 'REPLACE') |
| 997 | zg.add([i], v[2], 'REPLACE') |
| 998 | wg.add([i], v[3], 'REPLACE') |
| 999 | |
| 1000 | if has_v0: make_custom_vgroup(obj, iqmodel.custom_name[0], iqmodel.custom_size[0], new_v0) |
| 1001 | if has_v1: make_custom_vgroup(obj, iqmodel.custom_name[1], iqmodel.custom_size[1], new_v1) |
| 1002 | if has_v2: make_custom_vgroup(obj, iqmodel.custom_name[2], iqmodel.custom_size[2], new_v2) |
| 1003 | if has_v3: make_custom_vgroup(obj, iqmodel.custom_name[3], iqmodel.custom_size[3], new_v3) |
| 1004 | if has_v4: make_custom_vgroup(obj, iqmodel.custom_name[4], iqmodel.custom_size[4], new_v4) |
| 1005 | if has_v5: make_custom_vgroup(obj, iqmodel.custom_name[5], iqmodel.custom_size[5], new_v5) |
| 1006 | if has_v6: make_custom_vgroup(obj, iqmodel.custom_name[6], iqmodel.custom_size[6], new_v6) |
| 1007 | if has_v7: make_custom_vgroup(obj, iqmodel.custom_name[7], iqmodel.custom_size[7], new_v7) |
| 1008 | if has_v8: make_custom_vgroup(obj, iqmodel.custom_name[8], iqmodel.custom_size[8], new_v8) |
| 1009 | if has_v9: make_custom_vgroup(obj, iqmodel.custom_name[9], iqmodel.custom_size[9], new_v9) |
| 1010 | |
| 1011 | # Update mesh polygons from tessfaces |
| 1012 | |
| 1013 | mesh.update() |
| 1014 | |
| 1015 | # Apply the authored vertex normals as custom split normals. |
| 1016 | # |
| 1017 | # mesh.vertices.foreach_set("normal", ...) no longer works: since Blender |
| 1018 | # 4.1 vertex normals are computed and read-only, so writing them is a silent |
| 1019 | # no-op and the mesh falls back to geometric normals (which, combined with |
| 1020 | # the flipped winding above, point the wrong way). The supported path is to |
| 1021 | # set custom split normals from the per-vertex normals. |
| 1022 | if has_vn: |
| 1023 | if bpy.app.version >= (4, 1, 0): |
| 1024 | mesh.normals_split_custom_set_from_vertices(new_vn) |
| 1025 | else: |
| 1026 | # Pre-4.1 needs auto-smooth enabled for custom normals to show. |
| 1027 | mesh.use_auto_smooth = True |
| 1028 | mesh.normals_split_custom_set_from_vertices(new_vn) |
| 1029 | |
| 1030 | return obj |
| 1031 | |
| 1032 | # |
| 1033 | # Import armature and meshes. |
| 1034 | # If there is an armature, parent the meshes to it. |
| 1035 | # Otherwise create an empty object and group the meshes in that. |
| 1036 | # |
| 1037 | |
| 1038 | def make_model(iqmodel, bone_axis, dir): |
| 1039 | print("importing model", iqmodel.name) |
| 1040 | |
| 1041 | for obj in bpy.context.scene.objects: |
| 1042 | select_ob(obj, state=False) |
| 1043 | |
| 1044 | group = make_group(iqmodel.name) |
| 1045 | |
| 1046 | amtobj = make_armature(iqmodel, bone_axis) |
| 1047 | meshes = gather_meshes(iqmodel) |
| 1048 | |
| 1049 | if amtobj: |
| 1050 | rootobj = amtobj |
| 1051 | elif meshes: |
| 1052 | bpy.ops.object.empty_add() |
| 1053 | rootobj = bpy.context.object |
| 1054 | rootobj.name = iqmodel.name |
| 1055 | else: |
| 1056 | # Animation-only IQM (no joints, no meshes): the skeleton lives in a |
| 1057 | # separate base model, so there is nothing to attach here. Bail out |
| 1058 | # clearly instead of crashing on an undefined rootobj. |
| 1059 | raise Exception("This IQM has no mesh and no skeleton (animation-only file). " |
| 1060 | "Import the base model that contains the skeleton instead.") |
| 1061 | |
| 1062 | group.objects.link(rootobj) |
| 1063 | select_ob(rootobj) |
| 1064 | |
| 1065 | for name in meshes: |
| 1066 | meshobj = make_mesh_data(iqmodel, name, meshes[name], amtobj, dir) |
| 1067 | meshobj.parent = rootobj |
| 1068 | group.objects.link(meshobj) |
| 1069 | select_ob(meshobj) |
| 1070 | |
| 1071 | if len(iqmodel.anims) > 0: |
| 1072 | make_actions(iqmodel, amtobj, bone_axis) |
| 1073 | |
| 1074 | print("all done.") |
| 1075 | |
| 1076 | def import_iqm(filename, bone_axis='Y'): |
| 1077 | if filename.endswith(".iqm") or filename.endswith(".IQM"): |
| 1078 | iqmodel = load_iqm(filename) |
| 1079 | else: |
| 1080 | iqmodel = load_iqe(filename) |
| 1081 | dir = os.path.dirname(filename) |
| 1082 | make_model(iqmodel, bone_axis, dir) |
| 1083 | bpy.ops.screen.frame_jump() |
| 1084 | |
| 1085 | # |
| 1086 | # Register addon |
| 1087 | # |
| 1088 | |
| 1089 | class ImportIQM(bpy.types.Operator, ImportHelper): |
| 1090 | bl_idname = "import.iqm" |
| 1091 | bl_label = "Import IQM or IQE" |
| 1092 | |
| 1093 | filename_ext = ".iqe, .iqm" |
| 1094 | filter_glob = StringProperty(default="*.iq[em]", options={'HIDDEN'}) |
| 1095 | filepath = StringProperty(name="File Path", maxlen=1024, default="") |
| 1096 | |
| 1097 | bone_axis = EnumProperty(name="Bone Axis", |
| 1098 | description="Flip bones to extend along the Y axis", |
| 1099 | items=[ |
| 1100 | ('Y', "Preserve", ""), |
| 1101 | ('X', "Flip from X to Y", ""), |
| 1102 | ('Z', "Flip from Z to Y", "") |
| 1103 | ], |
| 1104 | default='Y') |
| 1105 | |
| 1106 | def execute(self, context): |
| 1107 | import_iqm(self.properties.filepath, self.bone_axis) |
| 1108 | return {'FINISHED'} |
| 1109 | |
| 1110 | def menu_func(self, context): |
| 1111 | self.layout.operator(ImportIQM.bl_idname, text="Inter-Quake Model (.iqm, .iqe)") |
| 1112 | |
| 1113 | def register(): |
| 1114 | if bpy.app.version >= (2, 80, 0): |
| 1115 | bpy.utils.register_class(ImportIQM) |
| 1116 | bpy.types.TOPBAR_MT_file_import.append(menu_func) |
| 1117 | else: |
| 1118 | bpy.utils.register_module(__name__) |
| 1119 | bpy.types.INFO_MT_file_import.append(menu_func) |
| 1120 | |
| 1121 | def unregister(): |
| 1122 | if bpy.app.version >= (2, 80, 0): |
| 1123 | bpy.types.TOPBAR_MT_file_import.remove(menu_func) |
| 1124 | bpy.utils.unregister_class(ImportIQM) |
| 1125 | else: |
| 1126 | bpy.utils.unregister_module(__name__) |
| 1127 | bpy.types.INFO_MT_file_import.remove(menu_func) |
| 1128 | |
| 1129 | def batch_zap(): |
| 1130 | if "Cube" in bpy.data.objects: |
| 1131 | obj = bpy.data.objects['Cube'] |
| 1132 | bpy.context.scene.objects.unlink(obj) |
| 1133 | bpy.data.objects.remove(obj) |
| 1134 | |
| 1135 | def batch(input): |
| 1136 | batch_zap() |
| 1137 | output = os.path.splitext(input)[0] + ".blend" |
| 1138 | import_iqm(input) |
| 1139 | print("Saving", output) |
| 1140 | bpy.ops.wm.save_mainfile(filepath=output, check_existing=False) |
| 1141 | |
| 1142 | def batch_many(input_list): |
| 1143 | batch_zap() |
| 1144 | output = "output.blend" |
| 1145 | for input in input_list: |
| 1146 | import_iqm(input) |
| 1147 | print("Saving", output) |
| 1148 | bpy.ops.wm.save_mainfile(filepath=output, check_existing=False) |
| 1149 | |
| 1150 | if __name__ == "__main__": |
| 1151 | register() |
| 1152 | if len(sys.argv) > 4 and sys.argv[-2] == '--': |
| 1153 | if "*" in sys.argv[-1]: |
| 1154 | batch_many(glob.glob(sys.argv[-1])) |
| 1155 | else: |
| 1156 | batch(sys.argv[-1]) |
| 1157 | elif len(sys.argv) > 4 and sys.argv[4] == '--': |
| 1158 | batch_many(sys.argv[5:]) |