Tools/ModelTuner.cs

A UI tool component for live-tuning body-proportion bone offsets on ported character models. It opens a non-networked overlay, finds ported SkinnedModelRenderer instances, resolves grouped bone indices, and applies combined per-group Z offsets as per-slider values by setting bone overrides each frame; it can clear, zero, print flags and accept console commands to set values or probe transforms.

File AccessNetworking
using Sandbox;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// Live body-proportion tuning for the ported characters: `nz_tune`.
///
/// ⛔ IT EXISTS BECAUSE THE ALTERNATIVE IS A SIX MINUTE ROUND TRIP. The drops live in
/// `Tools/retarget_to_human.py` and are baked into the mesh, so judging a value meant
/// re-retargeting eight characters, recompiling eight models and looking — per guess. Three rounds
/// of "a bit lower" cost most of an afternoon. This drives the same names on the bodies already on
/// screen, live, and PRINT writes them back out as the `--drop` argument the retarget takes.
///
/// ⚠️ THE PREVIEW OVERRIDES BONES; THE BAKE MOVES MESH. Not the same operation, but the same
/// result for the thing being judged: dropping `spine_2` by d puts the chest exactly where
/// translating its chunk by d does. The difference is at the boundaries, where an override blends
/// through the skin weights and the bake tears rigidly — so the preview is the FLATTERING one. A
/// value that looks right here is the value to bake; the baked seams will be slightly harder.
///
/// ⛔ AND IT MOVES WHOLE SUBTREES, NOT SINGLE BONES. `SetBoneOverride` replaces a bone's FINAL
/// transform, so children do NOT follow it — dropping `clavicle_L` by itself detaches the shoulder
/// cap from an upper arm that has not moved. Every bone under a group's roots is collected and
/// moved together, which is what the retarget does with the same table.
/// </summary>
public partial class ModelTuner
{
	/// <summary>
	/// The parts, and which target bones each covers: (roots, include everything under them).
	///
	/// ⛔ THE SAME TABLE AS `GROUPS` IN `retarget_to_human.py`, AND IT HAS TO STAY THAT WAY. The
	/// whole point of the panel is that a number found here can be pasted into the bake; the moment
	/// one side covers a bone the other does not, it is a preview of something else.
	///
	/// ⛔ THEY NEST ON PURPOSE AND THE OFFSETS ADD. `hands` sits inside `arms`, so a finger belongs
	/// to both and gets both. Taking the last group to mention a bone would silently make one of
	/// the two knobs do nothing on exactly the bones where they overlap.
	///
	/// ⚠️ `hips` AND `neck` ARE SINGLE BONES, NOT SUBTREES. Everything hangs off the pelvis, so a
	/// pelvis subtree would move the entire character and read as nothing having happened.
	/// </summary>
	static readonly (string Name, string[] Roots, bool Deep)[] Groups =
	{
		( "head",  new[] { "head" },                       true  ),
		( "neck",  new[] { "neck_0", "neck_1" },           false ),
		( "chest", new[] { "spine_2" },                    false ),
		( "waist", new[] { "spine_0", "spine_1" },         false ),
		( "arms",  new[] { "clavicle_L", "clavicle_R" },   true  ),
		( "hands", new[] { "hand_L", "hand_R" },           true  ),
		( "hips",  new[] { "pelvis" },                     false ),
		( "legs",  new[] { "leg_upper_L", "leg_upper_R" }, true  ),
	};

	// ⚠️ ONE PROPERTY PER PART rather than a dictionary, because `Value:bind` on a slider needs
	// something to bind TO. Signed: positive lowers, negative raises.
	//
	// ⛔ THEY START AT ZERO, AND ZERO MEANS "THE MODEL AS IT SHIPS" — not "no drop anywhere". This
	// panel offsets a body that has ALREADY been baked with its own values, so what it shows is a
	// DELTA on top of them and never the bake's own numbers.
	//
	// ⛔ IT USED TO OPEN AT THE RETARGET'S DEFAULTS, chest and arms at 0.010, on the theory that
	// that matched the model. It did the opposite: those were applied on top of a body that already
	// had them, so the chest was dropped TWICE the moment the panel appeared and every judgement
	// after that was made against a body nobody had asked for. The first tuned line to come back
	// had chest and arms conspicuously absent — which is what noticing this looks like from the
	// other end.
	public float Head { get; set; }
	public float Neck { get; set; }
	public float Chest { get; set; }
	public float Waist { get; set; }
	public float Arms { get; set; }
	public float Hands { get; set; }
	public float Hips { get; set; }
	public float Legs { get; set; }

