Code/CharacterKit/CharacterFootPlacement.cs

Component that applies character growth pose and optional foot ground-contact IK. It computes leg solves, samples a precomputed sole contact hull (including shoe contacts), adjusts bone transforms, and submits bone overrides to the model for rendering.

Native InteropFile Access
using System;

namespace CharacterEditor;

/// <summary>One pose owner composes growth and optional ground-contact IK over native animation.</summary>
public sealed class CharacterFootPlacement : IDisposable
{
 readonly CharacterGrowthPose growth = new();
 readonly int[] supportBones = new int[8];
 readonly Transform[] bind = new Transform[8];
 readonly Vector3[] heightAxes = new Vector3[8];
 readonly float[] heightOffsets = new float[8];
 Vector3[,] contactLocal;
 NativeSoleContacts.Point[] contacts;
 Model[] shoes = Array.Empty<Model>();
 float shoeCheck;
 readonly int[] hips = new int[2], knees = new int[2], ankles = new int[2];
 int[] parents, legBones;
 bool[] legBoneMask;
 Transform[] original, solved;
 SkinnedModelRenderer target;
 Model model;
 bool ownsOverrides, footRigValid;
 float size = -1, male = -1;
 static readonly string[] ManualFeet = { "ik.foot_left.enabled", "ik.foot_right.enabled" };

 public void Prepare(SkinnedModelRenderer body)
 {
  if (ownsOverrides && target.IsValid() && target.SceneModel is not null) target.SceneModel.ClearBoneOverrides();
  ownsOverrides = false;
 }

 /// <summary>
 /// Growth applies to native graph playback and frozen/sequence previews, even
 /// while airborne or with ground contact disabled. Call Prepare before native
 /// pose evaluation so the previous submitted pose never feeds back into itself.
 /// </summary>
 public void Update(SkinnedModelRenderer body, CharacterAppearanceData appearance, bool groundFeet = true, bool allowEditor = false)
 {
  if (!body.IsValid() || appearance is null || (body.Scene.IsEditor && !allowEditor) || !body.Enabled || body.SceneModel is null) return;
  var native = body.SceneModel;
  // Another pose owner takes precedence. Growth and contact submit together,
  // rather than each mistaking the other's overrides for an external owner.
  if (native.HasBoneOverrides()) return;
  growth.Configure(body, appearance);
  bool growing = growth.IsActive;
  bool resolveFeet = groundFeet && !body.Scene.IsEditor && body.UseAnimGraph && body.Parameters.GetBool("b_grounded")
   && body.Parameters.GetInt("sit") == 0 && !body.Parameters.GetBool("b_sit")
   && !body.Parameters.GetBool("b_swim") && !body.Parameters.GetBool("b_noclip") && body.Parameters.GetInt("special_movement_states") == 0;
  if (!growing && !resolveFeet) return;
  if (target != body || model != body.Model || original is null) CacheRig(body);
  resolveFeet &= footRigValid;
  if (!growing && !resolveFeet) return;

  if (growing)
  {
   for (int i = 0; i < model.BoneCount; i++) original[i] = native.GetBoneWorldTransform(i);
   growth.ApplyTo(original, solved);
   // The contact solve starts from the grown anatomy, retaining the native
   // rotations and shortened limb lengths during each candidate solve.
   Array.Copy(solved, original, original.Length);
  }
  else
   foreach (int i in legBones) solved[i] = original[i] = native.GetBoneWorldTransform(i);

  if (resolveFeet)
  {
   shoeCheck -= Time.Delta;
   if (shoeCheck <= 0 || contacts is null)
   {
    shoeCheck = .5f;
    var current = body.GameObject.GetComponentsInChildren<SkinnedModelRenderer>(true,true)
     .Where(r=>r!=body && r.Enabled && r.GameObject.Enabled && AutomaticWardrobeFit.FootContacts(r.Model) is not null).Select(r=>r.Model).ToArray();
    if (!current.SequenceEqual(shoes) || contacts is null) { shoes=current; size=-1; contacts=null; }
   }
   UpdateContacts(appearance);
  }

  float scale=MathF.Max(.01f,body.WorldScale.z);bool changed=growing;
  for(int side=0;resolveFeet && side<2;side++)
  {
   if(body.Parameters.GetBool(ManualFeet[side]))continue;
   var foot=original[ankles[side]].Position;
   var ground=body.Scene.Trace.Ray(foot+Vector3.Up*12*scale,foot-Vector3.Up*20*scale).IgnoreGameObjectHierarchy(body.GameObject.Root).Run();
   if(!ground.Hit)continue;
   float floor=ground.HitPosition.z+.04f*scale;
   float lift=floor-Height(side);
   if(lift<=.005f*scale||lift>8*scale)continue;
   for(int iteration=0;iteration<10;iteration++)
   {
    Solve(side,foot+Vector3.Up*lift);
    float residual=floor-Height(side);
    if(residual<=.002f*scale)break;
    lift+=MathF.Min(residual,scale);
   }
   changed=true;
  }
  if(!changed)return;
  // Submitting only the corrected legs allows the update below to reevaluate
  // unprotected arms without the native grip IK result. Preserve the complete
  // evaluated pose whenever contact changes it; defer this capture until a
  // correction exists so unchanged adult frames still read only the legs.
  if (!growing)
   for (int i = 0; i < solved.Length; i++)
    if (!legBoneMask[i]) solved[i] = native.GetBoneWorldTransform(i);
  for (int i = 0; i < solved.Length; i++) native.SetBoneOverride(i,body.WorldTransform.ToLocal(solved[i]));
  ownsOverrides=true;
  float rate=native.PlaybackRate;
  try { native.PlaybackRate=0;native.Update(0); }
  finally { native.PlaybackRate=rate; }
#pragma warning disable CS0612, CS0618
  body.PostAnimationUpdate(); // Native bone merging keeps every garment on the final shared pose.
#pragma warning restore CS0612, CS0618
 }

