Code/HumanoidRetargeter/Solve/SourceMirror.cs

Utility that constructs a sagittal mirror of a source skeleton clip and a mirrored rest skeleton before solving. It computes the source lateral axis, reflects positions and rotations per-frame, builds bone pairing from mapping roles and name tokens, and returns a new SourceScene with the mirrored clip and skeleton.

Reflection
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Skeleton;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
using SrcClip = HumanoidRetargeter.Skeleton.Clip;

namespace HumanoidRetargeter.Solve;

using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)

/// <summary>
/// Mirrors a SOURCE clip across the source character's sagittal plane, on the source
/// skeleton, BEFORE solving. The mirrored take then runs through the completely
/// unmodified primary conversion pipeline (solver, cleanups, IK bake, DMX, compile), so
/// the emitted twin is a first-class primary clip in every representational respect.
/// </summary>
/// <remarks>
/// <para><b>Why source-side (southpaw G8 mirror fix).</b> Target-space mirroring of the
/// SOLVED channels produces data that is geometrically exact (bit-scale reflections,
/// verified) yet renders broken in the engine: the render-side sequence evaluation
/// applies conversions to the data that do not commute with reflection, and the breakage
/// is invisible to every CPU-side API (bone transforms sample as perfect mirrors while
/// the skinned mesh collapses). Empirical bisect: injecting the mirrored PELVIS channel
/// alone flips the whole render upside down while CPU FK stays upright; helper, arm and
/// face channel injections behave differently in-engine than on the CPU. A clip whose
/// body was mirrored BEFORE the solve carries no such alien data: it is
/// indistinguishable from a clip an animator authored mirrored, and the engine renders
/// primary-shaped data correctly for any pose.</para>
/// <para><b>Math.</b> Identical conjugation to <see cref="ClipMirror"/> (locals
/// conjugated by the reflection, bones permuted by their L/R partner), with the plane
/// normal computed from the SOURCE character frame (roles from the source mapping) and
/// snapped to an exact axis when near one. The plane passes through the source origin;
/// source clips are authored near the origin, so the residual lateral placement shift is
/// the rest hips offset and the downstream placement passes own it.</para>
/// <para><b>Pairing.</b> Mapped bones pair role-to-mirrored-role. Unmapped bones pair by
/// side tokens supporting underscore/dot separators (<c>arm_l</c>, <c>thigh.r</c>) and
/// Left/Right name words (<c>mixamorig:LeftArm</c>). Anything unpaired mirrors in place.
/// Pairs whose parents are not themselves mirror partners would corrupt under local
/// conjugation, so they fall back to mirroring in place and are counted in the report
/// note.</para>
/// </remarks>
public static class SourceMirror
{
    private const float AxisSnapTolerance = 1e-3f;

