From c3ac168b947e17b7a26623535d0984641d038243 Mon Sep 17 00:00:00 2001
From: Jrius <2261279+Jrius@users.noreply.github.com>
Date: Sun, 15 Dec 2024 14:06:25 +0100
Subject: [PATCH 1/6] Animations: allow baking keyframes

---
 korman/exporter/animation.py        | 78 ++++++++++++++++++++---------
 korman/properties/modifiers/anim.py |  3 +-
 korman/properties/prop_anim.py      | 23 +++++++++
 korman/ui/ui_anim.py                |  5 ++
 4 files changed, 85 insertions(+), 24 deletions(-)

diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py
index e34eef6..ab70e78 100644
--- a/korman/exporter/animation.py
+++ b/korman/exporter/animation.py
@@ -26,6 +26,7 @@ import weakref
 from PyHSPlasma import *
 
 from . import utils
+from ..helpers import GoodNeighbor
 
 class AnimationConverter:
     def __init__(self, exporter):
@@ -36,38 +37,69 @@ class AnimationConverter:
         return frame_num / self._bl_fps
 
     def convert_object_animations(self, bo: bpy.types.Object, so: plSceneObject, anim_name: str, *,
-                                  start: Optional[int] = None, end: Optional[int] = None) -> Iterable[plAGApplicator]:
+                                  start: Optional[int] = None, end: Optional[int] = None, bake_frame_step: Optional[int] = None) -> Iterable[plAGApplicator]:
         if not bo.plasma_object.has_animation_data:
             return []
 
-        def fetch_animation_data(id_data):
+        temporary_actions = []
+
+        def bake_animation_data():
+            # Baking animations is a Blender operator, so requires a bit of boilerplate...
+            with GoodNeighbor() as toggle:
+                # Make sure we have only this object selected.
+                toggle.track(bo, "hide", False)
+                for i in bpy.data.objects:
+                    i.select = i == bo
+                bpy.context.scene.objects.active = bo
+
+                # Do bake, but make sure we don't mess the user's data.
+                old_action = bo.animation_data.action
+                try:
+                    frame_start = start if start is not None else bpy.context.scene.frame_start
+                    frame_end = end if end is not None else bpy.context.scene.frame_end
+                    bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, visual_keying=True, bake_types={"POSE", "OBJECT"})
+                    action = bo.animation_data.action
+                finally:
+                    bo.animation_data.action = old_action
+                temporary_actions.append(action)
+                return action
+
+        def fetch_animation_data(id_data, can_bake):
             if id_data is not None:
                 if id_data.animation_data is not None:
                     action = id_data.animation_data.action
+                    if bake_frame_step is not None and can_bake:
+                        action = bake_animation_data()
                     return action, getattr(action, "fcurves", [])
             return None, []
 
-        obj_action, obj_fcurves = fetch_animation_data(bo)
-        data_action, data_fcurves = fetch_animation_data(bo.data)
-
-        # We're basically just going to throw all the FCurves at the controller converter (read: wall)
-        # and see what sticks. PlasmaMAX has some nice animation channel stuff that allows for some
-        # form of separation, but Blender's NLA editor is way confusing and appears to not work with
-        # things that aren't the typical position, rotation, scale animations.
-        applicators = []
-        if isinstance(bo.data, bpy.types.Camera):
-            applicators.append(self._convert_camera_animation(bo, so, obj_fcurves, data_fcurves, anim_name, start, end))
-        else:
-            applicators.append(self._convert_transform_animation(bo, obj_fcurves, bo.matrix_local, bo.matrix_parent_inverse, start=start, end=end))
-        if bo.plasma_modifiers.soundemit.enabled:
-            applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit, start, end))
-        if isinstance(bo.data, bpy.types.Lamp):
-            lamp = bo.data
-            applicators.extend(self._convert_lamp_color_animation(bo.name, data_fcurves, lamp, start, end))
-            if isinstance(lamp, bpy.types.SpotLamp):
-                applicators.extend(self._convert_spot_lamp_animation(bo.name, data_fcurves, lamp, start, end))
-            if isinstance(lamp, bpy.types.PointLamp):
-                applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end))
+        try:
+            obj_action, obj_fcurves = fetch_animation_data(bo, True)
+            data_action, data_fcurves = fetch_animation_data(bo.data, False)
+
+            # We're basically just going to throw all the FCurves at the controller converter (read: wall)
+            # and see what sticks. PlasmaMAX has some nice animation channel stuff that allows for some
+            # form of separation, but Blender's NLA editor is way confusing and appears to not work with
+            # things that aren't the typical position, rotation, scale animations.
+            applicators = []
+            if isinstance(bo.data, bpy.types.Camera):
+                applicators.append(self._convert_camera_animation(bo, so, obj_fcurves, data_fcurves, anim_name, start, end))
+            else:
+                applicators.append(self._convert_transform_animation(bo, obj_fcurves, bo.matrix_local, bo.matrix_parent_inverse, start=start, end=end))
+            if bo.plasma_modifiers.soundemit.enabled:
+                applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit, start, end))
+            if isinstance(bo.data, bpy.types.Lamp):
+                lamp = bo.data
+                applicators.extend(self._convert_lamp_color_animation(bo.name, data_fcurves, lamp, start, end))
+                if isinstance(lamp, bpy.types.SpotLamp):
+                    applicators.extend(self._convert_spot_lamp_animation(bo.name, data_fcurves, lamp, start, end))
+                if isinstance(lamp, bpy.types.PointLamp):
+                    applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end))
+
+        finally:
+            for action in temporary_actions:
+                # Baking data is temporary, but the lifetime of our user's data is eternal !
+                bpy.data.actions.remove(action)
 
         return [i for i in applicators if i is not None]
 
diff --git a/korman/properties/modifiers/anim.py b/korman/properties/modifiers/anim.py
index 8db48fb..e17c9a5 100644
--- a/korman/properties/modifiers/anim.py
+++ b/korman/properties/modifiers/anim.py
@@ -99,8 +99,9 @@ class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties):
                 start, end = min((start, end)), max((start, end))
             else:
                 start, end = None, None
+            bake_frame_step = anim.bake_frame_step if anim.bake else None
 
-            applicators = converter.convert_object_animations(bo, so, anim_name, start=start, end=end)
+            applicators = converter.convert_object_animations(bo, so, anim_name, start=start, end=end, bake_frame_step=bake_frame_step)
             if not applicators:
                 exporter.report.warn(f"Animation '{anim_name}' generated no applicators. Nothing will be exported.")
                 continue
diff --git a/korman/properties/prop_anim.py b/korman/properties/prop_anim.py
index 47fb593..f8e0f85 100644
--- a/korman/properties/prop_anim.py
+++ b/korman/properties/prop_anim.py
@@ -127,6 +127,29 @@ class PlasmaAnimation(bpy.types.PropertyGroup):
                 bpy.types.Texture: "plasma_layer.anim_loop_end",
             },
         },