 void CacheRig(SkinnedModelRenderer body)
 {
  target = body; model = body.Model; size = male = -1; shoeCheck = 0;
  contacts = null; contactLocal = null; shoes = Array.Empty<Model>(); footRigValid = false;
  int count = model?.BoneCount ?? 0;
  original = new Transform[count]; solved = new Transform[count];
  parents = new int[count]; legBones = Array.Empty<int>();
  legBoneMask = new bool[count];
  var names = new string[count];
  for (int i = 0; i < count; i++) { names[i] = model.GetBoneName(i); parents[i] = model.GetBoneParent(i); }
  int Find(string name) => Array.IndexOf(names, name);
  for (int i = 0; i < supportBones.Length; i++)
  {
   supportBones[i] = Find(NativeSoleContacts.Bones[i]);
   if (supportBones[i] < 0) return;
   bind[i] = model.GetBoneTransform(supportBones[i]);
  }
  hips[0]=Find("leg_upper_L");hips[1]=Find("leg_upper_R");knees[0]=Find("leg_lower_L");knees[1]=Find("leg_lower_R");ankles[0]=Find("ankle_L");ankles[1]=Find("ankle_R");
  if (hips.Any(i=>i<0) || knees.Any(i=>i<0) || ankles.Any(i=>i<0)) return;
  int Depth(int i)
  {
   int depth = 0;
   while (i >= 0 && depth <= count) { if (i >= count) return count + 1; depth++; i = parents[i]; }
   return depth;
  }
  var depths = new int[count];
  for (int i = 0; i < count; i++) { depths[i] = Depth(i); if (depths[i] > count) return; }
  bool InLeg(int i) { while(i>=0) { if(i==hips[0]||i==hips[1])return true; i=parents[i]; } return false; }
  legBones=Enumerable.Range(0,count).Where(InLeg).OrderBy(i=>depths[i]).ToArray();
  foreach (int i in legBones) legBoneMask[i] = true;
  footRigValid = true;
 }

 void UpdateContacts(CharacterAppearanceData appearance)
 {
  float feetMale=BodyRegions.MaleAmount(appearance,"feet");
  if(size!=appearance.FootSize||male!=feetMale||contacts is null)
  {
   size=appearance.FootSize;male=feetMale;
   var all=NativeSoleContacts.Points.Concat(shoes.SelectMany(m=>AutomaticWardrobeFit.FootContacts(m)??Array.Empty<AutomaticWardrobeFit.SolePoint>()).Select(s=>s.Male with { Position=Vector3.Lerp(s.Female,s.Male.Position,male) }))
    .Select(s=>s with { Position=Shape(s.Position,size,male,appearance.BaseShape is not null) }).ToArray();
   contacts=FootContactHull.Reduce(all);
   contactLocal = new Vector3[contacts.Length,4];
   for(int i=0;i<contacts.Length;i++)
   {
    var sample=contacts[i];var p=sample.Position;
    for(int k=0;k<4;k++){int b=Bone(sample,k);contactLocal[i,k]=bind[b].Rotation.Inverse*(p-bind[b].Position);}
   }
  }
 }