    /// <summary>
    /// Returns a scene identical to <paramref name="scene"/> except that clip
    /// <paramref name="take"/> is replaced by its sagittal mirror (other clips are
    /// shared, the skeleton is shared).
    /// </summary>
    /// <exception cref="ArgumentException">Thrown when the source character frame is not
    /// computable or a sided role maps without its counterpart.</exception>
    public static SourceScene MirrorTake(SourceScene scene, MappingResult map, int take)
    {
        ArgumentNullException.ThrowIfNull(scene);
        ArgumentNullException.ThrowIfNull(map);
        if (take < 0 || take >= scene.Clips.Count)
            throw new ArgumentOutOfRangeException(nameof(take));

        var skeleton = scene.Skeleton;
        var lateral = LateralAxis(skeleton, map);
        var pair = BuildPairing(skeleton, map, out var inPlaceFallbacks);

        var clip = scene.Clips[take];
        var frames = new List<XForm[]>(clip.Frames.Count);
        foreach (var locals in clip.Frames)
        {
            if (locals.Length != skeleton.Count)
                throw new ArgumentException(
                    $"Frame has {locals.Length} bones but the source skeleton has {skeleton.Count}.");
            var mirrored = new XForm[locals.Length];
            for (var i = 0; i < locals.Length; i++)
            {
                var source = locals[pair[i]];
                mirrored[i] = new XForm(
                    ReflectPoint(source.Pos, lateral),
                    ReflectRotation(source.Rot, lateral));
            }
            frames.Add(mirrored);
        }

        // The REST skeleton mirrors with the clip. Mid-pose packs whose adopted rest IS
        // an asymmetric pose (a boxing stance kept as rest) would otherwise interpret
        // the mirrored clip against the unmirrored rest, corrupting every rest-relative
        // transfer (measured 25 cm of reflection error on step clips). With rest and
        // clip conjugated by the same pairing the solve is fully mirror-covariant; a
        // symmetric rest round-trips unchanged up to float dirt.
        var definitions = new List<BoneDefinition>(skeleton.Count);
        for (var i = 0; i < skeleton.Count; i++)
        {
            var rest = skeleton[pair[i]].RestLocal;
            definitions.Add(new BoneDefinition(
                skeleton[i].Name,
                skeleton[i].ParentIndex < 0 ? null : skeleton[skeleton[i].ParentIndex].Name,
                new XForm(ReflectPoint(rest.Pos, lateral), ReflectRotation(rest.Rot, lateral))));
        }
        var mirroredSkeleton = SkeletonModel.Create(definitions);

        var clips = new List<SrcClip>(scene.Clips);
        clips[take] = new SrcClip(clip.Name + "_mirrored", clip.Fps, clip.Looping, frames);

        // NOTE: only clip[take] is consistent with the mirrored skeleton; the shared
        // remaining clips are untouched primaries. Callers consume exactly the mirrored
        // take (ConvertOne does).
        return new SourceScene(
            mirroredSkeleton, clips, scene.UnitScaleCm,
            scene.UpAxis, scene.UpAxisSign,
            scene.FrontAxis, scene.FrontAxisSign,
            scene.CoordAxis, scene.CoordAxisSign,
            scene.OriginalUpAxis, scene.Notes)
        {
            AuthoredMapping = scene.AuthoredMapping,
            RestPlacementAuthored = scene.RestPlacementAuthored,
        };
    }

    // ---------------------------------------------------------------- plane

    private static Vector3 LateralAxis(SkeletonModel skeleton, MappingResult map)
    {
        Vector3 lateral;
        try
        {
            lateral = CharacterFrame.Compute(skeleton, map, skeleton.RestWorld).Lateral;
        }
        catch (ArgumentException e)
        {
            throw new ArgumentException(
                $"Cannot mirror source: character frame not computable ({e.Message}).", e);
        }

        var a = Vector3.Abs(lateral);
        if (a.Y <= AxisSnapTolerance && a.Z <= AxisSnapTolerance)
            return Vector3.UnitX;
        if (a.X <= AxisSnapTolerance && a.Z <= AxisSnapTolerance)
            return Vector3.UnitY;
        if (a.X <= AxisSnapTolerance && a.Y <= AxisSnapTolerance)
            return Vector3.UnitZ;
        return lateral;
    }

    private static Vector3 ReflectPoint(Vector3 p, Vector3 n)
        => p - 2f * Vector3.Dot(p, n) * n;

    private static Quaternion ReflectRotation(Quaternion q, Vector3 n)
    {
        var v = new Vector3(q.X, q.Y, q.Z);
        var reflected = 2f * Vector3.Dot(v, n) * n - v;
        return new Quaternion(reflected.X, reflected.Y, reflected.Z, q.W);
    }

    // ---------------------------------------------------------------- pairing