+        "bake": {
+            "type": BoolProperty,
+            "property": {
+                "name": "Bake Keyframes",
+                "description": "Bake animation keyframes on export. This generates a lot more intermediary keyframes but allows exporting inverse kinematics, and may improve timing for complex animations",
+                "default": False,
+            },
+            "entire_animation": {
+                bpy.types.Object: "plasma_modifiers.animation.bake",
+            },
+        },
+        "bake_frame_step": {
+            "type": IntProperty,
+            "property": {
+                "name": "Frame step",
+                "description": "How many frames between each keyframe sample",
+                "default": 1,
+                "min": 1,
+            },
+            "entire_animation": {
+                bpy.types.Object: "plasma_modifiers.animation.bake_frame_step",
+            },
+        },
         "sdl_var": {
             "type": StringProperty,
             "property": {
diff --git a/korman/ui/ui_anim.py b/korman/ui/ui_anim.py
index 3741854..c1f2689 100644
--- a/korman/ui/ui_anim.py
+++ b/korman/ui/ui_anim.py
@@ -67,6 +67,11 @@ def draw_single_animation(layout, anim):
             col.active = anim.loop and not anim.sdl_var
             col.prop_search(anim, "loop_start", action, "pose_markers", icon="PMARKER")
             col.prop_search(anim, "loop_end", action, "pose_markers", icon="PMARKER")
+            layout.separator()
+            split = layout.split()
+            split.prop(anim, "bake")
+            if anim.bake:
+                split.prop(anim, "bake_frame_step")
 
     layout.separator()
     layout.prop(anim, "sdl_var")

From ca28135f16d57c30aa367e5057a8c20a4398749f Mon Sep 17 00:00:00 2001
From: Jrius <2261279+Jrius@users.noreply.github.com>
Date: Mon, 16 Dec 2024 20:38:47 +0100
Subject: [PATCH 2/6] Export armatures

---
 korman/exporter/animation.py | 119 ++++++++++++++++++++++++++---------
 korman/exporter/armature.py  |  92 +++++++++++++++++++++++++++
 korman/exporter/convert.py   |  45 ++++++++++---
 korman/helpers.py            |   4 +-
 4 files changed, 220 insertions(+), 40 deletions(-)
 create mode 100644 korman/exporter/armature.py

diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py
index ab70e78..84b6a93 100644
--- a/korman/exporter/animation.py
+++ b/korman/exporter/animation.py
@@ -16,66 +16,103 @@
 import bpy
 
 from collections import defaultdict
+from contextlib import ExitStack
 import functools
 import itertools
 import math
 import mathutils
 from typing import *
 import weakref
+import re
 
 from PyHSPlasma import *
 
 from . import utils
-from ..helpers import GoodNeighbor
+from ..helpers import *
 
 class AnimationConverter:
     def __init__(self, exporter):
         self._exporter = weakref.ref(exporter)
         self._bl_fps = bpy.context.scene.render.fps
+        self._bone_data_path_regex = re.compile('^pose\\.bones\\["(.*)"]\\.(.*)$')
 
     def convert_frame_time(self, frame_num: int) -> float:
         return frame_num / self._bl_fps
 
+    def copy_armature_animation_to_temporary_bones(self, arm_bo: bpy.types.Object, generated_bones):
+        # Enable the anim group. We will need it to reroute anim messages to children bones.
+        # Please note: Plasma supports grouping many animation channels into a single ATC anim, but only programmatically.
+        # So instead we will do it the Cyan Way(tm), which is to make dozens or maybe even hundreds of small ATC anims. Derp.
+        temporary_objects = []
+        anim = arm_bo.plasma_modifiers.animation
+        anim_group = arm_bo.plasma_modifiers.animation_group
+        do_bake = anim.bake
+        exit_stack = ExitStack()
+        toggle = GoodNeighbor()
+        toggle.track(anim, "bake", False)
+        toggle.track(anim_group, "enabled", True)
+        temporary_objects.append(toggle)
+        temporary_objects.append(exit_stack)
+
+        armature_action = arm_bo.animation_data.action
+        if do_bake:
+            armature_action = self._bake_animation_data(arm_bo, anim.bake_frame_step, None, None)
+
+        try:
+            for bone_name, bone in generated_bones.items():
+                child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children))
+                child.child_anim = bone
+                # Copy animation modifier and its properties if it exists..
+                # Cheating ? Very much yes. Do not try this at home, kids.
+                bone.plasma_modifiers["animation"] = arm_bo.plasma_modifiers["animation"]
+                bone.plasma_modifiers["animation_loop"] = arm_bo.plasma_modifiers["animation_loop"]
+
+                # Now copy animation data.
+                anim_data = bone.animation_data_create()
+                action = bpy.data.actions.new("{}_action".format(bone.name))
+                temporary_objects.append(action)
+                anim_data.action = action
+                self._exporter().report.warn(str([(i.data_path, i.array_index) for i in armature_action.fcurves]))
+                for fcurve in armature_action.fcurves:
+                    match = self._bone_data_path_regex.match(fcurve.data_path)
+                    if not match:
+                        continue
+                    name, data_path = match.groups()
+                    if name != bone_name:
+                        continue
+                    new_curve = action.fcurves.new(data_path, fcurve.array_index)
+                    for point in fcurve.keyframe_points:
+                        # Thanks to bone_parent we can just copy the animation without a care in the world ! :P
+                        p = new_curve.keyframe_points.insert(point.co[0], point.co[1])
+                for original_marker in armature_action.pose_markers:
+                    marker = action.pose_markers.new(original_marker.name)
+                    marker.frame = original_marker.frame
+        finally:
+            if do_bake:
+                bpy.data.actions.remove(armature_action)
+
+        return temporary_objects
+
     def convert_object_animations(self, bo: bpy.types.Object, so: plSceneObject, anim_name: str, *,
                                   start: Optional[int] = None, end: Optional[int] = None, bake_frame_step: Optional[int] = None) -> Iterable[plAGApplicator]:
         if not bo.plasma_object.has_animation_data:
             return []
 
-        temporary_actions = []
-
-        def bake_animation_data():
-            # Baking animations is a Blender operator, so requires a bit of boilerplate...
-            with GoodNeighbor() as toggle:
-                # Make sure we have only this object selected.
-                toggle.track(bo, "hide", False)
-                for i in bpy.data.objects:
-                    i.select = i == bo
-                bpy.context.scene.objects.active = bo
-
-                # Do bake, but make sure we don't mess the user's data.
-                old_action = bo.animation_data.action
-                try:
-                    frame_start = start if start is not None else bpy.context.scene.frame_start
-                    frame_end = end if end is not None else bpy.context.scene.frame_end
-                    bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, visual_keying=True, bake_types={"POSE", "OBJECT"})
-                    action = bo.animation_data.action
-                finally:
-                    bo.animation_data.action = old_action
-                temporary_actions.append(action)
-                return action
-
-        def fetch_animation_data(id_data, can_bake):
+        def fetch_animation_data(id_data):
             if id_data is not None:
                 if id_data.animation_data is not None:
                     action = id_data.animation_data.action
-                    if bake_frame_step is not None and can_bake:
-                        action = bake_animation_data()
                     return action, getattr(action, "fcurves", [])
             return None, []
 
+        obj_action, obj_fcurves = fetch_animation_data(bo)
+        data_action, data_fcurves = fetch_animation_data(bo.data)
+        temporary_action = None
+
         try:
-            obj_action, obj_fcurves = fetch_animation_data(bo, True)
-            data_action, data_fcurves = fetch_animation_data(bo.data, False)
+            if bake_frame_step is not None and obj_action is not None:
+                temporary_action = obj_action = self._bake_animation_data(bo, bake_frame_step, start, end)
+                obj_fcurves = obj_action.fcurves
 
             # We're basically just going to throw all the FCurves at the controller converter (read: wall)
             # and see what sticks. PlasmaMAX has some nice animation channel stuff that allows for some
@@ -97,12 +134,32 @@ class AnimationConverter:
                     applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end))
 
         finally:
-            for action in temporary_actions:
+            if temporary_action is not None:
                 # Baking data is temporary, but the lifetime of our user's data is eternal !
-                bpy.data.actions.remove(action)
+                bpy.data.actions.remove(temporary_action)
 
         return [i for i in applicators if i is not None]
 
+    def _bake_animation_data(self, bo, bake_frame_step: int, start: Optional[int] = None, end: Optional[int] = None):
+        # Baking animations is a Blender operator, so requires a bit of boilerplate...
+        with GoodNeighbor() as toggle:
+            # Make sure we have only this object selected.
+            toggle.track(bo, "hide", False)
+            for i in bpy.data.objects:
+                i.select = i == bo
+            bpy.context.scene.objects.active = bo
+
+            # Do bake, but make sure we don't mess the user's data.
+            old_action = bo.animation_data.action
+            try:
+                frame_start = start if start is not None else bpy.context.scene.frame_start
+                frame_end = end if end is not None else bpy.context.scene.frame_end
+                bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, only_selected=False, visual_keying=True, bake_types={"POSE", "OBJECT"})
+                baked_anim = bo.animation_data.action
+                return baked_anim
+            finally:
+                bo.animation_data.action = old_action
+
     def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves, anim_name: str,
                                   start: Optional[int], end: Optional[int]):
         has_fov_anim = False
diff --git a/korman/exporter/armature.py b/korman/exporter/armature.py
new file mode 100644
index 0000000..7063000
--- /dev/null
+++ b/korman/exporter/armature.py
@@ -0,0 +1,92 @@
+#    This file is part of Korman.
+#
+#    Korman is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    Korman is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with Korman.  If not, see <http://www.gnu.org/licenses/>.
+
+import bpy
+from mathutils import Matrix
+import weakref
+from PyHSPlasma import *
+
+from . import utils
+
+class ArmatureConverter:
+    def __init__(self, exporter):
+        self._exporter = weakref.ref(exporter)
+
+    def convert_armature_to_empties(self, bo):
+        # Creates Blender equivalents to each bone of the armature, adjusting a whole bunch of stuff along the way.
+        # Yes, this is ugly, but required to get anims to export properly. I tried other ways to export armatures,
+        # but AFAICT sooner or later you have to implement similar hacks. Might as well create something that the
+        # animation exporter can already deal with, with no modification...
+        # Don't worry, we'll return a list of temporary objects to clean up after ourselves.
+        armature = bo.data
+        pose = bo.pose if armature.pose_position == "POSE" else None
+        generated_bones = {} # name: Blender empty.
+        temporary_objects = []
+        for bone in armature.bones:
+            if bone.parent:
+                continue
+            self._export_bone(bo, bone, bo, Matrix.Identity(4), pose, generated_bones, temporary_objects)
+
+        if bo.plasma_modifiers.animation.enabled and bo.animation_data is not None and bo.animation_data.action is not None:
+            # Let the anim exporter handle the anim crap.
+            temporary_objects.extend(self._exporter().animation.copy_armature_animation_to_temporary_bones(bo, generated_bones))
+
+        return temporary_objects
+
+    def _export_bone(self, bo, bone, parent, matrix, pose, generated_bones, temporary_objects):
+        bone_empty = bpy.data.objects.new(ArmatureConverter.get_bone_name(bo, bone), None)
+        bpy.context.scene.objects.link(bone_empty)
+        bone_empty.plasma_object.enabled = True
+
+        # Grmbl, animation is relative to rest pose in Blender, and relative to parent in Plasma...
+        # Using matrix_parent_inverse or manually adjust keyframes will just mess up rotation keyframes,
+        # so let's just insert an extra empty object to correct all that. This is why the CoordinateInterface caches computed matrices, after all...
+        bone_parent = bpy.data.objects.new(bone_empty.name + "_REST", None)
+        bpy.context.scene.objects.link(bone_parent)
+        bone_parent.plasma_object.enabled = True
+        bone_parent.parent = parent
+        bone_empty.parent = bone_parent
+        bone_empty.matrix_local = Matrix.Identity(4)
+
+        if pose is not None:
+            pose_bone = pose.bones[bone.name]
+            bone_empty.rotation_mode = pose_bone.rotation_mode
+            pose_matrix = pose_bone.matrix_basis
+        else:
+            pose_bone = None
+            pose_matrix = Matrix.Identity(4)
+
+        temporary_objects.append(bone_empty)
+        temporary_objects.append(bone_parent)
+        generated_bones[bone.name] = bone_empty
+        bone_parent.matrix_local = matrix * bone.matrix_local.to_4x4() * pose_matrix
+
+        for child in bone.children:
+            child_empty = self._export_bone(bo, child, bone_empty, bone.matrix_local.inverted(), pose, generated_bones, temporary_objects)
+        return bone_empty
+
+    @staticmethod
+    def get_bone_name(bo, bone):
+        if isinstance(bone, str):
+            return "{}_{}".format(bo.name, bone)
+        return "{}_{}".format(bo.name, bone.name)
+
+    @property
+    def _mgr(self):
+        return self._exporter().mgr
+
+    @property
+    def _report(self):
+        return self._exporter().report
diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py
index c12627b..f2cc4a8 100644
--- a/korman/exporter/convert.py
+++ b/korman/exporter/convert.py
@@ -21,6 +21,7 @@ import functools
 import inspect
 from pathlib import Path
 from typing import *
