Editor/EffigyEditor/EffigyRigPanel.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

// Effigy.Skeleton, not Sandbox.Skeleton - the engine has a Skeleton type of its own, and every
// Skeleton named here is the CAD one the rig panel builds and the exporters write out.
using Skeleton = Effigy.Skeleton;

namespace Marionette.EditorTools;

/// <summary>
/// Authoring a skeleton on top of the studio's mesh: place bones by clicking the model —
/// branching from a selected bone rather than only ever chaining in a straight line, which is
/// what a spine growing into two arms and two legs needs — rename or delete them from a tree,
/// mirror one side of a rig onto the other, and optionally pin a body to a bone so skinning does
/// not rely entirely on nearest-bone weighting.
///
/// This is the panel EffigyWindow has referenced since the day the rigged export path was
/// written — HasBones, Skeleton, BodyBoneMap and Refresh() are its contract with the window,
/// which reads them at compile time to decide between the rigged (DMX) and static (OBJ) export
/// paths. It simply never had a body until now.
///
/// The Skeleton lives here, not in the viewport. The viewport only draws it (EffigyViewport.cs),
/// poses it by dragging (also EffigyViewport.cs) and reports bone-placement clicks
/// (EffigyViewport.Rig.cs) — same division BodyPickMode already uses between the viewport and
/// EffigyFeatureDialog. One assignment of RigSkeleton in the constructor is enough because the
/// Skeleton is mutated in place; nothing here ever replaces the instance.
/// </summary>
internal sealed class EffigyRigPanel : Widget
{
	private readonly EffigyViewport _viewport;
	private PartStudio _studio;

	private readonly TreeView _tree;

	/// <summary>Keyed by bone NAME rather than index — TreeNode&lt;T&gt; needs a reference type
	/// for T, and a name survives exactly as long as the bone does, which an index does not (a
	/// delete shifts every later index).</summary>
	private readonly Dictionary<string, BoneNode> _nodes = new();

	private Button _addBoneButton;
	private Button _assignBodyButton;
	private Button _mirrorButton;

	// --- numeric inspector ------------------------------------------------------------------

	private Widget _inspector;
	private Editor.Label _inspectorName;
	private EffigyNumericField _headX, _headY, _headZ;
	private EffigyNumericField _tailX, _tailY, _tailZ;
	private Editor.Label _bodyListHeader;
	private Widget _bodyList;

	// --- diagnostics -------------------------------------------------------------------------

	private Editor.Label _problemsHeader;
	private Widget _problemsList;

	/// <summary>True while RefreshInspector is pushing values into the six fields — on a selection
	/// change, mainly. EffigyNumericField.SetValue does not fire ValueEdited on its own, but
	/// without this guard a stray re-entrant call would still read _selectedBone mid-refresh and
	/// write a half-updated bone back into the skeleton.</summary>
	private bool _editingInspector;

	public Skeleton Skeleton { get; } = new();

	public bool HasBones => Skeleton.Count > 0;

	private readonly Dictionary<string, string> _bodyBoneMap = new();

	/// <summary>Body id -> bone name. Keyed by name rather than index because SkinBinder.BindBodies
	/// takes it that way, and because a bone's index is not stable across a delete — its name is
	/// what a rename or a rebuild has to chase, not a slot in a list.</summary>
	public IReadOnlyDictionary<string, string> BodyBoneMap => _bodyBoneMap;

	// --- bone-placement chain state ------------------------------------------------------

	private int _selectedBone = -1;
	private Vec3? _chainHead;
	private int _chainParent = -1;
	private bool _assigningBody;

	public EffigyRigPanel( Widget parent, PartStudio studio, EffigyViewport viewport ) : base( parent )
	{
		_studio = studio;
		_viewport = viewport;

		Name = "Rig";
		WindowTitle = "Rig";
		SetWindowIcon( "account_tree" );

		Layout = Layout.Column();

		// Margin(8,4) + Spacing 8 matches the Parts and Features panel headers in this same
		// window.
		var header = new Widget( this ) { Layout = Layout.Column() };
		header.Layout.Margin = new Sandbox.UI.Margin( 8, 4 );
		header.Layout.Spacing = 8;

		var toolRow = new Widget( header ) { Layout = Layout.Row() };
		toolRow.Layout.Spacing = 6;

		// Text updates with the selection (see OnViewportBoneSelectionChanged) — "Branch from
		// 'upper_arm'" when a bone is selected, plain "Add Bone" when nothing is, so the tool
		// teaches its own branching behaviour instead of hiding it in a tooltip nobody reads
		// before their first click.
		_addBoneButton = new Button( "Add Bone", "add" )
		{
			ToolTip = "Click the model to place a bone. Click again to extend a chain from it. "
				+ "Select a bone first to branch a new chain from ITS tail instead of starting a new root.",
			Clicked = () => SetBoneToolActive( !_viewport.BoneToolActive ),
		};
		toolRow.Layout.Add( _addBoneButton, 1 );
		header.Layout.Add( toolRow );

		Layout.Add( header );

		// Assign Body and Mirror both act on "the selected bone" and are built here but laid out
		// inside the inspector, next to the name of the bone they would act on — see
		// BuildInspector. They used to sit up here, disabled and out of context until you scrolled
		// past the tree to see which bone was even selected; grouping them with the thing they
		// act on is the whole fix.
		_assignBodyButton = new Button( "Assign Body", "link" )
		{
			ToolTip = "Click bodies in the viewport to pin them to this bone. "
				+ "Optional — unassigned bodies still skin, to whichever bone is nearest.",
			Clicked = ToggleAssignBody,
		};

		_mirrorButton = new Button( "Mirror", "flip" )
		{
			ToolTip = "Mirror this bone (and everything under it) across Y=0 — this project's "
				+ "left/right axis — onto the same parent it already hangs from.",
			Clicked = MirrorSelectedBone,
		};

		_tree = new TreeView( this );
		_tree.OnSelectionChanged = objs =>
		{
			var index = objs?.FirstOrDefault() is BoneNode node ? Skeleton.IndexOf( node.Value ) : -1;
			_viewport.SelectBone( index );
			OnViewportBoneSelectionChanged( index );
		};
		Layout.Add( _tree, 1 );

		BuildInspector();
		Layout.Add( _inspector );

		// BELOW THE INSPECTOR, so a problem sits nearest the controls that fix it and a clean rig
		// costs no space at all — both the header and the list hide themselves when there is nothing
		// to say. A permanently visible "0 problems" panel is a panel people stop reading.
		BuildProblems();
		Layout.Add( _problemsHeader );
		Layout.Add( _problemsList );

		_viewport.RigSkeleton = Skeleton;
		_viewport.BoneSelectionChanged = OnViewportBoneSelectionChanged;
		_viewport.BonePosed = OnBonePosed;

		// += rather than =: EffigyFeatureDialog already set this to its own body-picker's Escape
		// handler. Escape is generic per BodyPickMode in EffigyViewport.OnKeyPress — it has no
		// idea which of the two ever armed it — so both listeners have to run, not just whichever
		// assigned last. DisarmAssign already no-ops when this panel wasn't the one armed, the
		// same way the dialog's own handler already no-ops when IT wasn't.
		_viewport.PickModeCancelled += DisarmAssign;

		Refresh();
	}