	float ValueOf( string group ) => group switch
	{
		"head" => Head,
		"neck" => Neck,
		"chest" => Chest,
		"waist" => Waist,
		"arms" => Arms,
		"hands" => Hands,
		"hips" => Hips,
		"legs" => Legs,
		_ => 0f,
	};

	void SetValue( string group, float v )
	{
		switch ( group )
		{
			case "head": Head = v; break;
			case "neck": Neck = v; break;
			case "chest": Chest = v; break;
			case "waist": Waist = v; break;
			case "arms": Arms = v; break;
			case "hands": Hands = v; break;
			case "hips": Hips = v; break;
			case "legs": Legs = v; break;
		}
	}

	/// <summary>Open or close the tuner: `nz_tune`.</summary>
	///
	/// ⛔ THE SCENE IS THE SOURCE OF TRUTH, NOT A STATIC — INSTRUCTIONS §1. A `static GameObject`
	/// remembering the open panel survives a hotload by NAME while the object it points at may not,
	/// and the reverse: after one recompile the console had TWO 'Model Tuner' screen panels stacked,
	/// one at the old z-index, because the toggle had closed the one the static remembered and the
	/// other was already orphaned. Asking the scene what exists cannot drift.
	///
	/// ⛔ ITS OWN ScreenPanel, NOT A CHILD OF WHATEVER PANEL WAS FOUND FIRST. Hosting it on an
	/// existing PanelComponent is what the weapon offset editor does and it laid out correctly —
	/// and drew UNDER the lobby, invisible, while `ui_panel_dump` reported it present at the right
	/// rect. A panel you cannot see but can measure is the worst kind of working.
	[ConCmd( "nz_tune" )]
	public static void Toggle()
	{
		var scene = Game.ActiveScene;
		if ( scene is null ) { Log.Warning( "[nz-tune] no scene" ); return; }

		var live = scene.GetAllComponents<ModelTuner>().Where( t => t.IsValid() ).ToList();

		if ( live.Count > 0 )
		{
			foreach ( var t in live )
				t.GameObject?.Destroy();

			Log.Info( $"[nz-tune] closed{(live.Count > 1 ? $" ({live.Count} were open)" : "")}" );
			return;
		}

		var host = scene.CreateObject();
		host.Name = "Model Tuner";
		host.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;

		// ⚠️ LOCAL ONLY. An authoring overlay on one machine has no business existing on anybody
		// else's — the lobby preview stage carries the same note for the same reason.
		host.NetworkMode = NetworkMode.Never;

		var screen = host.Components.Create<ScreenPanel>();

		// ⚠️ ABOVE THE LOBBY, NOT LEVEL WITH IT. The lobby's own ScreenPanel is at 100 and its
		// `.content` is a FULLSCREEN panel with pointer-events all — so at a tie the card can render
		// on top and still have every click swallowed by the menu underneath it.
		screen.ZIndex = 200;

		host.Components.Create<ModelTuner>();

		Mouse.Visibility = MouseVisibility.Visible;

		Log.Info( "[nz-tune] open. Drag the sliders; PRINT writes the retarget flags to the console." );
	}