+from mathutils import Matrix
 
 from ..helpers import TemporaryObject
 from ..korlib import ConsoleToggler
@@ -28,6 +29,7 @@ from ..korlib import ConsoleToggler
 from PyHSPlasma import *
 
 from .animation import AnimationConverter
+from .armature import ArmatureConverter
 from .camera import CameraConverter
 from .decal import DecalConverter
 from . import explosions
@@ -56,6 +58,7 @@ class Exporter:
         physics: PhysicsConverter = ...
         light: LightConverter = ...
         animation: AnimationConverter = ...
+        armature: ArmatureConverter = ...
         output: OutputFiles = ...
         camera: CameraConverter = ...
         image: ImageCache = ...
@@ -80,6 +83,7 @@ class Exporter:
             self.physics = PhysicsConverter(self)
             self.light = LightConverter(self)
             self.animation = AnimationConverter(self)
+            self.armature = ArmatureConverter(self)
             self.output = OutputFiles(self, self._op.filepath)
             self.camera = CameraConverter(self)
             self.image = ImageCache(self)
@@ -252,12 +256,20 @@ class Exporter:
 
     def _export_actor(self, so, bo):
         """Exports a Coordinate Interface if we need one"""
+        parent = bo.parent
+        parent_bone_name = bo.parent_bone
+        offset_matrix = None
+        if parent_bone_name:
+            # Object is parented to a bone, so use it instead.
+            parent_bone = parent.data.bones[parent_bone_name]
+            parent = bpy.context.scene.objects[ArmatureConverter.get_bone_name(parent, parent_bone)]
+            # ...Actually, it's parented to the bone's /tip/. So we need to offset the child...
+            offset_matrix = Matrix.Translation((0, 0, -parent_bone.length))
         if self.has_coordiface(bo):
-            self._export_coordinate_interface(so, bo)
+            self._export_coordinate_interface(so, bo, offset_matrix)
 
         # If this object has a parent, then we will need to go upstream and add ourselves to the
         # parent's CoordinateInterface... Because life just has to be backwards.
-        parent = bo.parent
         if parent is not None:
             if parent.plasma_object.enabled:
                 self.report.msg(f"Attaching to parent SceneObject '{parent.name}'")
@@ -268,18 +280,25 @@ class Exporter:
                                  The object may not appear in the correct location or animate properly.".format(
                                     bo.name, parent.name))
 
-    def _export_coordinate_interface(self, so, bl):
+    def _export_coordinate_interface(self, so, bl, matrix: Matrix = None):
         """Ensures that the SceneObject has a CoordinateInterface"""
         if so is None:
             so = self.mgr.find_create_object(plSceneObject, bl=bl)
+        ci = None
         if so.coord is None:
             ci_cls = bl.plasma_object.ci_type
             ci = self.mgr.add_object(ci_cls, bl=bl, so=so)
-
+        if ci is not None or matrix is not None:
+            # We just created the CI, or we have an extra transform that we may have previously skipped.
+            matrix_local = bl.matrix_local
+            matrix_world = bl.matrix_world
+            if matrix is not None:
+                matrix_local = matrix_local * matrix
+                matrix_world = matrix_world * matrix
             # Now we have the "fun" work of filling in the CI
-            ci.localToWorld = utils.matrix44(bl.matrix_world)
+            ci.localToWorld = utils.matrix44(matrix_world)
             ci.worldToLocal = ci.localToWorld.inverse()
-            ci.localToParent = utils.matrix44(bl.matrix_local)
+            ci.localToParent = utils.matrix44(matrix_local)
             ci.parentToLocal = ci.localToParent.inverse()
             return ci
         return so.coord.object
@@ -334,6 +353,9 @@ class Exporter:
                         mod.export(self, bl_obj, sceneobject)
             inc_progress()
 
+    def _export_armature_blobj(self, so, bo):
+        pass
+
     def _export_camera_blobj(self, so, bo):
         # Hey, guess what? Blender's camera data is utter crap!
         camera = bo.data.plasma_camera
@@ -403,7 +425,7 @@ class Exporter:
             inc_progress()
 
     def has_coordiface(self, bo):
-        if bo.type in {"CAMERA", "EMPTY", "LAMP"}:
+        if bo.type in {"CAMERA", "EMPTY", "LAMP", "ARMATURE"}:
             return True
         if bo.parent is not None or bo.children:
             return True
@@ -469,7 +491,7 @@ class Exporter:
             # Maybe this was an embedded context manager?
             if hasattr(temporary, "__enter__"):
                 ctx_temporary = self.exit_stack.enter_context(temporary)
-                if ctx_temporary is not None:
+                if ctx_temporary is not None and ctx_temporary != temporary:
                     return handle_temporary(ctx_temporary, parent)
             else:
                 raise RuntimeError("Temporary object of type '{}' generated by '{}' was unhandled".format(temporary.__class__, parent.name))
@@ -522,6 +544,13 @@ class Exporter:
             return temporary
 
         def do_pre_export(bo):
+            if bo.type == "ARMATURE":
+                so = self.mgr.find_create_object(plSceneObject, bl=bo)
+                self._export_actor(so, bo)
+                # Bake all armature bones to empties - this will make it easier to export animations and such.
+                for temporary_object in self.armature.convert_armature_to_empties(bo):
+                    # This could be a Blender object, an action, a context manager... I have no idea, handle_temporary will figure it out !
+                    handle_temporary(temporary_object, bo)
             for mod in bo.plasma_modifiers.modifiers:
                 proc = getattr(mod, "pre_export", None)
                 if proc is not None:
diff --git a/korman/helpers.py b/korman/helpers.py
index 35c0176..8092415 100644
--- a/korman/helpers.py
+++ b/korman/helpers.py
@@ -45,8 +45,10 @@ def copy_object(bl, name: Optional[str] = None):
 class GoodNeighbor:
     """Leave Things the Way You Found Them! (TM)"""
 
-    def __enter__(self):
+    def __init__(self):
         self._tracking = {}
+
+    def __enter__(self):
         return self
 
     def track(self, cls, attr, value):

From a6ddffed4e7c8d1941292d7eefa7ab54c7d47c91 Mon Sep 17 00:00:00 2001
From: Jrius <2261279+Jrius@users.noreply.github.com>
Date: Sat, 1 Feb 2025 22:00:09 +0100
Subject: [PATCH 3/6] Export skinned objects (WIP).

---
 korman/exporter/animation.py |  38 ++++---
 korman/exporter/armature.py  |  54 +++++++---
 korman/exporter/convert.py   |  46 +++++---
 korman/exporter/mesh.py      | 198 ++++++++++++++++++++++++++++++++++-
 korman/helpers.py            |   9 +-
 korman/render.py             |   1 +
 6 files changed, 296 insertions(+), 50 deletions(-)

diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py
index 84b6a93..d14e235 100644
--- a/korman/exporter/animation.py
+++ b/korman/exporter/animation.py
@@ -60,19 +60,7 @@ class AnimationConverter:
 
         try:
             for bone_name, bone in generated_bones.items():
-                child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children))
-                child.child_anim = bone
-                # Copy animation modifier and its properties if it exists..
-                # Cheating ? Very much yes. Do not try this at home, kids.
-                bone.plasma_modifiers["animation"] = arm_bo.plasma_modifiers["animation"]
-                bone.plasma_modifiers["animation_loop"] = arm_bo.plasma_modifiers["animation_loop"]
-
-                # Now copy animation data.
-                anim_data = bone.animation_data_create()
-                action = bpy.data.actions.new("{}_action".format(bone.name))
-                temporary_objects.append(action)
-                anim_data.action = action
-                self._exporter().report.warn(str([(i.data_path, i.array_index) for i in armature_action.fcurves]))
+                fcurves = []
                 for fcurve in armature_action.fcurves:
                     match = self._bone_data_path_regex.match(fcurve.data_path)
                     if not match:
@@ -80,6 +68,18 @@ class AnimationConverter:
                     name, data_path = match.groups()
                     if name != bone_name:
                         continue
+                    fcurves.append((fcurve, data_path))
+
+                if not fcurves:
+                    # No animation data for this bone.
+                    continue
+
+                # Copy animation data.
+                anim_data = bone.animation_data_create()
+                action = bpy.data.actions.new("{}_action".format(bone.name))
+                temporary_objects.append(action)
+                anim_data.action = action
+                for fcurve, data_path in fcurves:
                     new_curve = action.fcurves.new(data_path, fcurve.array_index)
                     for point in fcurve.keyframe_points:
                         # Thanks to bone_parent we can just copy the animation without a care in the world ! :P
@@ -87,6 +87,13 @@ class AnimationConverter:
                 for original_marker in armature_action.pose_markers:
                     marker = action.pose_markers.new(original_marker.name)
                     marker.frame = original_marker.frame