	/// <summary>A different studio entirely — New Studio or Load Document — not a rebuild of the
	/// same one. The old skeleton was authored against the old mesh and BodyBoneMap's ids belong
	/// to bodies that no longer exist, so both are cleared rather than carried into a model they
	/// were never placed on.</summary>
	public void SetStudio( PartStudio studio )
	{
		_studio = studio;

		SetBoneToolActive( false );
		DisarmAssign();

		Skeleton.Bones.Clear();
		_bodyBoneMap.Clear();
		_selectedBone = -1;
		_viewport.DeselectBone();

		// DeselectBone is a no-op when nothing was selected, so it cannot be trusted alone to
		// reset this — a stale "Branch from 'X'" would otherwise survive into a model that no
		// longer has a bone by that name.
		_addBoneButton.Text = "Add Bone";

		Refresh();
	}

	/// <summary>
	/// Fired right before a bone is placed, deleted, renamed, or mirrored — the same "before"
	/// moment EffigyViewport.SketchEditing exists for on the sketch side, wired to the window's own
	/// RecordUndo the same way. Deliberately NOT fired from the numeric inspector's fields: those
	/// fire on every keystroke that still parses, and putting one undo step per character on the
	/// stack is exactly what RecordUndo's own doc comment says a parameter drag must not do either.
	/// </summary>
	public Action RigChanging { get; set; }

	/// <summary>
	/// Raised AFTER the rig has changed, for anything that needs to look at the result.
	///
	/// RigChanging cannot serve this. It fires before the mutation so that undo has an untouched
	/// "before" to snapshot, which means a listener called from it sees the rig as it was — fine
	/// for taking a copy, useless for asking a question about what now exists. Nothing in a rig
	/// edit goes through RebuildStudio either, so without this a bone placed is invisible to the
	/// rest of the window until some unrelated thing forces a rebuild.
	/// </summary>
	public Action RigChanged { get; set; }

	/// <summary>
	/// Replace the skeleton and body-bone map wholesale — the undo/redo restore path. Unlike
	/// SetStudio, the mesh underneath hasn't changed, only the rig; still clears any in-progress
	/// placement or body-assign tool state, since neither survives meaningfully across a jump to a
	/// different point in history.
	/// </summary>
	public void RestoreRig( Skeleton snapshot, IReadOnlyDictionary<string, string> bodyBoneMap )
	{
		SetBoneToolActive( false );
		DisarmAssign();

		Skeleton.Bones.Clear();
		Skeleton.Bones.AddRange( snapshot.Bones.Select( b => b.Clone() ) );

		_bodyBoneMap.Clear();

		foreach ( var (body, bone) in bodyBoneMap )
			_bodyBoneMap[body] = bone;

		_viewport.DeselectBone();
		_selectedBone = -1;
		_addBoneButton.Text = "Add Bone";

		Refresh();
	}

	public void Refresh()
	{
		RebuildTree();
		RefreshInspector();
		RefreshProblems();
	}

	/// <summary>
	/// Called after the studio rebuilds for a reason that has nothing to do with the rig — any CAD
	/// parameter edit, which is to say constantly. A body a bone is pinned to might have gone away
	/// or been renamed, so the inspector's body-name lookups are worth refreshing; nothing about
	/// which BONES exist ever changes from a studio rebuild, so the tree is deliberately left
	/// alone. RebuildTree collapses every chain back to just its roots — fine on a genuine rig
	/// edit, not something a totally unrelated parameter drag should ever trigger.
	/// </summary>
	public void RefreshBodyNames()
	{
		RefreshInspector();

		// AND THE PROBLEMS, because most of them are about the studio rather than the skeleton: a
		// body a bone was pinned to going away, or a mesh gaining vertices no bone reaches. Those
		// appear and disappear on ordinary CAD edits, which is exactly when this is called.
		RefreshProblems();
	}