    private static int[] BuildPairing(
        SkeletonModel skeleton, MappingResult map, out int inPlaceFallbacks)
    {
        var pair = new int[skeleton.Count];
        for (var i = 0; i < pair.Length; i++)
            pair[i] = i;

        // roles first: UpperArmL pairs with the bone mapped to UpperArmR, etc.
        var boneRole = new Dictionary<int, BoneRole>();
        foreach (var (role, bone) in map.RoleToBone)
            boneRole[bone] = role;
        foreach (var (role, bone) in map.RoleToBone)
        {
            if (MirrorRole(role) is not { } mirroredRole)
                continue; // center role mirrors in place
            if (!map.RoleToBone.TryGetValue(mirroredRole, out var partner))
                throw new ArgumentException(
                    $"Cannot mirror source: mapping has role {role} but not its counterpart {mirroredRole}.");
            pair[bone] = partner;
        }

        // name tokens for everything unmapped
        for (var i = 0; i < skeleton.Count; i++)
        {
            if (boneRole.ContainsKey(i))
                continue;
            var partnerName = SwapSideTokens(skeleton[i].Name);
            if (partnerName is null)
                continue;
            var partner = skeleton.IndexOf(partnerName);
            if (partner >= 0)
                pair[i] = partner;
        }

        // validation: involution strictly; hierarchy consistency falls back in place
        inPlaceFallbacks = 0;
        for (var i = 0; i < pair.Length; i++)
        {
            if (pair[pair[i]] != i)
            {
                // asymmetric mapping artifact: safer to mirror both in place
                pair[pair[i]] = pair[i];
                pair[i] = i;
                inPlaceFallbacks++;
                continue;
            }
            if (pair[i] == i)
                continue;
            var parent = skeleton[i].ParentIndex;
            var partnerParent = skeleton[pair[i]].ParentIndex;
            var consistent = parent < 0
                ? partnerParent < 0
                : partnerParent == pair[parent];
            if (!consistent)
            {
                pair[pair[i]] = pair[i];
                pair[i] = i;
                inPlaceFallbacks++;
            }
        }
        return pair;
    }

    private static BoneRole? MirrorRole(BoneRole role)
    {
        var name = role.ToString();
        var mirroredName = name[^1] switch
        {
            'L' => name[..^1] + "R",
            'R' => name[..^1] + "L",
            _ => null,
        };
        return mirroredName is not null && Enum.TryParse<BoneRole>(mirroredName, out var mirrored)
            ? mirrored
            : null;
    }

    /// <summary>Swaps side tokens: underscore or dot delimited <c>l</c>/<c>r</c> tokens
    /// (case preserved), or Left/Right words anywhere in the name. Null when the name
    /// carries no side marker.</summary>
    internal static string? SwapSideTokens(string name)
    {
        static string PairSwap(string s, string a, string b)
        {
            const string placeholder = "";
            return s.Replace(a, placeholder).Replace(b, a).Replace(placeholder, b);
        }

        // word-level Left/Right (mixamo, UE rigs): pairwise swap
        if (name.Contains("Left", StringComparison.Ordinal)
            || name.Contains("Right", StringComparison.Ordinal))
        {
            var swapped = PairSwap(name, "Left", "Right");
            if (!string.Equals(swapped, name, StringComparison.Ordinal))
                return swapped;
        }
        if (name.Contains("left", StringComparison.Ordinal)
            || name.Contains("right", StringComparison.Ordinal))
        {
            var swapped = PairSwap(name, "left", "right");
            if (!string.Equals(swapped, name, StringComparison.Ordinal))
                return swapped;
        }

        var tokens = name.Split('_', '.');
        var separators = new List<char>();
        foreach (var c in name)
        {
            if (c is '_' or '.')
                separators.Add(c);
        }
        var changed = false;
        for (var i = 0; i < tokens.Length; i++)
        {
            var swapped = tokens[i] switch
            {
                "L" => "R", "R" => "L", "l" => "r", "r" => "l",
                _ => tokens[i],
            };
            if (swapped != tokens[i])
            {
                tokens[i] = swapped;
                changed = true;
            }
        }
        if (!changed)
            return null;
        var sb = new System.Text.StringBuilder(name.Length);
        for (var i = 0; i < tokens.Length; i++)
        {
            sb.Append(tokens[i]);
            if (i < separators.Count)
                sb.Append(separators[i]);
        }
        return sb.ToString();
    }

}