+
+                # Copy animation modifier and its properties if it exists.
+                # Cheating ? Very much yes. Do not try this at home, kids.
+                bone.plasma_modifiers["animation"] = arm_bo.plasma_modifiers["animation"]
+                bone.plasma_modifiers["animation_loop"] = arm_bo.plasma_modifiers["animation_loop"]
+                child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children))
+                child.child_anim = bone
         finally:
             if do_bake:
                 bpy.data.actions.remove(armature_action)
@@ -152,8 +159,9 @@ class AnimationConverter:
             # Do bake, but make sure we don't mess the user's data.
             old_action = bo.animation_data.action
             try:
-                frame_start = start if start is not None else bpy.context.scene.frame_start
-                frame_end = end if end is not None else bpy.context.scene.frame_end
+                keyframes = [keyframe.co[0] for curve in old_action.fcurves for keyframe in curve.keyframe_points]
+                frame_start = start if start is not None else min(keyframes)
+                frame_end = end if end is not None else max(keyframes)
                 bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, only_selected=False, visual_keying=True, bake_types={"POSE", "OBJECT"})
                 baked_anim = bo.animation_data.action
                 return baked_anim
diff --git a/korman/exporter/armature.py b/korman/exporter/armature.py
index 7063000..a2dcfb4 100644
--- a/korman/exporter/armature.py
+++ b/korman/exporter/armature.py
@@ -23,13 +23,15 @@ from . import utils
 class ArmatureConverter:
     def __init__(self, exporter):
         self._exporter = weakref.ref(exporter)
+        self._skinned_objects_modifiers = {}
+        self._bones_local_to_world = {}
 
     def convert_armature_to_empties(self, bo):
         # Creates Blender equivalents to each bone of the armature, adjusting a whole bunch of stuff along the way.
         # Yes, this is ugly, but required to get anims to export properly. I tried other ways to export armatures,
-        # but AFAICT sooner or later you have to implement similar hacks. Might as well create something that the
+        # but AFAICT sooner or later you have to implement similar hacks. Might as well generate something that the
         # animation exporter can already deal with, with no modification...
-        # Don't worry, we'll return a list of temporary objects to clean up after ourselves.
+        # Obviously the returned temporary_objects will be cleaned up afterwards.
         armature = bo.data
         pose = bo.pose if armature.pose_position == "POSE" else None
         generated_bones = {} # name: Blender empty.
@@ -45,11 +47,44 @@ class ArmatureConverter:
 
         return temporary_objects
 
+    def get_bone_local_to_world(self, bo):
+        return self._bones_local_to_world[bo]
+
+    def get_skin_modifiers(self, bo):
+        if self.is_skinned(bo):
+            return self._skinned_objects_modifiers[bo]
+        return []
+
+    def is_skinned(self, bo):
+        if bo.type != "MESH":
+            return False
+        if bo in self._skinned_objects_modifiers:
+            return True
+
+        # We need to cache the armature modifiers, because mesh.py will likely fiddle with them later.
+        armatures = []
+        for mod in bo.modifiers:
+            # Armature modifiers only result in exporting skinning if they are linked to an exported armature.
+            # If the armature is not exported, the deformation will simply get baked into the exported mesh.
+            if mod.type == "ARMATURE" and mod.object is not None and mod.object.plasma_object.enabled and mod.use_vertex_groups and mod.show_render:
+                armatures.append(mod)
+        if len(armatures):
+            self._skinned_objects_modifiers[bo] = armatures
+            return True
+        return False
+
     def _export_bone(self, bo, bone, parent, matrix, pose, generated_bones, temporary_objects):
         bone_empty = bpy.data.objects.new(ArmatureConverter.get_bone_name(bo, bone), None)
         bpy.context.scene.objects.link(bone_empty)
         bone_empty.plasma_object.enabled = True
 
+        if pose is not None:
+            pose_bone = pose.bones[bone.name]
+            bone_empty.rotation_mode = pose_bone.rotation_mode
+            pose_matrix = pose_bone.matrix_basis
+        else:
+            pose_matrix = Matrix.Identity(4)
+
         # Grmbl, animation is relative to rest pose in Blender, and relative to parent in Plasma...
         # Using matrix_parent_inverse or manually adjust keyframes will just mess up rotation keyframes,
         # so let's just insert an extra empty object to correct all that. This is why the CoordinateInterface caches computed matrices, after all...
@@ -59,19 +94,14 @@ class ArmatureConverter:
         bone_parent.parent = parent
         bone_empty.parent = bone_parent
         bone_empty.matrix_local = Matrix.Identity(4)
-
-        if pose is not None:
-            pose_bone = pose.bones[bone.name]
-            bone_empty.rotation_mode = pose_bone.rotation_mode
-            pose_matrix = pose_bone.matrix_basis
-        else:
-            pose_bone = None
-            pose_matrix = Matrix.Identity(4)
+        temporary_objects.append(bone_parent)
+        bone_parent.matrix_local = matrix * bone.matrix_local * pose_matrix
+        # The bone's local to world matrix may change when we copy animations over, which we don't want.
+        # Cache the matrix so we can use it when exporting meshes.
+        self._bones_local_to_world[bone_empty] = bo.matrix_world * bone.matrix_local
 
         temporary_objects.append(bone_empty)
-        temporary_objects.append(bone_parent)
         generated_bones[bone.name] = bone_empty
-        bone_parent.matrix_local = matrix * bone.matrix_local.to_4x4() * pose_matrix
 
         for child in bone.children:
             child_empty = self._export_bone(bo, child, bone_empty, bone.matrix_local.inverted(), pose, generated_bones, temporary_objects)
diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py
index f2cc4a8..98f6f73 100644
--- a/korman/exporter/convert.py
+++ b/korman/exporter/convert.py
@@ -257,28 +257,33 @@ class Exporter:
     def _export_actor(self, so, bo):
         """Exports a Coordinate Interface if we need one"""
         parent = bo.parent
-        parent_bone_name = bo.parent_bone
+        parent_bone_name = bo.parent_bone if parent is not None else None
         offset_matrix = None
-        if parent_bone_name:
+        if parent_bone_name and parent.plasma_object.enabled:
             # Object is parented to a bone, so use it instead.
             parent_bone = parent.data.bones[parent_bone_name]
             parent = bpy.context.scene.objects[ArmatureConverter.get_bone_name(parent, parent_bone)]
             # ...Actually, it's parented to the bone's /tip/. So we need to offset the child...
-            offset_matrix = Matrix.Translation((0, 0, -parent_bone.length))
+            offset_matrix = Matrix.Translation(bo.matrix_local.row[1].xyz * parent_bone.length)
         if self.has_coordiface(bo):
             self._export_coordinate_interface(so, bo, offset_matrix)
 
-        # If this object has a parent, then we will need to go upstream and add ourselves to the
-        # parent's CoordinateInterface... Because life just has to be backwards.
-        if parent is not None:
-            if parent.plasma_object.enabled:
-                self.report.msg(f"Attaching to parent SceneObject '{parent.name}'")
-                parent_ci = self._export_coordinate_interface(None, parent)
-                parent_ci.addChild(so.key)
-            else:
-                self.report.warn("You have parented Plasma Object '{}' to '{}', which has not been marked for export. \
-                                 The object may not appear in the correct location or animate properly.".format(
-                                    bo.name, parent.name))
+            # If this object has a parent, then we will need to go upstream and add ourselves to the
+            # parent's CoordinateInterface... Because life just has to be backwards.
+            if parent is not None:
+                if parent.plasma_object.enabled:
+                    if not self.armature.is_skinned(parent):
+                        self.report.msg(f"Attaching to parent SceneObject '{parent.name}'")
+                        parent_ci = self._export_coordinate_interface(None, parent)
+                        parent_ci.addChild(so.key)
+                    else:
+                        self.report.warn("You have parented Plasma Object '{}' to '{}', which is deformed by an armature. This is not supported. \
+                                        The object may not appear in the correct location or animate properly.".format(
+                                            bo.name, parent.name))
+                else:
+                    self.report.warn("You have parented Plasma Object '{}' to '{}', which has not been marked for export. \
+                                    The object may not appear in the correct location or animate properly.".format(
+                                        bo.name, parent.name))
 
     def _export_coordinate_interface(self, so, bl, matrix: Matrix = None):
         """Ensures that the SceneObject has a CoordinateInterface"""
@@ -425,6 +430,13 @@ class Exporter:
             inc_progress()
 
     def has_coordiface(self, bo):
+        if self.armature.is_skinned(bo):
+            # We forbid skinned objects from having a coordinate interface. See mesh.py for details.
+            for mod in bo.plasma_modifiers.modifiers:
+                if mod.enabled and mod.requires_actor:
+                    raise RuntimeError("Object '{}' is skinned, which is incompatible with modifier '{}'.".format(bo, mod))
+            return False
+
         if bo.type in {"CAMERA", "EMPTY", "LAMP", "ARMATURE"}:
             return True
         if bo.parent is not None or bo.children:
@@ -435,9 +447,8 @@ class Exporter:
             return True
 
         for mod in bo.plasma_modifiers.modifiers:
-            if mod.enabled:
-                if mod.requires_actor:
-                    return True
+            if mod.enabled and mod.requires_actor:
+                return True
         return False
 
     def _post_process_scene_objects(self):
@@ -490,6 +501,7 @@ class Exporter:
         def handle_temporary(temporary, parent):
             # Maybe this was an embedded context manager?
             if hasattr(temporary, "__enter__"):
+                log_msg(f"'{parent.name}': generated context manager '{temporary}' ")
                 ctx_temporary = self.exit_stack.enter_context(temporary)
                 if ctx_temporary is not None and ctx_temporary != temporary:
                     return handle_temporary(ctx_temporary, parent)