	// --- bone placement -------------------------------------------------------------------

	/// <summary>Turn the bone tool off if it happens to be on — called from EffigyWindow when
	/// entering a sketch, since IsSketching and BoneToolActive both drive left-clicks in the
	/// viewport and nothing before this stopped them from being true at once. Safe to call any
	/// time: a no-op when the tool is already off.</summary>
	public void CancelBoneTool()
	{
		if ( _viewport.BoneToolActive )
			SetBoneToolActive( false );
	}

	/// <summary>
	/// Arm or disarm the click-to-place tool. Each click extends the current chain from the last
	/// point to the new one, parented to the bone that segment just made — Blender's
	/// armature-extrude gesture.
	///
	/// If a bone was selected when the tool was armed, the chain starts from THAT bone's tail,
	/// parented to it, instead of starting fresh — the same "extrude from the selected tip"
	/// gesture Blender uses, and the only way this tool can build anything but one straight chain.
	/// Without it there would be no way to grow a spine into two arms and two legs: every new
	/// chain would have to start over as its own disconnected root.
	///
	/// Escape closes the current chain (so the next click starts a new, unparented root); Escape
	/// again turns the tool off.
	/// </summary>
	private void SetBoneToolActive( bool active )
	{
		// Sketching owns left-clicks in the viewport while it's open, the same way this tool
		// does — arming on top of it would mean one click tries to do both. Refuse rather than
		// force sketch mode closed: unlike this tool's own pending-chain state, a sketch mid-edit
		// is a whole feature that closing behind the user's back could discard cleanly or not.
		if ( active && _viewport.IsSketching )
		{
			_viewport.SetPickPrompt( "Finish or cancel the sketch first — Escape backs out of it." );
			return;
		}

		// SketchPickMode is a second, separate case: an open Extrude/Revolve dialog leaves it true
		// for the dialog's whole editing session — see EffigySketchSelector, whose Disarm only
		// toggles its own highlight, not this — so PickModeCancelled below never touches it. Same
		// refusal, same reason: closing that dialog isn't this tool's call to make.
		if ( active && _viewport.SketchPickMode )
		{
			_viewport.SetPickPrompt( "Close the open feature's dialog first." );
			return;
		}

		if ( active )
		{
			DisarmAssign();

			// If a feature dialog's own body/plane picker is armed, hand it back the same way
			// Escape would: clear the modes it owns and tell it to disarm, so its box repaints as
			// disarmed instead of silently losing the clicks it thinks it is still getting.
			_viewport.PlanePickMode = false;
			_viewport.BodyPickMode = false;
			_viewport.PickModeCancelled?.Invoke();

			var branchFrom = _selectedBone;

			_viewport.DeselectBone();
			_selectedBone = -1;

			if ( branchFrom >= 0 && branchFrom < Skeleton.Count )
			{
				_chainHead = Skeleton.TailWorld( branchFrom );
				_chainParent = branchFrom;
				_viewport.PendingBoneHead = _chainHead;
			}
			else
			{
				_chainHead = null;
				_chainParent = -1;
				_viewport.PendingBoneHead = null;
			}

			_viewport.BonePointPicked = OnBonePointPicked;
			_viewport.BoneToolEscape = OnBoneToolEscape;
			_viewport.SetPickPrompt( branchFrom >= 0
				? $"Click to extend a bone from '{Skeleton.Bones[branchFrom].Name}'. Escape when done."
				: "Click to place a bone. Click again to extend the chain. Escape when done." );
		}
		else
		{
			_viewport.BonePointPicked = null;
			_viewport.BoneToolEscape = null;
			_viewport.PendingBoneHead = null;
			_chainHead = null;
			_chainParent = -1;
			_viewport.SetPickPrompt( "" );
		}

		_viewport.BoneToolActive = active;
		_addBoneButton.Text = active ? "Placing… (Esc to stop)" : "Add Bone";

		// Selecting a different bone in the tree while placing is harmless (see the comment in
		// ToggleAssignBody), but arming Assign Body on top of it isn't — disabled rather than a
		// silent refusal on click, so it's obvious before you try.
		_assignBodyButton.Enabled = !active;
	}

	private void OnBonePointPicked( Vec3 point )
	{
		if ( _chainHead is not { } head )
		{
			_chainHead = point;
			_viewport.PendingBoneHead = point;
			return;
		}

		// A double-click landing on the same spot would otherwise hit AddBoneFromPoints' zero-
		// length guard and throw — worth swallowing quietly rather than teaching the tool to crash
		// on a mis-click.
		if ( (point - head).Length < 0.01f )
			return;

		RigChanging?.Invoke();
		var index = Skeleton.AddBoneFromPoints( NextBoneName(), _chainParent, head, point );

		_chainHead = point;
		_chainParent = index;
		_viewport.PendingBoneHead = point;

		RebuildTree();
	}

	private void OnBoneToolEscape()
	{
		if ( _chainHead is not null )
		{
			_chainHead = null;
			_chainParent = -1;
			_viewport.PendingBoneHead = null;
			return;
		}

		SetBoneToolActive( false );
	}