	/// <summary>
	/// Set one part, or all of them, from the console: `nz_tune_set chest -0.02`, `nz_tune_set all 0`.
	///
	/// ⚠️ THE PROJECT RULE IS THAT EVERY CONTROL GETS A COMMAND, and a slider is the case that
	/// needs it most: it is the one control that cannot be driven from anywhere except a mouse on
	/// the machine running the game, so without this the panel cannot be tested remotely at all.
	///
	/// ⛔ NAMED, NOT POSITIONAL, AND THE OLD SHAPE WAS A TRAP. It used to be `nz_tune_set <chest>
	/// [arm]` with `-1f` meaning "not given" — fine while the range started at zero, a bug the
	/// moment the sliders went negative: `nz_tune_set 0.02 -0.01` read the negative arm value as
	/// "no second argument" and copied the chest instead. A sentinel inside the valid range is not
	/// a sentinel, and with eight parts a positional list would be unreadable anyway.
	/// </summary>
	[ConCmd( "nz_tune_set" )]
	public static void Set( string part = "", float value = 0f )
	{
		var t = Game.ActiveScene?.GetAllComponents<ModelTuner>().FirstOrDefault( x => x.IsValid() );
		if ( t is null ) { Log.Warning( "[nz-tune] not open — run nz_tune first" ); return; }

		part = part.Trim().ToLowerInvariant();

		if ( part == "all" )
		{
			foreach ( var g in Groups )
				t.SetValue( g.Name, value );
		}
		else if ( Groups.Any( g => g.Name == part ) )
		{
			t.SetValue( part, value );
		}
		else
		{
			Log.Info( "[nz-tune] nz_tune_set <part|all> <value>" );
			Log.Info( "[nz-tune] parts: " + string.Join( ", ", Groups.Select( g => g.Name ) ) );
			Log.Info( $"[nz-tune] now: {t.Flags}" );
			return;
		}

		t.StateHasChanged();
		Log.Info( $"[nz-tune] {t.Flags}" );
	}

	/// <summary>
	/// What space each bone accessor actually answers in: `nz_tune_probe`.
	///
	/// ⚠️ IT EXISTS BECAUSE THE NAMES DISAGREE WITH EACH OTHER. `GetBoneWorldTransform` reads as
	/// world and `SetBoneOverride` documents its input as "local coordinates based on the
	/// SceneModel's transform"; guessing which one is true cost two collapsed models on screen.
	/// Print the body's world position beside a bone's and the answer is one line.
	/// </summary>
	[ConCmd( "nz_tune_probe" )]
	public static void Probe()
	{
		var r = Game.ActiveScene?.GetAllComponents<SkinnedModelRenderer>()
			.FirstOrDefault( IsPorted );

		if ( !r.IsValid() ) { Log.Warning( "[nz-tune] no ported body in the scene" ); return; }

		var sm = r.SceneModel;
		var b = r.Model?.Bones?.GetBone( "spine_2" );

		if ( !sm.IsValid() || b is null ) { Log.Warning( "[nz-tune] no scene model / spine_2" ); return; }

		Log.Info( $"[nz-tune] body world       {r.WorldPosition}" );
		Log.Info( $"[nz-tune] SceneModel.Trans {sm.Transform.Position}" );
		Log.Info( $"[nz-tune] bone 'spine_2' GetBoneWorldTransform {sm.GetBoneWorldTransform( b.Index ).Position}" );
		Log.Info( $"[nz-tune] model bounds {r.Model.Bounds.Size}" );
	}

	/// <summary>One body being driven, with each group's bone indices resolved once.</summary>
	class Rig
	{
		public SkinnedModelRenderer R;
		public float Height = 1f;

		/// <summary>Group name -> the bone indices it covers on THIS model.</summary>
		public readonly Dictionary<string, int[]> Bones = new();

		/// <summary>Bone index -> its transform in MODEL space, read once while nothing was
		/// overriding it. See `Apply` for why it is read exactly once.</summary>
		public readonly Dictionary<int, Transform> Rest = new();
	}

	readonly List<Rig> _rigs = new();

	/// <summary>How many bodies the sliders are driving, for the panel to show.</summary>
	public int Driving => _rigs.Count;

	/// <summary>
	/// Is this one of ours?
	///
	/// ⚠️ BY MODEL PATH, NOT BY BONE NAMES. The citizen has `spine_2` and `clavicle_L` too — it IS
	/// the target rig — so a bone test would drive the engine's own body as well and make it look
	/// like the tuner had broken something it does not own.
	/// </summary>
	static bool IsPorted( SkinnedModelRenderer r )
		=> r.IsValid() && r.Model is not null
		   && r.Model.ResourcePath is string p
		   && p.Contains( "models/player/", StringComparison.OrdinalIgnoreCase )
		   && p.Contains( "_rigged", StringComparison.OrdinalIgnoreCase );