diff --git a/korman/exporter/mesh.py b/korman/exporter/mesh.py
index 9812352..d30b42f 100644
--- a/korman/exporter/mesh.py
+++ b/korman/exporter/mesh.py
@@ -21,6 +21,7 @@ from math import fabs
 from typing import Iterable
 import weakref
 
+from .armature import ArmatureConverter
 from ..exporter.logger import ExportProgressLogger
 from . import explosions
 from .. import helpers
@@ -157,6 +158,8 @@ class _GeoData:
         self.blender2gs = [{} for i in range(numVtxs)]
         self.triangles = []
         self.vertices = []
+        self.max_deform_bones = 0
+        self.total_weight_by_bones = {}
 
 
 class _MeshManager:
@@ -166,6 +169,8 @@ class _MeshManager:
             self._report = report
         self._entered = False
         self._overrides = {}
+        self._objects_armatures = {}
+        self._geospans_armatures = {}
 
     @staticmethod
     def add_progress_presteps(report):
@@ -200,15 +205,30 @@ class _MeshManager:
                 # Remember, storing actual pointers to the Blender objects can cause bad things to
                 # happen because Blender's memory management SUCKS!
                 self._overrides[i.name] = { "mesh": i.data.name, "modifiers": [] }
-                i.data = i.to_mesh(scene, True, "RENDER", calc_tessface=False)
 
                 # If the modifiers are left on the object, the lightmap bake can break under some
-                # situations. Therefore, we now cache the modifiers and clear them away...
+                # situations. Therefore, we now cache the modifiers and will clear them away...
                 if i.plasma_object.enabled:
                     cache_mods = self._overrides[i.name]["modifiers"]
+
                     for mod in i.modifiers:
-                        cache_mods.append(self._build_prop_dict(mod))
+                        mod_prop_dict = self._build_prop_dict(mod)
+                        cache_mods.append(mod_prop_dict)
+
+                    armatures = []
+                    for armature_mod in self._exporter().armature.get_skin_modifiers(i):
+                        # We'll use armatures to export bones later. Disable it so it doesn't get baked into the mesh.
+                        armatures.append(armature_mod.object)
+                        # Note that this gets reverted when we reapply cached modifiers.
+                        armature_mod.show_render = False
+                    if armatures:
+                        self._objects_armatures[i.name] = armatures
+
+                i.data = i.to_mesh(scene, True, "RENDER", calc_tessface=False)
+
+                if i.plasma_object.enabled:
                     i.modifiers.clear()
+
             self._report.progress_increment()
         return self
 
@@ -365,6 +385,9 @@ class MeshConverter(_MeshManager):
                 for dspan in loc.values():
                     log_msg("[DrawableSpans '{}']", dspan.key.name)
 
+                    # We do one last pass to register bones.
+                    self._register_bones_before_merge(dspan)
+
                     # This mega-function does a lot:
                     # 1. Converts SourceSpans (geospans) to Icicles and bakes geometry into plGBuffers
                     # 2. Calculates the Icicle bounds
@@ -373,6 +396,87 @@ class MeshConverter(_MeshManager):
                     dspan.composeGeometry(True, True)
                 inc_progress()
 
+    def _register_bones_before_merge(self, dspan):
+        # We export all armatures used by this DSpan only once, unless an object uses multiple armatures - in which case, we treat the list of armatures as a single armature:
+        # [Armature 0: bone A, bone B, bone C...]
+        # [Armature 1: bone D, bone E, bone F...]
+        # [Armature 0, armature 1: bone A, bone B, bone C... bone D, bone E, bone F...]
+        # But really, the latter case should NEVER happen because users never use more than one armature modifier, cm'on.
+        # NOTE: we will export all bones, even those that are not used by any vertices in the DSpan. Would be too complex otherwise.
+        # NOTE: DSpan bone transforms are shared between all drawables in this dspan. This implies all skinned meshes must share the same
+        # coordinate system - and that's easier if these objects simply don't have a coordinate interface.
+        # Hence, we forbid skinned objects from having a coordint.
+        # The alternative is exporting each bone once per drawable with different l2w/w2l/l2b/b2l, but this is more complex
+        # and there is no good reason to do so - this just increases the risk of deformations going crazy due to a misaligned object.
+
+        armature_list_to_base_matrix = {}
+
+        def find_create_armature(armature_list):
+            nonlocal armature_list_to_base_matrix
+            existing_base_matrix = armature_list_to_base_matrix.get(armature_list)
+            if existing_base_matrix is not None:
+                # Armature already exported. Return the base bone ID.
+                return existing_base_matrix
+
+            # Not already exported. Do so now.
+            # Will be used to offset the DI/icicle's baseMatrix.
+            base_matrix_id = dspan.numTransforms
+            armature_list_to_base_matrix[armature_list] = base_matrix_id
+            for armature in armature_list:
+                # Create the null bone. We have 1 per matrix
+                identity = hsMatrix44.Identity()
+                dspan.addTransform(identity, identity, identity, identity)
+                # Now create the transforms for all bones, and make sure they are referenced by a draw interface.
+                for bone in armature.data.bones:
+                    find_create_bone(armature, bone)
+
+            return base_matrix_id
+
+        def find_create_bone(armature_bo, bone_bo):
+            bone_so_name = ArmatureConverter.get_bone_name(armature_bo, bone_bo)
+            bone_empty_bo = bpy.context.scene.objects[bone_so_name]
+            bone_empty_so = self._mgr.find_object(plSceneObject, bl=bone_empty_bo)
+
+            # Add the bone's transform.
+            identity = hsMatrix44.Identity()
+            localToWorld = utils.matrix44(self._exporter().armature.get_bone_local_to_world(bone_empty_bo))
+            transform_index = dspan.addTransform( \
+                # local2world, world2local: always identity it seems.
+                identity, identity, \
+                # local2bone, bone2local
+                localToWorld.inverse(), localToWorld)
+
+            # Add a draw interface to the object itself (if not already done).
+            di = self._mgr.find_create_object(plDrawInterface, bl=bone_empty_bo, so=bone_empty_so)
+            bone_empty_so.draw = di.key
+
+            # If the DI already has a reference to the DSpan, add the transform to the dspan's DIIndex.
+            # If not, create the DIIndex and the transform, and add it to the DI.
+            found = False
+            for key, id in di.drawables:
+                if dspan.key == key:
+                    # Already exported because the user has an object with two armatures.
+                    # Just readd the transform...
+                    dii = dspan.DIIndices[id]
+                    dii.indices = (*dii.indices, transform_index)
+            if not found:
+                dii = plDISpanIndex()
+                dii.flags = plDISpanIndex.kMatrixOnly
+                dii.indices = (transform_index,)
+                di_index = dspan.addDIIndex(dii)
+                di.addDrawable(dspan.key, di_index)
+
+        for i in range(len(dspan.sourceSpans)):
+            geospan = dspan.sourceSpans[i]
+            # Let's get any armature data.
+            armature_info = self._geospans_armatures.get((dspan, i))
+            if armature_info is None:
+                continue
+            armatures = armature_info[0]
+            # Export the armature (if not already done), and retrieve the BaseMatrix.
+            geospan.baseMatrix = find_create_armature(tuple(armatures))
+            geospan.numMatrices = sum(len(arm.data.bones) for arm in armatures) + 1
+
     def _export_geometry(self, bo, mesh, materials, geospans, mat2span_LUT):
         self._report.msg(f"Converting geometry from '{mesh.name}'...")
 
@@ -387,6 +491,26 @@ class MeshConverter(_MeshManager):
         color = self._find_vtx_color_layer(mesh.tessface_vertex_colors, autocolor=not lm.bake_lightmap, manual=True)
         alpha = self._find_vtx_alpha_layer(mesh.tessface_vertex_colors)
 
+        # And retrieve the vertex groups that are deformed by an armature.
+        armatures = self._objects_armatures.get(bo.name)
+        export_deform = armatures is not None
+        if export_deform:
+            # We will need to remap IDs of each bone per armature usage. This is annoying, especially since we support multiple armatures...
+            i = 1
+            all_bone_names = {}
+            for armature in armatures:
+                for bone in armature.data.bones:
+                    all_bone_names[bone.name] = i
+                    i += 1
+            # This will map the group ID (used by Blender vertices) to the bone index exported to Plasma.
+            # Theoretically supports multiple armatures, except if the two armatures have the same bone names (because that would be REALLY asking for a lot here).
+            # If the bone is not found, we'll just map to the null bone.
+            group_id_to_bone_id = [all_bone_names.get(group.name, 0) for group in bo.vertex_groups]
+            # We will also need to know which bones deform the most vertices per material, for max/pen bones.
+            for gd in geodata.values():
+                gd.total_weight_by_bones = { j: 0.0 for j in range(i) }
+            warned_extra_bone_weights = False
+
         # Convert Blender faces into things we can stuff into libHSPlasma
         for i, tessface in enumerate(mesh.tessfaces):
             data = geodata.get(tessface.material_index)
@@ -484,6 +608,37 @@ class MeshConverter(_MeshManager):
                         uvs.append(dPosDv)
                     geoVertex.uvs = uvs
 