	private string NextBoneName()
	{
		var n = Skeleton.Count + 1;
		while ( Skeleton.IndexOf( $"bone_{n}" ) >= 0 )
			n++;
		return $"bone_{n}";
	}

	// --- body assignment -------------------------------------------------------------------

	/// <summary>
	/// Arm or disarm assigning bodies to the selected bone. While armed, clicking a body toggles
	/// it onto (or off of) the selected bone — same "click to add or remove" feel
	/// EffigyFeatureDialog's body picker already has, reusing the same BodyPickMode the viewport
	/// exposes for it.
	///
	/// Optional: BindBodies falls back to nearest-bone rigid weighting, smoothed across mesh
	/// adjacency, for anything left unassigned. A skeleton with no assignments at all still
	/// exports and skins — this just lets a specific part be pinned to a specific bone rather than
	/// left to distance.
	/// </summary>
	private void ToggleAssignBody()
	{
		if ( _assigningBody )
		{
			DisarmAssign();
			return;
		}

		if ( _selectedBone < 0 || _selectedBone >= Skeleton.Count )
			return;

		// The tree stays clickable while the bone tool is armed (selecting a different bone mid-
		// chain doesn't disturb it — see OnBonePointPicked, which never reads _selectedBone after
		// arming), so a selection can exist here even though BoneToolActive is also true. Refuse
		// rather than arm BodyPickMode on top of it, same reasoning as SetBoneToolActive refusing
		// the reverse — one click cannot be both a body pick and a bone placement.
		if ( _viewport.BoneToolActive )
			return;

		// Same hand-back as the bone tool's: if a feature dialog's own picker is armed, tell it to
		// disarm rather than silently steal BodyPickMode out from under it.
		_viewport.PlanePickMode = false;
		_viewport.BodyPickMode = false;
		_viewport.PickModeCancelled?.Invoke();

		_assigningBody = true;

		// EffigyFeatureDialog is the only other thing that ever set this list, so it has to be
		// refreshed here rather than assumed — otherwise a click hits whatever bodies a feature
		// dialog last armed against, or nothing at all if none has run yet this session.
		_viewport.SetPickableBodies( _studio?.Bodies );

		_viewport.BodyPickMode = true;
		_viewport.BodyPicked = OnBodyPicked;
		_viewport.SelectedBodyIds = BodiesOnBone( Skeleton.Bones[_selectedBone].Name );
		_viewport.SetPickPrompt(
			$"Click bodies to assign to '{Skeleton.Bones[_selectedBone].Name}' — click again to unassign one. "
				+ "Escape when done." );
		_assignBodyButton.Text = "Done Assigning";
	}

	private void DisarmAssign()
	{
		if ( !_assigningBody )
			return;

		_assigningBody = false;
		_viewport.BodyPickMode = false;
		_viewport.BodyPicked = null;
		_viewport.SelectedBodyIds = null;
		_viewport.SetPickPrompt( "" );
		_assignBodyButton.Text = "Assign Body";
	}

	private void OnBodyPicked( string bodyId )
	{
		if ( string.IsNullOrEmpty( bodyId ) || _selectedBone < 0 || _selectedBone >= Skeleton.Count )
			return;

		var boneName = Skeleton.Bones[_selectedBone].Name;

		RigChanging?.Invoke();

		if ( _bodyBoneMap.TryGetValue( bodyId, out var current ) && current == boneName )
			_bodyBoneMap.Remove( bodyId );
		else
			_bodyBoneMap[bodyId] = boneName;

		_viewport.SelectedBodyIds = BodiesOnBone( boneName );

		// Update, not RebuildTree: nothing about which bones exist or how they're nested changed,
		// only a count a couple of rows will paint differently. RebuildTree tears down and
		// re-creates every node, which only re-opens ROOT bones — a full rebuild here would
		// collapse whatever chain you'd drilled into every single time you assigned a body, which
		// is the most repetitive action this tool has.
		_tree.Update();
		RefreshBodyList();
		RigChanged?.Invoke();
	}

	private List<string> BodiesOnBone( string boneName ) =>
		_bodyBoneMap.Where( kv => kv.Value == boneName ).Select( kv => kv.Key ).ToList();

	// --- mirroring -----------------------------------------------------------------------

	/// <summary>
	/// Mirror the selected bone and everything beneath it across Y=0 — this tool's own left/right
	/// axis (EffigyViewport's header comment: "+x forward, +y left, +z up") — grafted onto the
	/// same parent the original hangs from. That default covers the ordinary case, an arm or a leg
	/// mirrored onto the spine bone it already shares, without asking first; nothing stops running
	/// it again on a different selection for a less usual split.
	/// </summary>
	private void MirrorSelectedBone()
	{
		if ( _selectedBone < 0 || _selectedBone >= Skeleton.Count )
			return;

		var parent = Skeleton.Bones[_selectedBone].Parent;

		RigChanging?.Invoke();
		var newRoot = Skeleton.MirrorSubtree( _selectedBone, new Vec3( 0, 1, 0 ), parent );

		RebuildTree();
		_viewport.SelectBone( newRoot );
		OnViewportBoneSelectionChanged( newRoot );
	}

	// --- numeric inspector -----------------------------------------------------------------

