OverviewFilesCommitsIssuesDocsReleasesPeers

iqm-blender-importer commits / 60e160f

Add animation import for skeleton-less IQM clips
OctaForge stores each animation as a separate skeleton-less .iqm (pose/frame data indexing the base model's bones). Apply these onto an already-imported armature: - detect animation-only files (0 bones, 0 meshes, has anims) - reload the base model's skeleton + bind pose (matched by bone count), graft the clip's frames on, reuse make_actions -> one Blender action per clip - operator now accepts multiple files; models import before clips so the armature exists when clips attach - make_pose skips missing pose bones; extracted compute_bind_mats helper Verified headless: multi-select base + clips and sequential import both produce per-clip actions with real motion; no-armature case errors clearly; embedded- animation self-contained models still import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
60e160f27d64492d03f15098e76459ce9cd9d287
by plankatron <fleeky@gmail.com> · 2026-06-23T23:43:26.000Z

Changed files (2) +126 -19

README.md +21 -6
……@@ -25,7 +25,7 @@ LICENSE.md Attribution for asstools (BSD) and OctaForge (NCSA).
2525
26261. Blender **Edit ▸ Preferences ▸ Add-ons ▸ Install from Disk…** → pick
2727 `iqe_import.py`, then enable it.
282. **File ▸ Import ▸ Inter-Quake Model (.iqm, .iqe)**.
282. **File ▸ Import ▸ Inter-Quake Model + Anims (.iqm, .iqe)**.
29293. Pick `iqm_src/player.iqm` (or any `.iqm`/`.iqe`). Switch the viewport to
3030 **Material Preview / Rendered** with a world/HDRI to see textures and the
3131 metallic (envmap) reflection.
……@@ -33,6 +33,22 @@ LICENSE.md Attribution for asstools (BSD) and OctaForge (NCSA).
3333There's a **Bone Axis** option in the import panel (Preserve / Flip X→Y /
3434Flip Z→Y) for assets whose bones don't extend along Y.
3535
36### Animations
37
38Self-contained files import their embedded animations automatically. Models
39that split clips into separate **skeleton-less** files (OctaForge does this —
40see `iqm_src/animation/`) are applied onto an existing armature:
41
42- **One step:** in the import dialog, shift-select the base model **and** the
43 clips together (e.g. `player.iqm` + everything in `animation/`). Models are
44 imported first, then each clip is attached to the new armature as its own
45 **action** (browse them in the Action Editor / NLA).
46- **Or sequentially:** import `player.iqm` first, then import the clip files —
47 they attach to the armature already in the scene.
48
49The base model (the one with the skeleton) must be importable alongside the
50clips; the importer matches them by bone count.
51
3652> After editing the add-on file, restart Blender (or disable/re-enable it) so
3753> the new code is reloaded — Blender caches enabled add-ons.
3854
……@@ -100,11 +116,10 @@ negligible — so it isn't worth a custom tangent node group.)
100116
101117## Known limitations
102118
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).
119- **Skeleton-less animation clips** (e.g. `iqm_src/animation/*.iqm`) are applied
120 onto an existing armature, matched by bone count (see *Animations* above), so
121 the base model must be available/imported. They can't be imported on their own
122 with no armature in the scene — the importer reports a clear error.
108123- The Metallic ← `masks.B` / Roughness mapping is a reasonable approximation of
109124 Tesseract's envmap reflection in Principled-BSDF terms, not a pixel-exact
110125 match.
iqe_import.py +105 -13
……@@ -456,6 +456,15 @@ def calc_pose_mats(iqmodel, iqpose, bone_axis):
456456
457457 return loc_pose_mat, abs_pose_mat
458458
459def compute_bind_mats(iqmodel, bone_axis):
460 # Derive the bind-pose matrices used to convert per-frame poses into bone
461 # space. Stored on the model so both armature creation and (later)
462 # animation application can reuse them.
463 loc_bind_mat, abs_bind_mat = calc_pose_mats(iqmodel, iqmodel.bindpose, bone_axis)
464 iqmodel.abs_bind_mat = abs_bind_mat
465 iqmodel.loc_bind_mat = loc_bind_mat
466 iqmodel.inv_loc_bind_mat = [m.inverted() for m in loc_bind_mat]
467
459468def make_armature(iqmodel, bone_axis):
460469
461470 if len(iqmodel.bones) == 0: return None
……@@ -475,10 +484,8 @@ def make_armature(iqmodel, bone_axis):
475484 amt.name = iqmodel.name
476485 obj.name = iqmodel.name + ".amt"
477486
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]
487 compute_bind_mats(iqmodel, bone_axis)
488 abs_bind_mat = iqmodel.abs_bind_mat
482489
483490 for n in range(len(iqmodel.bones)):
484491 iqbone = iqmodel.bones[n]
……@@ -517,7 +524,9 @@ def make_pose(iqmodel, frame, amtobj, bone_axis, tick):
517524 loc_pose_mat, _ = calc_pose_mats(iqmodel, frame, bone_axis)
518525 for n in range(len(iqmodel.bones)):
519526 name = iqmodel.bones[n].name
520 pose_bone = amtobj.pose.bones[name]
527 pose_bone = amtobj.pose.bones.get(name)
528 if pose_bone is None:
529 continue
521530 pose_bone.matrix_basis = matmul(iqmodel.inv_loc_bind_mat[n], loc_pose_mat[n])
522531 pose_bone.keyframe_insert(group=name, frame=tick, data_path='location')
523532 pose_bone.keyframe_insert(group=name, frame=tick, data_path='rotation_quaternion')
……@@ -1073,14 +1082,89 @@ def make_model(iqmodel, bone_axis, dir):
10731082
10741083 print("all done.")
10751084
1076def import_iqm(filename, bone_axis='Y'):
1085#
1086# Animation-only files (no joints, no meshes — e.g. OctaForge per-clip IQMs)
1087# carry pose/frame data that indexes the base model's skeleton by order. We
1088# can't build them standalone, but we can apply them onto an already-imported
1089# armature: reload the base model's skeleton + bind pose, graft the clip's
1090# frames on, and reuse the normal action-building path.
1091#
1092
1093base_skeleton_cache = {}
1094
1095def load_model(filename):
10771096 if filename.endswith(".iqm") or filename.endswith(".IQM"):
1078 iqmodel = load_iqm(filename)
1097 return load_iqm(filename)
1098 return load_iqe(filename)
1099
1100def is_animation_only(iqmodel):
1101 return len(iqmodel.bones) == 0 and len(iqmodel.meshes) == 0 and len(iqmodel.anims) > 0
1102
1103def find_base_skeleton(anim_filename, nbones):
1104 # Look for a sibling/parent model file whose skeleton matches the clip's
1105 # bone count (OctaForge keeps animation/<clip>.iqm next to <model>.iqm).
1106 anim_dir = os.path.dirname(anim_filename)
1107 for search_dir in [os.path.dirname(anim_dir), anim_dir]:
1108 if not search_dir:
1109 continue
1110 for cand in sorted(glob.glob(os.path.join(search_dir, "*.iqm")) +
1111 glob.glob(os.path.join(search_dir, "*.iqe"))):
1112 if os.path.abspath(cand) == os.path.abspath(anim_filename):
1113 continue
1114 key = os.path.abspath(cand)
1115 if key not in base_skeleton_cache:
1116 try:
1117 base_skeleton_cache[key] = load_model(cand)
1118 except Exception:
1119 base_skeleton_cache[key] = None
1120 base = base_skeleton_cache[key]
1121 if base and len(base.bones) == nbones and nbones > 0:
1122 print("using base skeleton", cand)
1123 return base
1124 return None
1125
1126def find_target_armature(nbones):
1127 cands = [o for o in bpy.context.scene.objects if o.type == 'ARMATURE']
1128 matching = [o for o in cands if len(o.data.bones) == nbones]
1129 act = bpy.context.active_object
1130 if act in matching: return act
1131 if matching: return matching[0]
1132 if act in cands: return act
1133 return cands[0] if cands else None
1134
1135def apply_animation(iqmodel, filename, bone_axis):
1136 nbones = len(iqmodel.anims[0].frames[0]) if iqmodel.anims[0].frames else 0
1137 amtobj = find_target_armature(nbones)
1138 if amtobj is None:
1139 raise Exception("No armature in the scene to apply this animation to. "
1140 "Import the base model (with the skeleton) first.")
1141 base = find_base_skeleton(filename, nbones)
1142 if base is None:
1143 raise Exception("Could not find the base model (%d-bone skeleton) for this "
1144 "animation. Keep the base .iqm next to it and import it first." % nbones)
1145 base.anims = iqmodel.anims
1146 compute_bind_mats(base, bone_axis)
1147 print("applying %d animation(s) onto armature '%s'" % (len(base.anims), amtobj.name))
1148 make_actions(base, amtobj, bone_axis)
1149
1150def dispatch_iqmodel(iqmodel, filename, bone_axis):
1151 if is_animation_only(iqmodel):
1152 apply_animation(iqmodel, filename, bone_axis)
10791153 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()
1154 make_model(iqmodel, bone_axis, os.path.dirname(filename))
1155 bpy.ops.screen.frame_jump()
1156
1157def import_iqm(filename, bone_axis='Y'):
1158 dispatch_iqmodel(load_model(filename), filename, bone_axis)
1159
1160def import_iqm_files(filenames, bone_axis='Y'):
1161 # Load everything, then process models (skeleton/mesh) before animation-only
1162 # clips so the armature exists when the clips are applied.
1163 loaded = [(f, load_model(f)) for f in filenames]
1164 models = [(f, m) for f, m in loaded if not is_animation_only(m)]
1165 anims = [(f, m) for f, m in loaded if is_animation_only(m)]
1166 for f, m in models + anims:
1167 dispatch_iqmodel(m, f, bone_axis)
10841168
10851169#
10861170# Register addon
……@@ -1093,6 +1177,10 @@ class ImportIQM(bpy.types.Operator, ImportHelper):
10931177 filename_ext = ".iqe, .iqm"
10941178 filter_glob = StringProperty(default="*.iq[em]", options={'HIDDEN'})
10951179 filepath = StringProperty(name="File Path", maxlen=1024, default="")
1180 # Allow selecting several files at once (e.g. a model + its animation clips);
1181 # models are imported first so clips can attach to the new armature.
1182 files = CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
1183 directory = StringProperty(subtype='DIR_PATH')
10961184
10971185 bone_axis = EnumProperty(name="Bone Axis",
10981186 description="Flip bones to extend along the Y axis",
……@@ -1104,11 +1192,15 @@ class ImportIQM(bpy.types.Operator, ImportHelper):
11041192 default='Y')
11051193
11061194 def execute(self, context):
1107 import_iqm(self.properties.filepath, self.bone_axis)
1195 if self.files and self.directory:
1196 paths = [os.path.join(self.directory, f.name) for f in self.files if f.name]
1197 else:
1198 paths = [self.properties.filepath]
1199 import_iqm_files(paths, self.bone_axis)
11081200 return {'FINISHED'}
11091201
11101202def menu_func(self, context):
1111 self.layout.operator(ImportIQM.bl_idname, text="Inter-Quake Model (.iqm, .iqe)")
1203 self.layout.operator(ImportIQM.bl_idname, text="Inter-Quake Model + Anims (.iqm, .iqe)")
11121204
11131205def register():
11141206 if bpy.app.version >= (2, 80, 0):