+                    if export_deform:
+                        # Get bone ID and weight from the vertex' "group" data.
+                        # While we're at it, sort it by weight, and filter groups that
+                        # have no bone assigned. Take only the first 3 bones.
+                        weights = sorted([ \
+                                (group_id_to_bone_id[group.group], group.weight) \
+                                for group in source.groups \
+                                if group.weight > 0 \
+                            ], key=lambda t: t[1], reverse=True)
+                        if len(weights) > 3 and not warned_extra_bone_weights:
+                            warned_extra_bone_weights = True
+                            self._report.warn(f"'{bo.name}': only three bones can deform a vertex at a time. Please use Weight Tools -> Limit Total at 3 to ensure deformation is consistent between Blender and Plasma.")
+                        weights = weights[:3]
+                        total_weight = sum((w[1] for w in weights))
+                        # NOTE: Blender will ALWAYS normalize bone weights when deforming !
+                        # This means if weights don't add up to 1, we CANNOT assign the remaining weight to the null bone.
+                        # For instance, a vertex with a single bone of weight 0.25 will move as if it were weighted 1.0.
+                        # However, a vertex with no bone at all will not move at all (null bone).
+                        weights = [(id, weight / total_weight) for id, weight in weights]
+                        # Count how many bones deform this vertex, so we know how many skin indices to enable.
+                        num_bones = len(weights)
+                        data.max_deform_bones = max(data.max_deform_bones, num_bones)
+                        # Keep track of how much weight each bone is handling
+                        for id, weight in weights:
+                            data.total_weight_by_bones[id] += weight
+                        # Pad to 3 weights to make it simpler.
+                        weights += [(0, 0.0)] * (3 - len(weights))
+                        # And store all this into the vertex.
+                        geoVertex.indices = weights[0][0] | (weights[1][0] << 8) | (weights[2][0] << 16)
+                        geoVertex.weights = tuple((weight for id, weight in weights))
+
                     idx = len(data.vertices)
                     data.blender2gs[vertex][normcoluv] = idx
                     data.vertices.append(geoVertex)
@@ -535,6 +690,39 @@ class MeshConverter(_MeshManager):
                     uvMap[numUVs - 1].normalize()
                     vtx.uvs = uvMap
 
+            if export_deform:
+                # MaxBoneIdx and PenBoneIdx: these are the indices of the two highest-weighted bones on the mesh.
+                # Plasma will use those to compute a bounding box for the deformed object at run-time,
+                # in order to clip them when outside the view frustum.
+                # (The new BB is computed by extending the base BB with two versions of itself transformed by the max/pen bones).
+                # See plDrawableSpans::IUpdateMatrixPaletteBoundsHack.
+                # This is... about as reliable as you can expect: it kinda works, it's not great. This will have to do for now.
+                # (If you ask me, I'd rip the entire thing out and just not clip anything, framerate be damned.)
+                # Note that max/pen bones are determined by how many vertices they deform, which is a bit different (and more efficient)
+                # than whatever the Max plugin does in plMAXVertexAccumulator::StuffMyData.
+                sorted_ids_by_weight = sorted(((weight, id) for id, weight in data.total_weight_by_bones.items()), reverse = True)
+                # We should be guaranteed to have at least two bones - there are no armatures with no bones (...right?),
+                # and there is always the null bone if we really have nothing else.
+                geospan.maxBoneIdx = sorted_ids_by_weight[0][1]
+                geospan.penBoneIdx = sorted_ids_by_weight[1][1]
+
+                # This is also a good time to specify how many bones per vertices we allow, for optimization purposes.
+                max_deform_bones = data.max_deform_bones
+                if max_deform_bones == 3:
+                    geospan.format |= plGeometrySpan.kSkin3Weights | plGeometrySpan.kSkinIndices
+                elif max_deform_bones == 2:
+                    geospan.format |= plGeometrySpan.kSkin2Weights | plGeometrySpan.kSkinIndices
+                else: # max_bones_per_vert == 1
+                    geospan.format |= plGeometrySpan.kSkin1Weight
+                    if len(group_id_to_bone_id) > 1:
+                        geospan.format |= plGeometrySpan.kSkinIndices
+                    else:
+                        # No skin indices required... BUT! We have assigned some weight to the null bone on top of the only bone, so we need to fix that.
+                        for vtx in data.vertices:
+                            weight = vtx.weights[0]
+                            weight = 1 - weight
+                            vtx.weights = (weight, 0.0, 0.0)
+
             # If we're still here, let's add our data to the GeometrySpan
             geospan.indices = data.triangles
             geospan.vertices = data.vertices
@@ -636,9 +824,13 @@ class MeshConverter(_MeshManager):
             dspan = self._find_create_dspan(bo, i.geospan, i.pass_index)
             self._report.msg("Exported hsGMaterial '{}' geometry into '{}'",
                              i.geospan.material.name, dspan.key.name)
+            armatures = self._objects_armatures.get(bo.name)
             idx = dspan.addSourceSpan(i.geospan)
             diidx = _diindices.setdefault(dspan, [])
             diidx.append(idx)
+            if armatures is not None:
+                bone_id_to_name = {group.index: group.name for group in bo.vertex_groups}
+                self._geospans_armatures[(dspan, idx)] = (armatures, bone_id_to_name)
 
         # Step 3.1: Harvest Span indices and create the DIIndices
         drawables = []
diff --git a/korman/helpers.py b/korman/helpers.py
index 8092415..fc34bb4 100644
--- a/korman/helpers.py
+++ b/korman/helpers.py
@@ -18,6 +18,7 @@ import bpy
 from contextlib import contextmanager
 import math
 from typing import *
+from uuid import uuid4
 
 @contextmanager
 def bmesh_from_object(bl):
@@ -64,12 +65,14 @@ class GoodNeighbor:
 @contextmanager
 def TemporaryCollectionItem(collection):
     item = collection.add()
+    # Blender may recreate the `item` instance as the collection grows and shrink...
+    # Assign it a unique name so we know which item to delete later on.
+    name = item.name = str(uuid4())
     try:
         yield item
     finally:
-        index = next((i for i, j in enumerate(collection) if j == item), None)
-        if index is not None:
-            collection.remove(index)
+        index = collection.find(name)
+        collection.remove(index)
 
 class TemporaryObject:
     def __init__(self, obj, remove_func):
diff --git a/korman/render.py b/korman/render.py
index 9677754..fa238f6 100644
--- a/korman/render.py
+++ b/korman/render.py
@@ -40,6 +40,7 @@ from bl_ui import properties_data_mesh
 properties_data_mesh.DATA_PT_normals.COMPAT_ENGINES.add("PLASMA_GAME")
 properties_data_mesh.DATA_PT_uv_texture.COMPAT_ENGINES.add("PLASMA_GAME")
 properties_data_mesh.DATA_PT_vertex_colors.COMPAT_ENGINES.add("PLASMA_GAME")
+properties_data_mesh.DATA_PT_vertex_groups.COMPAT_ENGINES.add("PLASMA_GAME")
 del properties_data_mesh
 
 def _whitelist_all(mod):

From a6c7c7ba992a0706f073fd65937f660092f1be75 Mon Sep 17 00:00:00 2001
From: Jrius <2261279+Jrius@users.noreply.github.com>
Date: Sat, 8 Feb 2025 16:00:27 +0100
Subject: [PATCH 4/6] Improve handling of temporary objects, fix various issues

---
 korman/exporter/animation.py | 181 ++++++++++++++++-------------------
 korman/exporter/armature.py  |  47 ++++-----
 korman/exporter/convert.py   |   4 +-
 3 files changed, 108 insertions(+), 124 deletions(-)

diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py
index d14e235..44f7966 100644
--- a/korman/exporter/animation.py
+++ b/korman/exporter/animation.py
@@ -39,11 +39,50 @@ class AnimationConverter:
     def convert_frame_time(self, frame_num: int) -> float:
         return frame_num / self._bl_fps
 
-    def copy_armature_animation_to_temporary_bones(self, arm_bo: bpy.types.Object, generated_bones):
+    def convert_object_animations(self, bo: bpy.types.Object, so: plSceneObject, anim_name: str, *,
+                                  start: Optional[int] = None, end: Optional[int] = None, bake_frame_step: Optional[int] = None) -> Iterable[plAGApplicator]:
+        if not bo.plasma_object.has_animation_data:
+            return []
+
+        def fetch_animation_data(id_data):
+            if id_data is not None:
+                if id_data.animation_data is not None:
+                    action = id_data.animation_data.action
+                    return action, getattr(action, "fcurves", [])
+            return None, []
+
+        obj_action, obj_fcurves = fetch_animation_data(bo)
+        data_action, data_fcurves = fetch_animation_data(bo.data)
+
+        if bake_frame_step is not None and obj_action is not None:
+            obj_action = self._bake_animation_data(bo, bake_frame_step, start, end)
+            obj_fcurves = obj_action.fcurves
+
+        # We're basically just going to throw all the FCurves at the controller converter (read: wall)
+        # and see what sticks. PlasmaMAX has some nice animation channel stuff that allows for some
+        # form of separation, but Blender's NLA editor is way confusing and appears to not work with
+        # things that aren't the typical position, rotation, scale animations.
+        applicators = []
+        if isinstance(bo.data, bpy.types.Camera):
+            applicators.append(self._convert_camera_animation(bo, so, obj_fcurves, data_fcurves, anim_name, start, end))
+        else:
+            applicators.append(self._convert_transform_animation(bo, obj_fcurves, bo.matrix_local, bo.matrix_parent_inverse, start=start, end=end))
+        if bo.plasma_modifiers.soundemit.enabled:
+            applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit, start, end))
+        if isinstance(bo.data, bpy.types.Lamp):
+            lamp = bo.data
+            applicators.extend(self._convert_lamp_color_animation(bo.name, data_fcurves, lamp, start, end))
+            if isinstance(lamp, bpy.types.SpotLamp):
+                applicators.extend(self._convert_spot_lamp_animation(bo.name, data_fcurves, lamp, start, end))
+            if isinstance(lamp, bpy.types.PointLamp):
+                applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end))
+
+        return [i for i in applicators if i is not None]
+
+    def copy_armature_animation_to_temporary_bones(self, arm_bo: bpy.types.Object, generated_bones, handle_temporary):
         # Enable the anim group. We will need it to reroute anim messages to children bones.
         # Please note: Plasma supports grouping many animation channels into a single ATC anim, but only programmatically.
         # So instead we will do it the Cyan Way(tm), which is to make dozens or maybe even hundreds of small ATC anims. Derp.