	/// <summary>
	/// Head and tail as typeable X/Y/Z, the same "type it rather than eyeball it off a drag"
	/// escape hatch the CAD side's EffigyNumericField exists for — placing a bone is a raycast
	/// against the mesh, which is precise about WHAT it hit and nowhere near precise enough for a
	/// joint that needs to land at, say, exactly X=0 on a spine.
	/// </summary>
	private void BuildInspector()
	{
		_inspector = new Widget( this ) { Layout = Layout.Column(), Visible = false };
		_inspector.Layout.Margin = new Sandbox.UI.Margin( 8, 6 );
		_inspector.Layout.Spacing = 4;

		_inspectorName = new Editor.Label( "" ) { Color = Theme.TextControl };
		_inspector.Layout.Add( _inspectorName );

		var actionRow = new Widget( _inspector ) { Layout = Layout.Row() };
		actionRow.Layout.Spacing = 6;
		actionRow.Layout.Add( _assignBodyButton, 1 );
		actionRow.Layout.Add( _mirrorButton, 1 );
		_inspector.Layout.Add( actionRow );

		var headRow = new Widget( _inspector ) { Layout = Layout.Row() };
		headRow.Layout.Spacing = 4;
		headRow.Layout.Add( new Editor.Label( "Head" ) { FixedWidth = 36 } );
		_headX = AddVectorField( headRow, OnHeadFieldEdited );
		_headY = AddVectorField( headRow, OnHeadFieldEdited );
		_headZ = AddVectorField( headRow, OnHeadFieldEdited );
		_inspector.Layout.Add( headRow );

		var tailRow = new Widget( _inspector ) { Layout = Layout.Row() };
		tailRow.Layout.Spacing = 4;
		tailRow.Layout.Add( new Editor.Label( "Tail" ) { FixedWidth = 36 } );
		_tailX = AddVectorField( tailRow, OnTailFieldEdited );
		_tailY = AddVectorField( tailRow, OnTailFieldEdited );
		_tailZ = AddVectorField( tailRow, OnTailFieldEdited );
		_inspector.Layout.Add( tailRow );

		// A count on the tree row is enough to notice a bone has bodies; fixing a WRONG one from
		// there means re-arming Assign Body and hunting for it in the viewport. Naming each one
		// here, with its own remove button, is the actual undo-a-mistake path.
		_bodyListHeader = new Editor.Label( "" ) { Color = Theme.TextControl.WithAlpha( 0.6f ) };
		_inspector.Layout.Add( _bodyListHeader );

		_bodyList = new Widget( _inspector ) { Layout = Layout.Column() };
		_bodyList.Layout.Spacing = 2;
		_inspector.Layout.Add( _bodyList );
	}

	/// <summary>
	/// The list `RigDiagnostics.Check` has been filling since it was written, finally shown.
	///
	/// WHAT IT IS FOR. A rig leaves this tool through DmxWriter and then through the engine's
	/// compiler, and each of those reports what IT could not do rather than what is wrong — an
	/// unweighted vertex arrives as a vertex that does not move, a bone pinned to a deleted body as
	/// nothing at all. Every one of those is knowable here, while the numbers that caused it are
	/// still to hand and while the controls that fix them are on screen.
	/// </summary>
	private void BuildProblems()
	{
		_problemsHeader = new Editor.Label( "" ) { Color = Theme.TextControl.WithAlpha( 0.6f ), Visible = false };

		_problemsList = new Widget( this ) { Layout = Layout.Column(), Visible = false };
		_problemsList.Layout.Margin = new Sandbox.UI.Margin( 8, 4 );
		_problemsList.Layout.Spacing = 2;
	}

	/// <summary>
	/// Re-run the checks and redraw the list.
	///
	/// Run against whatever there is: the skeleton always, the mesh and the body map when the studio
	/// has built something. `Check` takes every argument as optional for exactly this reason — a
	/// panel reports on what it has rather than waiting for a complete model.
	/// </summary>
	private void RefreshProblems()
	{
		_problemsList.Layout.Clear( true );

		List<RigProblem> problems;

		try
		{
			var mesh = _studio?.Bodies.Count > 0 ? _studio.ToMesh() : null;
			var bodyIds = _studio?.Bodies.Select( b => b.Id ).ToList();

			problems = RigDiagnostics.Check( Skeleton, mesh, _bodyBoneMap, bodyIds );
		}
		catch ( Exception e )
		{
			// A diagnostic that throws must not take the panel with it. The rig is still editable
			// and the checks are the least important thing on screen.
			problems = new List<RigProblem>
			{
				new( RigSeverity.Warning, "The rig checks could not run", e.Message,
					"Carry on - this does not stop the rig from being edited or exported" )
			};
		}

		// NOTHING TO SAY MEANS NOTHING ON SCREEN. A rig with no bones yet reports "this model has no
		// skeleton", which is true and is not news to somebody who has not placed a bone - so the
		// empty-skeleton case is treated as silence rather than as a problem.
		if ( Skeleton.Count == 0 )
			problems.Clear();

		_problemsHeader.Visible = problems.Count > 0;
		_problemsList.Visible = problems.Count > 0;

		if ( problems.Count == 0 )
			return;

		var errors = problems.Count( p => p.Severity == RigSeverity.Error );

		_problemsHeader.Text = errors > 0
			? $"{problems.Count} problem(s), {errors} blocking"
			: $"{problems.Count} warning(s)";
		_problemsHeader.Color = errors > 0 ? Theme.Red : Theme.Yellow;

		foreach ( var problem in problems )
			_problemsList.Layout.Add( ProblemRow( problem ) );
	}