	static void Collect( BoneCollection.Bone b, List<int> into )
	{
		into.Add( b.Index );

		foreach ( var c in b.Children )
			Collect( c, into );
	}

	void Refresh()
	{
		var live = Game.ActiveScene?.GetAllComponents<SkinnedModelRenderer>()
			.Where( IsPorted ).ToList() ?? new List<SkinnedModelRenderer>();

		_rigs.RemoveAll( g => !g.R.IsValid() || !live.Contains( g.R ) );

		foreach ( var r in live )
		{
			if ( _rigs.Any( g => g.R == r ) ) continue;

			var bones = r.Model?.Bones;
			if ( bones is null ) continue;

			var rig = new Rig
			{
				R = r,

				// ⛔ THE MODEL'S OWN BOUNDS, NOT THE RENDERER'S. `r.Bounds` is the LIVE bounds and
				// an override that goes wrong corrupts it — measured at 116 million units on a
				// 70-unit body — so reading the height from it makes one bad frame permanent.
				Height = MathF.Max( 1f, r.Model.Bounds.Size.z ),
			};

			foreach ( var g in Groups )
			{
				var idx = new List<int>();

				foreach ( var root in g.Roots )
				{
					var b = bones.GetBone( root );
					if ( b is null ) continue;

					if ( g.Deep ) Collect( b, idx );
					else idx.Add( b.Index );
				}

				rig.Bones[g.Name] = idx.ToArray();
			}

			// ⚠️ A BODY WITH NOTHING TO DRIVE IS NOT A FAILURE, it is a model that has not finished
			// loading, or one that is not on the human rig. Skipped, and picked up on a later tick.
			if ( rig.Bones.Values.All( v => v.Length == 0 ) ) continue;

			_rigs.Add( rig );
		}
	}

	/// <summary>
	/// Push the current slider values onto one body.
	///
	/// ⛔ THE REST POSE IS READ EXACTLY ONCE, AND EVERY FRAME AFTER THAT ASSIGNS FROM IT.
	/// `GetBoneWorldTransform` answers with the pose AFTER overrides and `SetBoneOverride` takes
	/// MODEL space, so a read-modify-write loop feeds the body's own world transform back into
	/// itself every frame and the bone position DOUBLES. Measured on the lobby stage, which sits at
	/// z 20000: `spine_2` reached z 116,818,000 within seconds and the character rendered as a
	/// vertical smear. `ClearBoneOverrides` first does not save it — the clear does not reach the
	/// read in the same frame.
	///
	/// ⚠️ THE CLEAR IS STILL NEEDED, for the other direction: a part dragged back to zero has to be
	/// RELEASED, and there is no per-bone clear. It is safe here precisely because nothing is read
	/// afterwards.
	///
	/// ⚠️ SO THE MOVED PARTS ARE FROZEN WHILE THE TUNER IS OPEN. That is the trade: the panel
	/// exists to judge a standing character's proportions, and a body animating under it would need
	/// the animation's answer every frame — which is the read that cannot be had safely.
	/// </summary>
	void Apply( Rig g )
	{
		if ( !g.R.IsValid() ) return;

		var sm = g.R.SceneModel;
		if ( !sm.IsValid() ) return;

		sm.ClearBoneOverrides();

		// ⛔ SUMMED ACROSS GROUPS BEFORE ANYTHING IS WRITTEN. A finger is in `arms` and in `hands`;
		// writing one override per group would leave it wherever the last group put it, so the
		// hands knob would do nothing whenever the arms knob was also set.
		var total = new Dictionary<int, float>();

		foreach ( var grp in Groups )
		{
			var amount = ValueOf( grp.Name );
			if ( amount == 0f ) continue;

			foreach ( var i in g.Bones[grp.Name] )
				total[i] = total.GetValueOrDefault( i ) + amount;
		}

		foreach ( var (index, amount) in total )
		{
			if ( !g.Rest.TryGetValue( index, out var rest ) )
			{
				// ⚠️ MODEL SPACE BOTH WAYS. The getter answers in WORLD and the setter takes "local
				// coordinates based on the SceneModel's transform" — two spaces behind two names
				// that do not say so. The body's own transform is what converts between them.
				rest = sm.Transform.ToLocal( sm.GetBoneWorldTransform( index ) );
				g.Rest[index] = rest;
			}

			var t = rest;
			t.Position += new Vector3( 0f, 0f, -amount * g.Height );

			// ⚠️ `in`, NOT `ref`. The parameter is `in`, so `ref` compiled and meant exactly the
			// same thing — but it reads as "this call may write back to `t`", which it cannot.
			sm.SetBoneOverride( index, in t );
		}
	}