 void Solve(int side,Vector3 targetFoot)
 {
  int h=hips[side],k=knees[side],a=ankles[side];
  var hip=original[h].Position;var knee=original[k].Position;var foot=original[a].Position;
  float upper=(knee-hip).Length,lower=(foot-knee).Length;
  var axis=(targetFoot-hip).Normal;
  float distance=Math.Clamp((targetFoot-hip).Length,.001f,upper+lower-.0001f);
  float along=(upper*upper-lower*lower+distance*distance)/(2*distance);
  var bend=(knee-hip)-axis*Vector3.Dot(knee-hip,axis);
  if(bend.LengthSquared<.000001f)bend=original[h].Rotation.Forward;
  var newKnee=hip+axis*along+bend.Normal*MathF.Sqrt(MathF.Max(0,upper*upper-along*along));
  solved[h]=new Transform(hip,Rotation.FromToRotation(knee-hip,newKnee-hip)*original[h].Rotation,original[h].Scale);
  solved[k]=new Transform(newKnee,Rotation.FromToRotation(foot-knee,targetFoot-newKnee)*original[k].Rotation,original[k].Scale);
  solved[a]=new Transform(targetFoot,original[a].Rotation,original[a].Scale);
  foreach(int i in legBones)
  {
   if(i==h||i==k||i==a)continue;
   int ancestor=i;while(ancestor>=0&&ancestor!=h)ancestor=parents[ancestor];
   if(ancestor!=h)continue;
   int p=parents[i];
   solved[i]=solved[p].ToWorld(original[p].ToLocal(original[i]));
  }
 }
 float Height(int side)
 {
  // Plane clearance needs only world Z, not four complete rotated positions
  // per vertex. Cache the eight bone rows once for this candidate pose.
  for(int b=0;b<supportBones.Length;b++)
  {
   var t=solved[supportBones[b]];
   heightAxes[b]=new Vector3((t.Rotation*Vector3.Forward).z,(t.Rotation*Vector3.Left).z,(t.Rotation*Vector3.Up).z)*t.Scale;
   heightOffsets[b]=t.Position.z;
  }
  float minimum=float.MaxValue;
  for(int i=0;i<contacts.Length;i++)
  {
   var sample=contacts[i];if((sample.Position.y>0?0:1)!=side)continue;
   float z=0;
   for(int k=0;k<4;k++){float w=Weight(sample,k);if(w==0)continue;int b=Bone(sample,k);z+=(heightOffsets[b]+Vector3.Dot(heightAxes[b],contactLocal[i,k]))*w;}
   minimum=MathF.Min(minimum,z);
  }
  return minimum;
 }
 static int Bone(NativeSoleContacts.Point s,int k)=>k switch{0=>s.B0,1=>s.B1,2=>s.B2,_=>s.B3};
 static float Weight(NativeSoleContacts.Point s,int k)=>k switch{0=>s.W0,1=>s.W1,2=>s.W2,_=>s.W3};
 static Vector3 Shape(Vector3 point,float size,float male,bool unified)
 {
  var gender=unified?Vector3.Lerp(new(.8f,.84f,1),Vector3.One,male):Vector3.One;
  var footSize=Vector3.One+new Vector3(.22f,.18f,0)*((size-.5f)*2);
  var center=new Vector3(0,point.y>0?7.8f:-7.8f,1.8f);var mask=new Vector3(9,4,4.2f);
  var p=(point-center)/mask;float t=Math.Clamp((1-p.Length)/.65f,0,1);float influence=t*t*(3-2*t);
  var shift=new Vector3(-2.8f/9*(1-gender.x)*footSize.x,0,0);
  var candidate=Vector3.Lerp(p,p*gender*footSize+shift,influence);if(candidate.Length>1)candidate=candidate.Normal;
  return point+(candidate-p)*mask;
 }
 public void Dispose(){Prepare(target);target=null;model=null;contacts=null;contactLocal=null;original=solved=null;parents=legBones=null;legBoneMask=null;footRigValid=false;shoes=Array.Empty<Model>();growth.Configure(null,1,Transform.Zero);}
}

/// <summary>Clear owned poses before native playback, then compose growth and contact before rendering.</summary>
public sealed class CharacterFootPlacementSystem:GameObjectSystem<CharacterFootPlacementSystem>
{
 public CharacterFootPlacementSystem(Scene scene):base(scene)
 {
  Listen(Stage.UpdateBones,-100,PrepareFeet,"Prepare foot IK");
  Listen(Stage.UpdateBones,100,UpdateFeet,"Resolve foot contact");
 }
 void PrepareFeet(){foreach(var a in Scene.GetAllComponents<CharacterAvatar>())a.PrepareFeet();}
 void UpdateFeet(){foreach(var a in Scene.GetAllComponents<CharacterAvatar>())a.UpdateFeet();}
}