	/// <summary>
	/// One problem, as a row. Clicking it selects the bone it names — which is what `RigProblem.Bone`
	/// has been carrying since it was written, and the difference between a list of complaints and a
	/// way to fix them.
	/// </summary>
	private Widget ProblemRow( RigProblem problem )
	{
		var row = new Widget( _problemsList ) { Layout = Layout.Row() };
		row.Layout.Spacing = 4;

		// COLOURED TEXT, not an icon glyph, because that is what this window already does for a
		// feature's own diagnostic (EffigyFeatureDialog.FillDiagnostic) - and because an icon here
		// would be a Material Icon NAME in a Label, which renders as the literal word.
		var colour = problem.Severity == RigSeverity.Error ? Theme.Red : Theme.Yellow;

		// THE CAUSE AND THE REMEDY GO IN THE TOOLTIP rather than on the row. Three lines per problem
		// would push the bone tree off the panel on a rig with four of them, and the one-line form is
		// what somebody scanning the list is reading anyway.
		var label = new Editor.Label( problem.Problem )
		{
			Color = colour,
			WordWrap = true,
			ToolTip = problem.Bone >= 0 && problem.Bone < Skeleton.Count
				? $"{problem.Cause}\n\n{problem.Remedy}\n\nThe target button selects '{Skeleton.Bones[problem.Bone].Name}'."
				: $"{problem.Cause}\n\n{problem.Remedy}",
		};

		row.Layout.Add( label, 1 );

		if ( problem.Bone >= 0 && problem.Bone < Skeleton.Count )
		{
			row.Layout.Add( new IconButton( "my_location", () => SelectBoneFromProblem( problem.Bone ) )
			{
				IconSize = 16,
				Background = Color.Transparent,
				ToolTip = $"Select '{Skeleton.Bones[problem.Bone].Name}'",
			} );
		}

		return row;
	}

	/// <summary>Select the bone a problem names, in the tree and in the viewport, so the two do not
	/// disagree about what is selected.</summary>
	private void SelectBoneFromProblem( int bone )
	{
		if ( bone < 0 || bone >= Skeleton.Count )
			return;

		_viewport.SelectBone( bone );
		OnViewportBoneSelectionChanged( bone );

		if ( _nodes.TryGetValue( Skeleton.Bones[bone].Name, out var node ) )
			_tree.SelectItem( node );
	}

	private static EffigyNumericField AddVectorField( Widget row, Action<float> edited )
	{
		var field = new EffigyNumericField( row, 0f ) { FixedWidth = 60, ValueEdited = edited };
		row.Layout.Add( field );
		return field;
	}

	/// <summary>Reload all six fields from the skeleton — on a selection change, a rename, or a
	/// live pose drag (OnBonePosed). Never called after a field's own edit: head and tail are
	/// independent, so nothing else needs to move, and re-reading the field just typed into would
	/// fight the cursor and reformat a mid-keystroke expression back to a plain number.</summary>
	private void RefreshInspector()
	{
		if ( _selectedBone < 0 || _selectedBone >= Skeleton.Count )
		{
			_inspector.Visible = false;
			return;
		}

		_editingInspector = true;

		_inspector.Visible = true;
		_inspectorName.Text = Skeleton.Bones[_selectedBone].Name;

		var head = Skeleton.HeadWorld( _selectedBone );
		_headX.SetValue( head.x );
		_headY.SetValue( head.y );
		_headZ.SetValue( head.z );

		var tail = Skeleton.TailWorld( _selectedBone );
		_tailX.SetValue( tail.x );
		_tailY.SetValue( tail.y );
		_tailZ.SetValue( tail.z );

		_editingInspector = false;

		RefreshBodyList();
	}

	/// <summary>Named rows for every body assigned to the selected bone, each with its own remove
	/// button — the count on the tree row says a bone has assignments, this is what lets a wrong
	/// one be found and undone without re-arming Assign Body and hunting in the viewport.</summary>
	private void RefreshBodyList()
	{
		_bodyList.Layout.Clear( true );

		if ( _selectedBone < 0 || _selectedBone >= Skeleton.Count )
		{
			_bodyListHeader.Visible = false;
			return;
		}

		var boneName = Skeleton.Bones[_selectedBone].Name;
		var bodies = BodiesOnBone( boneName );

		_bodyListHeader.Visible = bodies.Count > 0;
		_bodyListHeader.Text = bodies.Count == 0 ? "" : "Assigned bodies";

		foreach ( var bodyId in bodies )
		{
			var row = new Widget( _bodyList ) { Layout = Layout.Row() };
			row.Layout.Spacing = 4;

			var name = _studio?.Bodies.FirstOrDefault( b => b.Id == bodyId )?.Name ?? bodyId;
			row.Layout.Add( new Editor.Label( name ) { Color = Theme.TextLight }, 1 );

			// IconSize 16 matches every other IconButton in this window (the feature dialog's
			// Accept/Cancel, the tree eye's own icon) rather than inventing a smaller one here.
			row.Layout.Add( new IconButton( "close", () => UnassignBody( bodyId ) )
			{
				IconSize = 16,
				Background = Color.Transparent,
				ToolTip = $"Unassign from '{boneName}'",
			} );

			_bodyList.Layout.Add( row );
		}
	}

