OverviewFilesCommitsIssuesDocsReleasesPeers

iqm-blender-importer commits / bba22ef

IQM/IQE importer for Blender 5.x with OctaForge material support
Port of the asstools Inter-Quake importer to Blender 5.0/5.1: - custom split normals via normals_split_custom_set_from_vertices - validate() after per-face UV/colour assignment (fixes scrambled UVs) - re-fetch layer handles after color layer creation - guard removed action.id_root (4.4 slotted actions) - OctaForge iqm.cfg material build (ao base + decal, masks R/G/B = spec/glow/ envmap, normal map with green-channel flip) Bundles the OctaForge player model (iqm_src/) and a reference .blend (blender_src/) as example/test data. See README.md and LICENSE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bba22ef94360f979337e146c73eeef3764515243
by plankatron <fleeky@gmail.com> · 2026-06-21T19:11:18.000Z

Changed files (39) +1402 -0

.gitignore +9 -0
……@@ -0,0 +1,9 @@
1# Blender backups / temp
2*.blend1
3*.blend2
4*.blend@
5# OS / editor cruft
6.DS_Store
7*~
8__pycache__/
9*.pyc
LICENSE.md +46 -0
……@@ -0,0 +1,46 @@
1# Licenses & Attribution
2
3This repository bundles content from two upstream projects, each under its
4own permissive license. Both are retained below as required for redistribution.
5
6## 1. `iqe_import.py` — derived from asstools
7
8The Blender importer is a modified version of the Inter-Quake Model importer
9from **asstools** by Tor Andersson (ccxvii), <https://codeberg.org/ccxvii/asstools>.
10
11asstools is distributed under the **BSD license**. The modifications in this
12repository (Blender 5.x compatibility + OctaForge material handling) are
13released under the same terms.
14
15```
16Copyright (c) Tor Andersson and contributors.
17Redistribution and use in source and binary forms, with or without
18modification, are permitted provided that the BSD conditions are met.
19THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
20```
21
22## 2. `iqm_src/` — OctaForge player model & textures
23
24The example model (`player.iqm`, its `animation/` clips, and the
25`diffuse.png` / `fullbody_ao.png` / `fullbody_nm.png` / `masks.png` textures)
26and `blender_src/ofplayer.blend` are assets from **OctaForge**,
27<https://octaforge.org>.
28
29OctaForge is licensed under the **University of Illinois/NCSA Open Source
30License**, a permissive BSD-style license:
31
32```
33Copyright (c) 2011-2015 OctaForge developers. All rights reserved.
34
35Permission is hereby granted, free of charge, to any person obtaining a copy
36of this software and associated documentation files (the "Software"), to deal
37with the Software without restriction... subject to: redistributions must
38retain the above copyright notice, this list of conditions and the following
39disclaimers; neither the names of OctaForge developers nor contributors may be
40used to endorse or promote products derived from this Software without specific
41prior written permission.
42
43THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
44```
45
46These assets are included purely as test/example data for the importer.
README.md +117 -0
……@@ -0,0 +1,117 @@
1# IQM / IQE importer for Blender 5.x (OctaForge-aware)
2
3A Blender add-on that imports **Inter-Quake Model** (`.iqm`) and **Inter-Quake
4Export** (`.iqe`) files — meshes, skeletons, skinning, animations — and, for
5**OctaForge / Tesseract** models, builds a faithful material straight from the
6model's `iqm.cfg`.
7
8It is a port of the importer from [asstools](https://codeberg.org/ccxvii/asstools)
9brought up to date for **Blender 5.0 / 5.1**, plus a correct OctaForge material
10pipeline (decoded from the engine's own shader source).
11
12![OctaForge player imported into Blender](blender_src/ofplayer.jpg)
13
14## Repository layout
15
16```
17iqe_import.py The Blender add-on (single file). Install this.
18blender_src/ Reference .blend + screenshots of the imported OF player.
19iqm_src/ Example OctaForge model (player.iqm, animations, textures,
20 iqm.cfg) used to develop & verify the importer.
21LICENSE.md Attribution for asstools (BSD) and OctaForge (NCSA).
22```
23
24## Install & use
25
261. Blender **Edit ▸ Preferences ▸ Add-ons ▸ Install from Disk…** → pick
27 `iqe_import.py`, then enable it.
282. **File ▸ Import ▸ Inter-Quake Model (.iqm, .iqe)**.
293. Pick `iqm_src/player.iqm` (or any `.iqm`/`.iqe`). Switch the viewport to
30 **Material Preview / Rendered** with a world/HDRI to see textures and the
31 metallic (envmap) reflection.
32
33There's a **Bone Axis** option in the import panel (Preserve / Flip X→Y /
34Flip Z→Y) for assets whose bones don't extend along Y.
35
36> After editing the add-on file, restart Blender (or disable/re-enable it) so
37> the new code is reloaded — Blender caches enabled add-ons.
38
39## What was fixed for Blender 5.x
40
41The original asstools importer targeted Blender 2.8. Several APIs it relied on
42were removed or changed between 2.8 and 5.0; each fix below was verified by
43running Blender 5.0.1 headless against `iqm_src/player.iqm` and a synthetic
44test model.
45
46| Area | Problem in 5.x | Fix |
47|---|---|---|
48| **Custom normals** | `mesh.vertices.foreach_set("normal", …)` is a **silent no-op since 4.1** (vertex normals are computed/read-only). Authored normals were thrown away, so shading fell back to geometric normals from the flipped winding → wrong direction. | Use `mesh.normals_split_custom_set_from_vertices()`. |
49| **UVs scrambled (~17%)** | `mesh.validate()` ran *before* per-face UV/colour assignment. `validate()` removes duplicate faces and shifts polygon indices, so `new_ft[poly_i]` no longer matched the polygon. | Move `validate()` to *after* all per-face data is written. |
50| **UV/colour handle** | Creating a vertex-colour layer reallocates loop CustomData and invalidates a held UV-layer handle (writes produced `nan`). | Re-fetch layer handles by name after all layers exist. |
51| **Animation crash** | `action.id_root` was removed in 4.4 (slotted actions). | Guard with `hasattr`; keyframes import fine under the new action system. |
52| **Misc** | `unpack_list` import unused/deprecated; animation-only IQMs crashed with `UnboundLocalError`. | Dropped the import; clear error message instead. |
53
54## OctaForge material handling
55
56OctaForge models don't name their textures in the IQM material strings — they
57live in an `iqm.cfg` sidecar, and the engine packs data into channels very
58deliberately. The importer parses `iqm.cfg` and reproduces the mapping decoded
59from the engine shader (`config/glsl/model.cfg`):
60
61| `iqm.cfg` command | Texture | Used as |
62|---|---|---|
63| `iqmskin * <tex> <masks> …` | `fullbody_ao.png` | **Base colour** (rgb), alpha = alphatest |
64| | `masks.png` | **R = spec, G = glow, B = envmap reflection** |
65| `iqmdecal * <decal>` | `diffuse.png` | **Decal** mixed over base by its **alpha** |
66| `iqmbumpmap * <normalmap>` | `fullbody_nm.png` | Tangent-space **normal map** |
67
68Mapped onto a Principled BSDF:
69
70- Base Color = `mix(ao.rgb, decal.rgb, decal.a)`
71- Metallic ← `masks.B` (envmap reflection), Specular ← `masks.R` (× `mdlspec`),
72 Emission ← `masks.G` (glow)
73- Normal ← normal map **with the green channel flipped** (see below)
74
75### The normal-map green flip (the subtle one)
76
77OctaForge loads IQM with **un-flipped V** texcoords (and negates Y). This
78importer flips V (`v → 1-v`) so the **diffuse** displays right-side-up in
79Blender's bottom-up UV space — which is correct, but it means a tangent-space
80normal map authored for the engine's top-down V must have its **green channel
81inverted** (the standard OpenGL ↔ DirectX flip).
82
83Because the model uses **mirrored UVs** on ~23% of its faces, getting this
84wrong shows up as *per-piece* "inside-out" lighting on the mirrored parts —
85not a uniform error — which makes it easy to misdiagnose.
86
87This was settled numerically rather than by eye: reproducing OctaForge's exact
88TBN + normal-map decode as ground truth, then comparing Blender's shading
89normal **with** vs **without** the green flip:
90
91| Blender setup | error vs OctaForge |
92|---|---|
93| green as-is | mean **17.2°**, median 10.2° |
94| green **flipped** | mean 2.0°, median **0.09°** |
95
96The green flip matches the engine essentially exactly. (The importer also
97ignores the IQM's stored tangent array and lets Blender recompute tangents via
98Mikktspace; measured difference vs the stored tangents is median ~0.2° — i.e.
99negligible — so it isn't worth a custom tangent node group.)
100
101## Known limitations
102
103- **Animation-only IQMs** (e.g. the files in `iqm_src/animation/`) contain only
104 pose/frame data and **no skeleton** — OctaForge references the skeleton from
105 the base `player.iqm`. They can't be imported standalone; the importer reports
106 a clear error. Applying them onto an already-imported armature is a possible
107 future feature (the data lines up 1:1 — 50 poses ↔ 50 bones).
108- The Metallic ← `masks.B` / Roughness mapping is a reasonable approximation of
109 Tesseract's envmap reflection in Principled-BSDF terms, not a pixel-exact
110 match.
111
112## Credits
113
114- Importer: based on **asstools** by Tor Andersson (BSD).
115- Example model & textures: **OctaForge** (NCSA license).
116
117See [LICENSE.md](LICENSE.md) for full notices.
blender_src/ofplayer.blend +0 -0
(no content changes — binary or rename)
blender_src/ofplayer.jpg +0 -0
(no content changes — binary or rename)
blender_src/ofplayer1.jpg +0 -0
(no content changes — binary or rename)
blender_src/ofplayer2.jpg +0 -0
(no content changes — binary or rename)
blender_src/ofplayer3.jpg +0 -0
(no content changes — binary or rename)
blender_src/ofplayer4.jpg +0 -0
(no content changes — binary or rename)
blender_src/setup.jpg +0 -0
(no content changes — binary or rename)
iqe_import.py +1158 -0
……@@ -0,0 +1,1158 @@
1# Inter-Quake Import
2
3bl_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
14import bpy, math, shlex, struct, os, sys, glob
15
16from bpy.props import *
17from bpy_extras.io_utils import ImportHelper
18from bpy_extras.image_utils import load_image
19from 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
26if 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)
31else:
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
37def 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
52def 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
65class 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
87class IQBone:
88 def __init__(self, name, parent):
89 self.name = name
90 self.parent = parent
91
92class 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
101class IQAnimation:
102 def __init__(self, name):
103 self.name = name
104 self.framerate = 30
105 self.loop = False
106 self.frames = []
107
108class 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
122def 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
206IQM_POSITION = 0
207IQM_TEXCOORD = 1
208IQM_NORMAL = 2
209IQM_BLENDINDEXES = 4
210IQM_BLENDWEIGHTS = 5
211IQM_COLOR = 6
212IQM_CUSTOM = 0x10
213
214IQM_BYTE = 0
215IQM_UBYTE = 1
216IQM_SHORT = 2
217IQM_USHORT = 3
218IQM_INT = 4
219IQM_UINT = 5
220IQM_HALF = 6
221IQM_FLOAT = 7
222IQM_DOUBLE = 8
223
224IQM_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
235def 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
241def 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
246def 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
284def 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
294def 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
308def 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
322def 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
348def 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
358def 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
374def 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
400def 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
459def 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
516def 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
526def 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
539def 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
550images = {}
551of_textures_cache = {}
552
553def 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
595def 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
602def 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
611def 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
681def 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
741def 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
750def 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
764def 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
773def 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
1038def 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
1076def 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
1089class 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
1110def menu_func(self, context):
1111 self.layout.operator(ImportIQM.bl_idname, text="Inter-Quake Model (.iqm, .iqe)")
1112
1113def 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
1121def 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
1129def 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
1135def 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
1142def 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
1150if __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:])
iqm_src/animation.cfg +60 -0
……@@ -0,0 +1,60 @@
1//iqmtag tag_weapon tag_weapon 0 0 0 0 0 90
2
3iqmpitch spine 0.4 0 -30 30
4iqmpitch chest 0.3 0 -30 30
5//iqmpitch chest 0.30 0 -7 9
6//iqmpitch C_chest 0.30 0 -6 3
7//iqmpitch C_neck 0.30 0 -9 13
8//iqmpitch C_head 0.25 0 -15 19
9//iqmpitch L_arm 0.30 0 -15 40
10//iqmpitch R_arm 0.30 0 -15 25
11
12iqmanim "mapmodel" "animation/rest.iqm"
13iqmanim "edit" "animation/jump.iqm"
14iqmanim "lag" "animation/rest.iqm"
15
16iqmanim "idle" "animation/stand.iqm"
17iqmanim "run_N" "animation/forward.iqm" 27
18iqmanim "run_NE" "animation/forward_45R.iqm" 35
19iqmanim "run_E" "animation/sideright.iqm" 33
20iqmanim "run_SE" "animation/backward45_R.iqm" 35
21iqmanim "run_S" "animation/backward.iqm" 27
22iqmanim "run_SW" "animation/backward45_L.iqm" 35
23iqmanim "run_W" "animation/sideleft.iqm" 33
24iqmanim "run_NW" "animation/forward_45L.iqm" 35
25
26iqmanim "jump" "animation/jump.iqm"
27iqmanim "jump_N" "animation/jump_forward.iqm"
28iqmanim "jump_NE" "animation/jump_forward45R.iqm"
29iqmanim "jump_E" "animation/jump_right.iqm"
30iqmanim "jump_SE" "animation/jump_backward45R.iqm"
31iqmanim "jump_S" "animation/jump_backward.iqm"
32iqmanim "jump_SW" "animation/jump_backward45L.iqm"
33iqmanim "jump_W" "animation/jump_left.iqm"
34iqmanim "jump_NW" "animation/jump_forward45L.iqm"
35
36iqmanim "crouch" "animation/crouch.iqm"
37iqmanim "crouch_N" "animation/crouch_forward.iqm" 21
38iqmanim "crouch_NE" "animation/crouch_forward.iqm" 21
39iqmanim "crouch_E" "animation/crouch_forward.iqm" 21
40iqmanim "crouch_SE" "animation/crouch_forward.iqm" 21
41iqmanim "crouch_S" "animation/crouch_forward.iqm" 21
42iqmanim "crouch_SW" "animation/crouch_forward.iqm" 21
43iqmanim "crouch_W" "animation/crouch_forward.iqm" 21
44iqmanim "crouch_NW" "animation/crouch_forward.iqm" 21
45
46//iqmanim "crouch jump" "animation/crouch_jump.iqmanim"
47//iqmanim "crouch jump N" "animation/crouch_jump_N.iqmanim"
48//iqmanim "crouch jump NE" "animation/crouch_jump_NE.iqmanim"
49//iqmanim "crouch jump E" "animation/crouch_jump_E.iqmanim"
50//iqmanim "crouch jump SE" "animation/crouch_jump_SE.iqmanim"
51//iqmanim "crouch jump S" "animation/crouch_jump_S.iqmanim"
52//iqmanim "crouch jump SW" "animation/crouch_jump_SW.iqmanim"
53//iqmanim "crouch jump W" "animation/crouch_jump_W.iqmanim"
54//iqmanim "crouch jump NW" "animation/crouch_jump_NW.iqmanim"
55
56//iqmanim "sink" "animation/jump.iqmanim"
57//iqmanim "swim" "animation/jump_N.iqmanim"
58
59//iqmanim "crouch sink" "animation/crouch_jump.iqmanim"
60//iqmanim "crouch swim" "animation/crouch_jump_N.iqmanim"
iqm_src/animation/backward.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/backward45_L.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/backward45_R.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/crouch.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/crouch_forward.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/forward.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/forward_45L.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/forward_45R.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/jump.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/jump_backward.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/jump_backward45L.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/jump_backward45R.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/jump_forward.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/jump_forward45L.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/jump_forward45R.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/jump_left.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/jump_right.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/rest.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/sideleft.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/sideright.iqm +0 -0
(no content changes — binary or rename)
iqm_src/animation/stand.iqm +0 -0
(no content changes — binary or rename)
iqm_src/diffuse.png +0 -0
(no content changes — binary or rename)
iqm_src/fullbody_ao.png +0 -0
(no content changes — binary or rename)
iqm_src/fullbody_nm.png +0 -0
(no content changes — binary or rename)
iqm_src/iqm.cfg +12 -0
……@@ -0,0 +1,12 @@
1iqmload "player.iqm" player 180
2
3iqmskin * fullbody_ao.png masks.png 0.6 0.3
4//iqmcolor * -1
5iqmdecal * diffuse.png
6iqmbumpmap * fullbody_nm.png
7
8exec "media/model/player/animation.cfg"
9//exec "media/model/player/hammer/ragdoll.cfg"
10
11mdlspec 150
12mdlscale 345
iqm_src/masks.png +0 -0
(no content changes — binary or rename)
iqm_src/player.iqm +0 -0
(no content changes — binary or rename)