	/// <summary>Put every body back the way the animation left it.</summary>
	void Clear()
	{
		foreach ( var g in _rigs.Where( g => g.R.IsValid() && g.R.SceneModel.IsValid() ) )
			g.R.SceneModel.ClearBoneOverrides();

		_rigs.Clear();
	}

	protected override void OnUpdate()
	{
		// ⛔ EVERY FRAME, NOT ONCE ON OPEN. Setting it in the command only works if nothing else
		// touches the cursor afterwards, and leaving the lobby, entering creative or pressing L all
		// set it back to `Auto` — which locks the pointer to the game and makes the card a picture
		// of some sliders. Nothing writes `Auto` on a per-frame basis, so holding it here is enough
		// and does not fight the lobby for it.
		Mouse.Visibility = MouseVisibility.Visible;

		Refresh();

		foreach ( var g in _rigs )
			Apply( g );
	}

	/// <summary>
	/// Back to the model as it ships — every offset off.
	///
	/// ⚠️ THERE IS NO SECOND "BAKED" BUTTON ANY MORE, and there should not be: zero IS baked. The
	/// two buttons used to mean different things and only one of them was true.
	/// </summary>
	void OnZero()
	{
		foreach ( var g in Groups )
			SetValue( g.Name, 0f );

		StateHasChanged();
	}

	/// <summary>
	/// Write the values out, and say what they are.
	///
	/// ⛔ THEY ARE AN OFFSET, NOT A SPEC, and printing them as a bare `--drop` line invited exactly
	/// the wrong thing: pasted over a character that already carries its own entry they would
	/// DISCARD that entry rather than adjust it. The second line is the instruction.
	/// </summary>
	void OnPrint()
	{
		Log.Info( $"[nz-tune] offset from baked: {Flags}" );
		Log.Info( "[nz-tune] ADD these to the character's TUNING entry in character_port.py"
			+ " (or pass as --drop if it has none)" );
		Log.Info( $"[nz-tune] driving {Driving} bod{(Driving == 1 ? "y" : "ies")}" );
	}

	void OnClose() => Toggle();

	protected override void OnDestroy()
	{
		Clear();
	}

	/// <summary>
	/// The line the panel shows and PRINT writes — one source for both.
	///
	/// ⚠️ ONLY THE PARTS THAT MOVED, because an untouched part is not an instruction and a line of
	/// eight values, six of them zero, hides the two that matter.
	/// </summary>
	// ⚠️ `new` BECAUSE THIS HIDES `Component.Flags`, which is a `GameObjectFlags` and nothing to do
	// with the diagnostic string below. Deliberate, and now stated.
	public new string Flags
	{
		get
		{
			var set = Groups.Where( g => ValueOf( g.Name ) != 0f )
				.Select( g => $"{g.Name}={ValueOf( g.Name ):0.###}" )
				.ToList();

			return set.Count == 0 ? "(as baked)" : string.Join( ",", set );
		}
	}

	protected override int BuildHash()
		=> System.HashCode.Combine(
			System.HashCode.Combine( Head, Neck, Chest, Waist ),
			System.HashCode.Combine( Arms, Hands, Hips, Legs ),
			Driving );
}