	private void UnassignBody( string bodyId )
	{
		if ( _selectedBone < 0 || _selectedBone >= Skeleton.Count )
			return;

		RigChanging?.Invoke();
		_bodyBoneMap.Remove( bodyId );

		// Keep the viewport's highlight honest if Assign Body is still armed — otherwise the body
		// just removed stays lit as if it were still assigned.
		if ( _assigningBody )
			_viewport.SelectedBodyIds = BodiesOnBone( Skeleton.Bones[_selectedBone].Name );

		// See OnBodyPicked — only a displayed count changed, not the tree's shape.
		_tree.Update();
		RefreshBodyList();
	}

	private void OnHeadFieldEdited( float _ )
	{
		if ( _editingInspector || _selectedBone < 0 || _selectedBone >= Skeleton.Count )
			return;

		var head = new Vec3( _headX.Value, _headY.Value, _headZ.Value );
		var tail = Skeleton.TailWorld( _selectedBone );

		ApplyHeadTailEdit( head, tail );
	}

	private void OnTailFieldEdited( float _ )
	{
		if ( _editingInspector || _selectedBone < 0 || _selectedBone >= Skeleton.Count )
			return;

		var head = Skeleton.HeadWorld( _selectedBone );
		var tail = new Vec3( _tailX.Value, _tailY.Value, _tailZ.Value );

		ApplyHeadTailEdit( head, tail );
	}

	/// <summary>
	/// SetHeadTail throws on a zero-length bone, which a field mid-edit passes through constantly
	/// — typing "-1.5" one character at a time crosses zero if the tail happens to share that
	/// axis's value. Swallowed the same way EffigyNumericField itself treats an unparseable
	/// string: the model just stops agreeing with the field until the text makes sense again,
	/// rather than an exception reaching the user over a keystroke.
	///
	/// Deliberately does NOT call RefreshInspector after a successful edit: head and tail are
	/// independent (moving one never changes the other's stored value), so the other five fields
	/// have nothing new to show, and re-reading the field just typed into would reformat whatever
	/// expression is mid-keystroke back to a plain number — the same trap BuildFloatRow's own
	/// comment warns about on the CAD side.
	/// </summary>
	private void ApplyHeadTailEdit( Vec3 head, Vec3 tail )
	{
		try
		{
			Skeleton.SetHeadTail( _selectedBone, head, tail );
		}
		catch ( ArgumentException )
		{
			// Momentarily zero-length mid-edit — leave the model alone until the text means
			// something again, same as EffigyNumericField's own "?" readout for unparsed text.
		}
	}

	private void OnBonePosed( int index )
	{
		if ( index == _selectedBone )
			RefreshInspector();
	}

	// --- selection sync (viewport <-> tree) -------------------------------------------------

	/// <summary>Called both from the tree's own selection callback and from the viewport when a
	/// bone is clicked in 3D — either way the two stay in step. SelectBone (called from the tree
	/// side) deliberately does not re-invoke this, so there is no feedback loop even though both
	/// paths land here.</summary>
	private void OnViewportBoneSelectionChanged( int index )
	{
		_selectedBone = index;

		var hasSelection = index >= 0 && index < Skeleton.Count;

		// Assign Body is scoped to whichever bone was selected when it was armed. Without this,
		// switching to a different bone mid-assign left the prompt and the viewport's highlighted
		// bodies both still naming the OLD bone while a click would silently start assigning to
		// the new one instead — the exact kind of thing this session keeps finding and fixing.
		DisarmAssign();

		// Teaches the branch gesture: pick a bone, and the button that would otherwise just say
		// "Add Bone" tells you what clicking it will actually do. Only while the tool itself isn't
		// already armed — SetBoneToolActive owns the text once it is ("Placing…").
		if ( !_viewport.BoneToolActive )
			_addBoneButton.Text = hasSelection ? $"Branch from '{Skeleton.Bones[index].Name}'" : "Add Bone";

		if ( index >= 0 && index < Skeleton.Count && _nodes.TryGetValue( Skeleton.Bones[index].Name, out var node ) )
			_tree.SelectItem( node );

		RefreshInspector();
	}

	// --- rename / delete ---------------------------------------------------------------------

	/// <summary>Rename in place, the same one-field popup every tree in this tool renames with.
	/// Bodies already assigned to the bone follow the rename — BindBodies keys BodyBoneMap by
	/// name, so leaving the old name behind would point those bodies at a bone that no longer
	/// exists and throw at export time.</summary>
	private void BeginRename( int index )
	{
		if ( index < 0 || index >= Skeleton.Count )
			return;

		var menu = new Menu( this );
		var edit = new LineEdit( Skeleton.Bones[index].Name, menu ) { FixedWidth = 190 };

		edit.ReturnPressed += () =>
		{
			var name = edit.Text?.Trim();
			var renamed = false;

			if ( !string.IsNullOrWhiteSpace( name ) )
			{
				var oldName = Skeleton.Bones[index].Name;

				if ( name != oldName )
				{
					try
					{
						RigChanging?.Invoke();
						Skeleton.RenameBone( index, name );

						foreach ( var body in BodiesOnBone( oldName ) )
							_bodyBoneMap[body] = name;

						renamed = true;
					}
					catch ( ArgumentException )
					{
						// Blank or a name already in use — leave the old one rather than crash.
					}
				}
			}

			menu.Close();

			// Blank text, the unchanged name, or a name already taken all leave the bone exactly
			// as it was — nothing for the tree to redraw, and rebuilding it anyway would collapse
			// whatever chain was expanded over a rename that never happened.
			if ( renamed )
			{
				RebuildTree();
				RefreshInspector();
			}
		};

		menu.AddWidget( edit );
		menu.OpenAtCursor();

		edit.Focus();
		edit.SelectAll();
	}