-        temporary_objects = []
         anim = arm_bo.plasma_modifiers.animation
         anim_group = arm_bo.plasma_modifiers.animation_group
         do_bake = anim.bake
@@ -51,101 +90,48 @@ class AnimationConverter:
         toggle = GoodNeighbor()
         toggle.track(anim, "bake", False)
         toggle.track(anim_group, "enabled", True)
-        temporary_objects.append(toggle)
-        temporary_objects.append(exit_stack)
+        handle_temporary(toggle)
+        handle_temporary(exit_stack)
 
         armature_action = arm_bo.animation_data.action
         if do_bake:
             armature_action = self._bake_animation_data(arm_bo, anim.bake_frame_step, None, None)
 
-        try:
-            for bone_name, bone in generated_bones.items():
-                fcurves = []
-                for fcurve in armature_action.fcurves:
-                    match = self._bone_data_path_regex.match(fcurve.data_path)
-                    if not match:
-                        continue
-                    name, data_path = match.groups()
-                    if name != bone_name:
-                        continue
-                    fcurves.append((fcurve, data_path))
-
-                if not fcurves:
-                    # No animation data for this bone.
+        for bone_name, bone in generated_bones.items():
+            fcurves = []
+            for fcurve in armature_action.fcurves:
+                match = self._bone_data_path_regex.match(fcurve.data_path)
+                if not match:
                     continue
+                name, data_path = match.groups()
+                if name != bone_name:
+                    continue
+                fcurves.append((fcurve, data_path))
 
-                # Copy animation data.
-                anim_data = bone.animation_data_create()
-                action = bpy.data.actions.new("{}_action".format(bone.name))
-                temporary_objects.append(action)
-                anim_data.action = action
-                for fcurve, data_path in fcurves:
-                    new_curve = action.fcurves.new(data_path, fcurve.array_index)
-                    for point in fcurve.keyframe_points:
-                        # Thanks to bone_parent we can just copy the animation without a care in the world ! :P
-                        p = new_curve.keyframe_points.insert(point.co[0], point.co[1])
-                for original_marker in armature_action.pose_markers:
-                    marker = action.pose_markers.new(original_marker.name)
-                    marker.frame = original_marker.frame
-
-                # Copy animation modifier and its properties if it exists.
-                # Cheating ? Very much yes. Do not try this at home, kids.
-                bone.plasma_modifiers["animation"] = arm_bo.plasma_modifiers["animation"]
-                bone.plasma_modifiers["animation_loop"] = arm_bo.plasma_modifiers["animation_loop"]
-                child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children))
-                child.child_anim = bone
-        finally:
-            if do_bake:
-                bpy.data.actions.remove(armature_action)
-
-        return temporary_objects
-
-    def convert_object_animations(self, bo: bpy.types.Object, so: plSceneObject, anim_name: str, *,
-                                  start: Optional[int] = None, end: Optional[int] = None, bake_frame_step: Optional[int] = None) -> Iterable[plAGApplicator]:
-        if not bo.plasma_object.has_animation_data:
-            return []
-
-        def fetch_animation_data(id_data):
-            if id_data is not None:
-                if id_data.animation_data is not None:
-                    action = id_data.animation_data.action
-                    return action, getattr(action, "fcurves", [])
-            return None, []
-
-        obj_action, obj_fcurves = fetch_animation_data(bo)
-        data_action, data_fcurves = fetch_animation_data(bo.data)
-        temporary_action = None
-
-        try:
-            if bake_frame_step is not None and obj_action is not None:
-                temporary_action = obj_action = self._bake_animation_data(bo, bake_frame_step, start, end)
-                obj_fcurves = obj_action.fcurves
-
-            # We're basically just going to throw all the FCurves at the controller converter (read: wall)
-            # and see what sticks. PlasmaMAX has some nice animation channel stuff that allows for some
-            # form of separation, but Blender's NLA editor is way confusing and appears to not work with
-            # things that aren't the typical position, rotation, scale animations.
-            applicators = []
-            if isinstance(bo.data, bpy.types.Camera):
-                applicators.append(self._convert_camera_animation(bo, so, obj_fcurves, data_fcurves, anim_name, start, end))
-            else:
-                applicators.append(self._convert_transform_animation(bo, obj_fcurves, bo.matrix_local, bo.matrix_parent_inverse, start=start, end=end))
-            if bo.plasma_modifiers.soundemit.enabled:
-                applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit, start, end))
-            if isinstance(bo.data, bpy.types.Lamp):
-                lamp = bo.data
-                applicators.extend(self._convert_lamp_color_animation(bo.name, data_fcurves, lamp, start, end))
-                if isinstance(lamp, bpy.types.SpotLamp):
-                    applicators.extend(self._convert_spot_lamp_animation(bo.name, data_fcurves, lamp, start, end))
-                if isinstance(lamp, bpy.types.PointLamp):
-                    applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end))
-
-        finally:
-            if temporary_action is not None:
-                # Baking data is temporary, but the lifetime of our user's data is eternal !
-                bpy.data.actions.remove(temporary_action)
+            if not fcurves:
+                # No animation data for this bone.
+                continue
 
-        return [i for i in applicators if i is not None]
+            # Copy animation data.
+            anim_data = bone.animation_data_create()
+            action = bpy.data.actions.new("{}_action".format(bone.name))
+            handle_temporary(action)
+            anim_data.action = action
+            for fcurve, data_path in fcurves:
+                new_curve = action.fcurves.new(data_path, fcurve.array_index)
+                for point in fcurve.keyframe_points:
+                    # Thanks to bone_parent we can just copy the animation without a care in the world ! :P
+                    p = new_curve.keyframe_points.insert(point.co[0], point.co[1])
+            for original_marker in armature_action.pose_markers:
+                marker = action.pose_markers.new(original_marker.name)
+                marker.frame = original_marker.frame
+
+            # Copy animation modifier and its properties if it exists.
+            # Cheating ? Very much yes. Do not try this at home, kids.
+            bone.plasma_modifiers["animation"] = arm_bo.plasma_modifiers["animation"]
+            bone.plasma_modifiers["animation_loop"] = arm_bo.plasma_modifiers["animation_loop"]
+            child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children))
+            child.child_anim = bone
 
     def _bake_animation_data(self, bo, bake_frame_step: int, start: Optional[int] = None, end: Optional[int] = None):
         # Baking animations is a Blender operator, so requires a bit of boilerplate...
@@ -158,15 +144,14 @@ class AnimationConverter:
 
             # Do bake, but make sure we don't mess the user's data.
             old_action = bo.animation_data.action
-            try:
-                keyframes = [keyframe.co[0] for curve in old_action.fcurves for keyframe in curve.keyframe_points]
-                frame_start = start if start is not None else min(keyframes)
-                frame_end = end if end is not None else max(keyframes)
-                bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, only_selected=False, visual_keying=True, bake_types={"POSE", "OBJECT"})
-                baked_anim = bo.animation_data.action
-                return baked_anim
-            finally:
-                bo.animation_data.action = old_action
+            toggle.track(bo.animation_data, "action", old_action)
+            keyframes = [keyframe.co[0] for curve in old_action.fcurves for keyframe in curve.keyframe_points]
+            frame_start = start if start is not None else min(keyframes)
+            frame_end = end if end is not None else max(keyframes)
+            bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, only_selected=False, visual_keying=True, bake_types={"POSE", "OBJECT"})
+            baked_anim = bo.animation_data.action
+            self._exporter().exit_stack.enter_context(TemporaryObject(baked_anim, bpy.data.actions.remove))
+            return baked_anim
 
     def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves, anim_name: str,
                                   start: Optional[int], end: Optional[int]):
diff --git a/korman/exporter/armature.py b/korman/exporter/armature.py
index a2dcfb4..d65aa15 100644
--- a/korman/exporter/armature.py
+++ b/korman/exporter/armature.py
@@ -26,26 +26,29 @@ class ArmatureConverter:
         self._skinned_objects_modifiers = {}
         self._bones_local_to_world = {}
 
-    def convert_armature_to_empties(self, bo):
+    def convert_armature_to_empties(self, bo, handle_temporary):
         # Creates Blender equivalents to each bone of the armature, adjusting a whole bunch of stuff along the way.
         # Yes, this is ugly, but required to get anims to export properly. I tried other ways to export armatures,
         # but AFAICT sooner or later you have to implement similar hacks. Might as well generate something that the
         # animation exporter can already deal with, with no modification...
-        # Obviously the returned temporary_objects will be cleaned up afterwards.
+        # Obviously the created objects will be cleaned up afterwards.
         armature = bo.data
-        pose = bo.pose if armature.pose_position == "POSE" else None
         generated_bones = {} # name: Blender empty.
-        temporary_objects = []
-        for bone in armature.bones:
-            if bone.parent:
-                continue
-            self._export_bone(bo, bone, bo, Matrix.Identity(4), pose, generated_bones, temporary_objects)
-
-        if bo.plasma_modifiers.animation.enabled and bo.animation_data is not None and bo.animation_data.action is not None:
-            # Let the anim exporter handle the anim crap.
-            temporary_objects.extend(self._exporter().animation.copy_armature_animation_to_temporary_bones(bo, generated_bones))
-
-        return temporary_objects
+        temporary_bones = []
+        # Note: ideally we would give the temporary bone objects to handle_temporary as soon as they are created.
+        # However we need to delay until we actually create their animation modifiers, so that these get exported.
+        try:
+            for bone in armature.bones:
+                if bone.parent:
+                    continue
+                self._export_bone(bo, bone, bo, Matrix.Identity(4), bo.pose, armature.pose_position == "POSE", generated_bones, temporary_bones)
+
+            if bo.plasma_modifiers.animation.enabled and bo.animation_data is not None and bo.animation_data.action is not None:
+                # Let the anim exporter handle the anim crap.
+                self._exporter().animation.copy_armature_animation_to_temporary_bones(bo, generated_bones, handle_temporary)
+        finally:
+            for bone in temporary_bones:
+                handle_temporary(bone)
 
     def get_bone_local_to_world(self, bo):
         return self._bones_local_to_world[bo]
@@ -73,14 +76,14 @@ class ArmatureConverter:
             return True
         return False
 
-    def _export_bone(self, bo, bone, parent, matrix, pose, generated_bones, temporary_objects):
+    def _export_bone(self, bo, bone, parent, matrix, pose, pose_mode, generated_bones, temporary_bones):
         bone_empty = bpy.data.objects.new(ArmatureConverter.get_bone_name(bo, bone), None)
         bpy.context.scene.objects.link(bone_empty)
         bone_empty.plasma_object.enabled = True
+        pose_bone = pose.bones[bone.name]
+        bone_empty.rotation_mode = pose_bone.rotation_mode
 
-        if pose is not None:
-            pose_bone = pose.bones[bone.name]
-            bone_empty.rotation_mode = pose_bone.rotation_mode
+        if pose_mode:
             pose_matrix = pose_bone.matrix_basis
         else:
             pose_matrix = Matrix.Identity(4)
@@ -94,18 +97,16 @@ class ArmatureConverter:
         bone_parent.parent = parent
         bone_empty.parent = bone_parent
         bone_empty.matrix_local = Matrix.Identity(4)
-        temporary_objects.append(bone_parent)
+        temporary_bones.append(bone_parent)
         bone_parent.matrix_local = matrix * bone.matrix_local * pose_matrix
         # The bone's local to world matrix may change when we copy animations over, which we don't want.
         # Cache the matrix so we can use it when exporting meshes.
         self._bones_local_to_world[bone_empty] = bo.matrix_world * bone.matrix_local
-
-        temporary_objects.append(bone_empty)
+        temporary_bones.append(bone_empty)
         generated_bones[bone.name] = bone_empty
 
         for child in bone.children:
-            child_empty = self._export_bone(bo, child, bone_empty, bone.matrix_local.inverted(), pose, generated_bones, temporary_objects)
-        return bone_empty
+            self._export_bone(bo, child, bone_empty, bone.matrix_local.inverted(), pose, pose_mode, generated_bones, temporary_bones)
 
     @staticmethod
     def get_bone_name(bo, bone):
diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py
index 98f6f73..73684e0 100644
--- a/korman/exporter/convert.py
+++ b/korman/exporter/convert.py
@@ -560,9 +560,7 @@ class Exporter:
                 so = self.mgr.find_create_object(plSceneObject, bl=bo)
                 self._export_actor(so, bo)
                 # Bake all armature bones to empties - this will make it easier to export animations and such.
-                for temporary_object in self.armature.convert_armature_to_empties(bo):
-                    # This could be a Blender object, an action, a context manager... I have no idea, handle_temporary will figure it out !
-                    handle_temporary(temporary_object, bo)
+                self.armature.convert_armature_to_empties(bo, lambda obj: handle_temporary(obj, bo))
             for mod in bo.plasma_modifiers.modifiers:
                 proc = getattr(mod, "pre_export", None)
                 if proc is not None:

From 8c74677dd6349b5ad8ee5fa2a0219494ded4dfa1 Mon Sep 17 00:00:00 2001
From: Jrius <2261279+Jrius@users.noreply.github.com>
Date: Sat, 8 Feb 2025 16:24:26 +0100
Subject: [PATCH 5/6] Fix parenting to bone

---
 korman/exporter/convert.py | 21 ++++++++++++---------
 1 file changed, 12 insertions(+), 9 deletions(-)

diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py
index 73684e0..9a421d5 100644
--- a/korman/exporter/convert.py
+++ b/korman/exporter/convert.py
@@ -21,7 +21,7 @@ import functools
 import inspect
 from pathlib import Path
 from typing import *
-from mathutils import Matrix
+from mathutils import Matrix, Vector
 
 from ..helpers import TemporaryObject
 from ..korlib import ConsoleToggler
@@ -258,15 +258,17 @@ class Exporter:
         """Exports a Coordinate Interface if we need one"""
         parent = bo.parent
         parent_bone_name = bo.parent_bone if parent is not None else None
-        offset_matrix = None
+        offset_matrix_local = None
+        offset_matrix_world = None
         if parent_bone_name and parent.plasma_object.enabled:
             # Object is parented to a bone, so use it instead.
             parent_bone = parent.data.bones[parent_bone_name]
             parent = bpy.context.scene.objects[ArmatureConverter.get_bone_name(parent, parent_bone)]
             # ...Actually, it's parented to the bone's /tip/. So we need to offset the child...
-            offset_matrix = Matrix.Translation(bo.matrix_local.row[1].xyz * parent_bone.length)
+            offset_matrix_local = Matrix.Translation(Vector((0, parent_bone.length, 0)))
+            offset_matrix_world = Matrix.Translation(bo.matrix_world.row[1].xyz * parent_bone.length)
         if self.has_coordiface(bo):
-            self._export_coordinate_interface(so, bo, offset_matrix)
+            self._export_coordinate_interface(so, bo, offset_matrix_local, offset_matrix_world)
 
             # If this object has a parent, then we will need to go upstream and add ourselves to the
             # parent's CoordinateInterface... Because life just has to be backwards.
@@ -285,7 +287,7 @@ class Exporter:
                                     The object may not appear in the correct location or animate properly.".format(
                                         bo.name, parent.name))
 
-    def _export_coordinate_interface(self, so, bl, matrix: Matrix = None):
+    def _export_coordinate_interface(self, so, bl, offset_matrix_local: Matrix = None, offset_matrix_world: Matrix = None):
         """Ensures that the SceneObject has a CoordinateInterface"""
         if so is None:
             so = self.mgr.find_create_object(plSceneObject, bl=bl)
@@ -293,13 +295,14 @@ class Exporter:
         if so.coord is None:
             ci_cls = bl.plasma_object.ci_type
             ci = self.mgr.add_object(ci_cls, bl=bl, so=so)
-        if ci is not None or matrix is not None:
+        if ci is not None or offset_matrix_local is not None or offset_matrix_world is not None:
             # We just created the CI, or we have an extra transform that we may have previously skipped.
             matrix_local = bl.matrix_local
             matrix_world = bl.matrix_world
-            if matrix is not None:
-                matrix_local = matrix_local * matrix
-                matrix_world = matrix_world * matrix
+            if offset_matrix_local is not None:
+                matrix_local = offset_matrix_local * matrix_local
+            if offset_matrix_world is not None:
+                matrix_world = offset_matrix_world * matrix_world
             # Now we have the "fun" work of filling in the CI
             ci.localToWorld = utils.matrix44(matrix_world)
             ci.worldToLocal = ci.localToWorld.inverse()

From 3ed604f4008fbafa5b01ea31a788cc921218f2f7 Mon Sep 17 00:00:00 2001
From: Jrius <2261279+Jrius@users.noreply.github.com>
Date: Sat, 8 Feb 2025 16:40:01 +0100
Subject: [PATCH 6/6] Fix lightmap baking

---
 korman/exporter/mesh.py | 24 ++++++++++++++++--------
 1 file changed, 16 insertions(+), 8 deletions(-)

diff --git a/korman/exporter/mesh.py b/korman/exporter/mesh.py
index d30b42f..6074afe 100644
--- a/korman/exporter/mesh.py
+++ b/korman/exporter/mesh.py
@@ -215,14 +215,8 @@ class _MeshManager:
                         mod_prop_dict = self._build_prop_dict(mod)
                         cache_mods.append(mod_prop_dict)
 
-                    armatures = []
-                    for armature_mod in self._exporter().armature.get_skin_modifiers(i):
-                        # We'll use armatures to export bones later. Disable it so it doesn't get baked into the mesh.
-                        armatures.append(armature_mod.object)
-                        # Note that this gets reverted when we reapply cached modifiers.
-                        armature_mod.show_render = False
-                    if armatures:
-                        self._objects_armatures[i.name] = armatures
+                    # Disable armatures if need be (only when exporting)
+                    self._disable_armatures(i)
 
                 i.data = i.to_mesh(scene, True, "RENDER", calc_tessface=False)
 
@@ -254,6 +248,10 @@ class _MeshManager:
                         setattr(mod, key, value)
             self._entered = False
 
+    def _disable_armatures(self, bo):
+        # Overridden when necessary.
+        pass
+
     def is_collapsed(self, bo) -> bool:
         return bo.name in self._overrides
 
@@ -371,6 +369,16 @@ class MeshConverter(_MeshManager):
 
         return geospan
 
+    def _disable_armatures(self, bo):
+        armatures = []
+        for armature_mod in self._exporter().armature.get_skin_modifiers(bo):
+            # We'll use armatures to export bones later. Disable it so it doesn't get baked into the mesh.
+            armatures.append(armature_mod.object)
+            # Note that this gets reverted when we reapply cached modifiers.
+            armature_mod.show_render = False
+        if armatures:
+            self._objects_armatures[bo.name] = armatures
+
     def finalize(self):
         """Prepares all baked Plasma geometry to be flushed to the disk"""
         self._report.progress_advance()