	/// <summary>Delete a bone. Its children re-parent to its own parent (Skeleton.RemoveBone), and
	/// anything that was assigned to it falls back to BindBodies' nearest-bone default rather than
	/// naming a bone that is no longer there.</summary>
	private void DeleteBone( int index )
	{
		if ( index < 0 || index >= Skeleton.Count )
			return;

		var name = Skeleton.Bones[index].Name;

		RigChanging?.Invoke();
		Skeleton.RemoveBone( index );

		foreach ( var body in BodiesOnBone( name ) )
			_bodyBoneMap.Remove( body );

		_viewport.DeselectBone();
		_selectedBone = -1;
		DisarmAssign();

		RebuildTree();
		RefreshInspector();
	}

	private void OpenBoneMenu( int index )
	{
		if ( index < 0 || index >= Skeleton.Count )
			return;

		var menu = new Menu( this );
		menu.AddOption( "Rename", "edit", () => BeginRename( index ) );
		menu.AddSeparator();

		// Deleting reindexes every bone after it in Skeleton.Bones. _chainParent (see
		// OnBonePointPicked) is an index into that same list, captured when the bone tool armed —
		// a delete mid-chain would either reparent the next placed bone onto the wrong bone, or
		// throw outright if the deleted bone was the last one. Renaming carries no such risk: it
		// never moves anything, only the Name string.
		var delete = menu.AddOption( "Delete", "delete", () => DeleteBone( index ) );
		delete.Enabled = !_viewport.BoneToolActive;

		if ( !delete.Enabled )
			delete.StatusTip = "Finish placing bones first — Escape twice, or click Add Bone again.";

		menu.OpenAtCursor();
	}

	// --- tree ---------------------------------------------------------------------------------

	private void RebuildTree()
	{
		_tree.Clear();
		_nodes.Clear();

		if ( Skeleton.Count == 0 )
		{
			_tree.AddItem( new EmptyRigNode() );

			// Fires on the empty path too. Deleting the last bone is every bit as much a change
			// as adding the first, and a listener that only ever hears about the non-empty case
			// would keep believing in a rig that is no longer there.
			RigChanged?.Invoke();
			return;
		}

		for ( var i = 0; i < Skeleton.Count; i++ )
		{
			if ( Skeleton.Bones[i].Parent != -1 )
				continue;

			var node = _tree.AddItem( new BoneNode( this, Skeleton.Bones[i].Name ) );
			_tree.Open( node );
		}

		if ( _selectedBone >= 0 && _selectedBone < Skeleton.Count
			&& _nodes.TryGetValue( Skeleton.Bones[_selectedBone].Name, out var selected ) )
			_tree.SelectItem( selected );

		RigChanged?.Invoke();
	}

	private sealed class BoneNode : TreeNode<string>
	{
		private readonly EffigyRigPanel _panel;

		public BoneNode( EffigyRigPanel panel, string name ) : base( name )
		{
			_panel = panel;
			_panel._nodes[name] = this;
		}

		public override void OnActivated() => _panel.BeginRename( _panel.Skeleton.IndexOf( Value ) );

		public override bool OnContextMenu()
		{
			_panel.OpenBoneMenu( _panel.Skeleton.IndexOf( Value ) );
			return true;
		}

		public override void OnPaint( VirtualWidget item )
		{
			PaintSelection( item );

			var bodyCount = _panel._bodyBoneMap.Values.Count( v => v == Value );

			// Icon size 14 and a 22px left margin for the label — Effigy's own Features and Parts
			// trees both use this pairing (RigControlEditor's separate bone tree uses a smaller
			// 12/20 that has no business bleeding into this window), and this tree sits in docks
			// right next to those two.
			Paint.SetPen( Theme.Blue );
			Paint.DrawIcon( item.Rect, "fiber_manual_record", 14, TextFlag.LeftCenter );

			Paint.SetPen( Theme.Text );
			Paint.DrawText( item.Rect.Shrink( 22, 0, bodyCount > 0 ? 62 : 0, 0 ), Value, TextFlag.LeftCenter );

			if ( bodyCount == 0 )
				return;

			Paint.SetPen( Theme.TextLight.WithAlpha( 0.6f ) );
			Paint.DrawText( item.Rect.Shrink( 0, 0, 8, 0 ), $"{bodyCount} bod{(bodyCount == 1 ? "y" : "ies")}",
				TextFlag.RightCenter );
		}

		protected override void BuildChildren()
		{
			Clear();

			var index = _panel.Skeleton.IndexOf( Value );
			if ( index < 0 )
				return;

			AddItems( _panel.Skeleton.Children( index )
				.Select( c => new BoneNode( _panel, _panel.Skeleton.Bones[c].Name ) ) );
		}
	}

	/// <summary>Shown instead of an empty tree, same reasoning as EffigyPartsPanel's — a blank
	/// panel reads as broken rather than as "nothing here yet".</summary>
	private sealed class EmptyRigNode : TreeNode<string>
	{
		public EmptyRigNode() : base( "No bones yet — click Add Bone" ) { }

		public override void OnPaint( VirtualWidget item )
		{
			// 8px left margin, matching EmptyPartsNode's empty-state row in the same window.
			Paint.SetPen( Theme.TextLight.WithAlpha( 0.6f ) );
			Paint.DrawText( item.Rect.Shrink( 8, 0, 0, 0 ), Value, TextFlag.LeftCenter );
		}
	}
}