Editor/EffigyEditor/EffigyRigPanel.cs

Editor UI panel for authoring and editing a skeleton rig. Manages bone placement, selection sync with the viewport, renaming, deleting, parenting, mirroring, soft-bone properties, body-to-bone assignments, diagnostics display, and exposes events for undo and window-level controls.

File Access
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;
	private Button _poseButton;
	private Button _resetPoseButton;

	// --- where a click puts the joint ---------------------------------------------------------

	private ComboBox _placementBox;
	private Checkbox _centreSnap;

	/// <summary>Where the two placement settings persist between sessions. EditorCookie is what the
	/// viewport's own grid and palette settings already use — these are tool state, not document
	/// state, so they belong beside those rather than in the .effigy file.</summary>
	private const string PlacementCookie = "Effigy.BonePlacementMiddle";
	private const string CentreSnapCookie = "Effigy.BoneSnapCentre";

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

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

	// --- softness -----------------------------------------------------------------------------

	private Checkbox _softToggle;
	private Widget _softFields;
	private EffigyNumericField _softStiffness, _softDamping, _softWeight, _softCone;

	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;

	/// <summary>
	/// The rig being edited — which is the DOCUMENT'S, not this panel's.
	///
	/// IT USED TO BE THE PANEL'S, and that was the bug behind a whole missing feature. A skeleton
	/// owned by a widget is a skeleton no file format has heard of: StudioDocument.Write takes a
	/// PartStudio, this was not in one, and so placing bones, saving and reopening lost every one
	/// of them without a word. It only surfaced when soft bones needed somewhere to be saved to.
	///
	/// A PROPERTY OVER THE STUDIO rather than a field re-pointed on load, so there is no moment
	/// where the panel is showing one skeleton and the document holds another. The empty fallback
	/// is for the brief window before a studio is handed over; nothing edits through it.
	/// </summary>
	public Skeleton Skeleton => _studio?.Rig ?? _detachedRig;

	private readonly Skeleton _detachedRig = new();

	public bool HasBones => Skeleton.Count > 0;

	/// <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 => Bindings;

	/// <summary>The live map, for this panel's own edits. Same ownership argument as Skeleton
	/// above.</summary>
	private Dictionary<string, string> Bindings => _studio?.BodyBoneMap ?? _detachedBindings;

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

	// --- 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 );

		// Pose and Reset — the pose preview's whole surface. Pose is owned by the window, which has
		// the studio to compute weights from; the panel only reports the clicks, the way it reports
		// every other tool action. The button text tracks the mode so the toggle teaches itself.
		_poseButton = new Button( "Pose", "accessibility_new" )
		{
			ToolTip = "Pose the rig: drag a bone and the mesh deforms with it, so a weight is a "
				+ "crease in the surface instead of a number in a panel. Nothing is saved — Reset "
				+ "Pose returns every bone to the bind pose.",
			Clicked = () => PoseToggled?.Invoke(),
		};
		toolRow.Layout.Add( _poseButton );

		_resetPoseButton = new Button( "Reset Pose", "restart_alt" )
		{
			ToolTip = "Return every bone to the bind pose it was posed from.",
			Clicked = () => PoseReset?.Invoke(),
		};
		toolRow.Layout.Add( _resetPoseButton );

		header.Layout.Add( toolRow );

		header.Layout.Add( BuildPlacementRow( header ) );

		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 = "Pin the selected parts to this bone. If nothing is selected, click bodies "
				+ "in the viewport instead. Optional — unassigned bodies still skin to the nearest bone.",
			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();

		// ADOPTS THE NEW DOCUMENT'S RIG rather than clearing one. This used to empty the skeleton
		// on every load, which was the only sane thing to do while the rig belonged to this panel:
		// the bones on screen described the part that had just been closed. Now the rig arrives
		// with the document, so a loaded part comes back rigged.
		_viewport.RigSkeleton = Skeleton;

		_selectedBone = -1;
		_viewport.DeselectBone();

		// The preview is solving against the skeleton that has just been replaced, and its pose
		// arrays are sized for it.
		_viewport.StopSoftPreview();

		RebuildTree();

		// 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>The Pose button was pressed. The window owns the pose preview — it has the studio
	/// to compute skin weights from — so this reports the click and the window toggles the mode.</summary>
	public Action PoseToggled { get; set; }

	/// <summary>Reset Pose was pressed. The window restores the bind pose and refreshes.</summary>
	public Action PoseReset { 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() ) );

		Bindings.Clear();

		foreach ( var (body, bone) in bodyBoneMap )
			Bindings[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();
	}

	/// <summary>An assignment changed somewhere OTHER than this panel's own Assign Body — the Parts
	/// list's Assign to bone. Refreshes the tree's per-bone counts and the inspector WITHOUT a
	/// RebuildTree, which would collapse every chain this panel had left expanded over a single
	/// right-click on the other side of the window.</summary>
	public void RefreshBodyAssignments()
	{
		_tree.Update();
		RefreshInspector();
		RefreshProblems();
	}

	/// <summary>Keep the Pose and Reset buttons honest against the viewport's mode — the Pose button
	/// labels itself "Posing…" while on, and Reset is dead when there is nothing to reset to.</summary>
	public void RefreshPoseState()
	{
		var posing = _viewport?.PosePreviewActive ?? false;
		_poseButton.Text = posing ? "Posing…" : "Pose";
		_resetPoseButton.Enabled = posing;
	}

	// --- 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 );
	}

	// --- the rig stage bar's handle on all this ------------------------------------------------
	//
	// The three buttons this panel grew up with are also the three tools on the Rig workspace's
	// stage bar, and they are the SAME actions rather than a second copy — the panel still owns the
	// arming, the chain state and the refusals, exactly as it did when it was the only way in.
	// What the bar gets is a way to ask, a way to read back what happened, and a nudge when the
	// panel changed its mind on its own (Escape closing a chain, a selection going away).
	//
	// The buttons stay. A bar tool and a panel button doing one thing is not two controls fighting
	// — it is the same control reachable from where you happen to be looking, and the panel is
	// where you are looking when you have just clicked a bone in the tree.

	/// <summary>Whether the click-to-place tool is armed, for the bar to put a tick on.</summary>
	public bool BoneToolActive => _viewport?.BoneToolActive ?? false;

	/// <summary>Whether body assignment is armed, for the same reason.</summary>
	public bool AssigningBody => _assigningBody;

	/// <summary>Whether there is a bone to assign to or mirror. Both tools are dead without one,
	/// and the bar dims them rather than letting a click do nothing.</summary>
	public bool HasSelectedBone => _selectedBone >= 0 && _selectedBone < Skeleton.Count;

	/// <summary>Index into the skeleton, or -1. The weight brush paints this bone.</summary>
	public int SelectedBoneIndex => HasSelectedBone ? _selectedBone : -1;

	/// <summary>The name of the selected bone, for a prompt that can say which one.</summary>
	public string SelectedBoneName => HasSelectedBone ? Skeleton.Bones[_selectedBone].Name : null;

	/// <summary>
	/// Any of the above changed. Fired whenever this panel arms, disarms or re-selects on its own,
	/// so a tick on the stage bar cannot go stale — which it would, constantly, because the two
	/// most common ways out of both tools (Escape, and clicking a different bone in the tree) never
	/// pass through the bar at all.
	/// </summary>
	public Action ToolStateChanged { get; set; }

	/// <summary>Arm or disarm the bone tool. Same entry the Add Bone button uses.</summary>
	public void ToggleBoneTool() => SetBoneToolActive( !BoneToolActive );

	/// <summary>Arm or disarm body assignment. Same entry the Assign Body button uses.</summary>
	public void ToggleAssignBodyTool() => ToggleAssignBody();

	/// <summary>Mirror the selected bone and its subtree. Same entry the Mirror button uses.</summary>
	public void MirrorSelected() => MirrorSelectedBone();

	/// <summary>Whether the selected bone is soft, for the bar to put a tick on.</summary>
	public bool SelectedBoneIsSoft => HasSelectedBone && Skeleton.Bones[_selectedBone].Soft is not null;

	/// <summary>Make the selected bone soft, or rigid again. Same entry the inspector's tick uses,
	/// and it refreshes that tick on the way through.</summary>
	public void ToggleSelectedSoft() => SetSelectedSoft( !SelectedBoneIsSoft );

	/// <summary>How many bones carry softness. The bar puts it on the Soft stage's tab so the count
	/// there means the same thing the other stages' counts do - how much is behind this tab -
	/// rather than how many buttons it holds.</summary>
	public int SoftBoneCount => SoftSolver.SoftBones( Skeleton ).Count();

	/// <summary>Delete the selected bone. Same entry the tree's own context menu uses — children
	/// re-parent, assignments fall back to the nearest-bone default.</summary>
	public void DeleteSelected()
	{
		if ( HasSelectedBone )
			DeleteBone( _selectedBone );
	}

	/// <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}', {PlacementWhere()}. "
					+ "Escape when done."
				: PlacementPrompt() );
		}
		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";

		ToolStateChanged?.Invoke();

		// 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}";
	}

	/// <summary>
	/// The two answers to "where along the ray does the click land", above the tree because they
	/// change what the NEXT click does rather than describing the bone already selected.
	///
	/// THIS IS THE CONTROL THAT WAS MISSING, not a preference. A click can only ever name a surface,
	/// so every joint this tool placed sat on the skin — a spine down the front of a chest, an
	/// upper arm along the outside of a shoulder — and the only way to reach the middle of the model
	/// was to place it wrong and then type coordinates into the inspector below. Middle measures the
	/// material behind the cursor and puts the joint halfway through it, which is where a joint
	/// belongs, and it is the default for that reason.
	///
	/// The snap sits beside it because they answer the same question on different axes: Middle fixes
	/// the depth the cursor cannot express, and the snap fixes the one number a symmetric rig cannot
	/// afford to have wrong.
	/// </summary>
	private Widget BuildPlacementRow( Widget parent )
	{
		// Restored before the controls read it, or they would open showing the default while the
		// viewport used the remembered value.
		_viewport.BonePlacementMode = EditorCookie.Get( PlacementCookie, true )
			? EffigyViewport.BonePlacement.Middle
			: EffigyViewport.BonePlacement.Surface;

		_viewport.SnapBonesToCentre = EditorCookie.Get( CentreSnapCookie, false );

		var row = new Widget( parent ) { Layout = Layout.Row() };
		row.Layout.Spacing = 6;

		_placementBox = new ComboBox( row )
		{
			ToolTip = "Middle: the joint lands halfway through the material under the cursor, which "
				+ "is where a joint belongs. Surface: on the skin, for an attachment point.",
		};

		_placementBox.AddItem( "Middle", "vertical_align_center",
			() => SetPlacement( EffigyViewport.BonePlacement.Middle ), "",
			_viewport.BonePlacementMode == EffigyViewport.BonePlacement.Middle, true );

		_placementBox.AddItem( "Surface", "layers",
			() => SetPlacement( EffigyViewport.BonePlacement.Surface ), "",
			_viewport.BonePlacementMode == EffigyViewport.BonePlacement.Surface, true );

		row.Layout.Add( _placementBox, 1 );

		_centreSnap = new Checkbox( "Y=0" )
		{
			Value = _viewport.SnapBonesToCentre,
			ToolTip = "Put every placed joint exactly on y = 0 — the plane Mirror reflects across, "
				+ "so a spine placed with this on mirrors cleanly.",
		};

		_centreSnap.Toggled = () =>
		{
			_viewport.SnapBonesToCentre = _centreSnap.Value;
			EditorCookie.Set( CentreSnapCookie, _centreSnap.Value );

			if ( _viewport.BoneToolActive )
				_viewport.SetPickPrompt( PlacementPrompt() );
		};
		row.Layout.Add( _centreSnap );

		return row;
	}

	/// <summary>Not a RigChanging step: this changes what the next click will do, not the rig, so
	/// it must not mark the document dirty or land on the undo stack.</summary>
	private void SetPlacement( EffigyViewport.BonePlacement mode )
	{
		_viewport.BonePlacementMode = mode;
		EditorCookie.Set( PlacementCookie, mode == EffigyViewport.BonePlacement.Middle );

		if ( _viewport.BoneToolActive )
			_viewport.SetPickPrompt( PlacementPrompt() );
	}

	/// <summary>What the status bar says while the tool is armed. Names the mode, because the whole
	/// difference between the two is invisible until a click lands somewhere the cursor was not.
	/// </summary>
	private string PlacementPrompt()
	{
		if ( _chainHead is not null || _chainParent >= 0 )
			return $"Click to extend the chain, {PlacementWhere()}. Escape when done.";

		return $"Click to place a bone {PlacementWhere()}. Click again to extend the chain. "
			+ "Escape when done.";
	}

	/// <summary>Where the next click will land, as a phrase. Shared by every prompt that mentions
	/// it so the two cannot describe the same setting differently.</summary>
	private string PlacementWhere()
	{
		var where = _viewport.BonePlacementMode == EffigyViewport.BonePlacement.Middle
			? "through the middle of the model"
			: "on the surface";

		return _viewport.SnapBonesToCentre ? $"{where} and snapped to y=0" : where;
	}

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

	/// <summary>
	/// Pin parts to the selected bone.
	///
	/// If parts are already selected (Parts list or a click in the viewport), they are pinned
	/// immediately — that is the loop people actually try: select a bone, select a part, press
	/// Assign. Arming a click-to-pick mode instead left Assign looking like it did nothing, and
	/// worse, a viewport click on the part used to drop the bone and grey the button.
	///
	/// With nothing selected, 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;

		if ( TryAssignIdleBodies() )
			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";

		ToolStateChanged?.Invoke();
	}

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

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

		ToolStateChanged?.Invoke();
	}

	/// <summary>Pin every currently selected part to the selected bone. Returns false when there
	/// is nothing selected, so the caller can fall through to click-to-pick.</summary>
	private bool TryAssignIdleBodies()
	{
		var ids = _viewport?.IdleBodyIds;

		if ( ids is null || ids.Count == 0 )
			return false;

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

		foreach ( var id in ids )
		{
			if ( string.IsNullOrEmpty( id ) )
				continue;

			if ( Bindings.TryGetValue( id, out var current ) && current == boneName )
				continue;

			if ( !changed )
			{
				RigChanging?.Invoke();
				changed = true;
			}

			Bindings[id] = boneName;
		}

		if ( !changed )
		{
			_viewport.SetPickPrompt( $"Those parts are already on '{boneName}'." );
			return true;
		}

		_tree.Update();
		RefreshBodyList();
		RigChanged?.Invoke();
		_viewport.SetPickPrompt( $"Pinned to '{boneName}'." );
		ToolStateChanged?.Invoke();
		return true;
	}

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

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

		RigChanging?.Invoke();

		if ( Bindings.TryGetValue( bodyId, out var current ) && current == boneName )
			Bindings.Remove( bodyId );
		else
			Bindings[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 ) =>
		Bindings.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 );

		BuildSoftness();

		// 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 four numbers that make a bone soft, finally reachable.
	///
	/// THE KERNEL HAS HAD THESE ALL ALONG. SoftBone, SoftPose and SoftSolver were written, tested
	/// and shipped to the game assembly; RigDiagnostics has been checking them since it was written
	/// - it will tell you a soft bone has a zero cone - and there has never been anything in the
	/// editor that could put a SoftBone on a bone for it to complain about. SoftSolver even carries
	/// a SoftBones() helper whose summary says it is "for a panel or a diagnostic to list". This is
	/// that panel.
	///
	/// A TICK AND FOUR FIELDS, in the inspector, under head and tail. Softness is a property OF THE
	/// SELECTED BONE in exactly the way its head and tail are, so it belongs in the same place and
	/// appears and disappears with the same selection. The tick is the whole of "is this bone
	/// soft": Bone.Soft is null or it is not, and null is the overwhelming majority - see the note
	/// on the field itself about why it hangs off the bone rather than living in it.
	///
	/// THE FIELDS ARE HIDDEN WHEN THE TICK IS OFF rather than dimmed. Four disabled boxes is the
	/// tallest possible way to say "nothing here", and this panel is already carrying a tree, an
	/// inspector and a problems list in one column.
	/// </summary>
	private void BuildSoftness()
	{
		_softToggle = new Checkbox( "Soft" )
		{
			ToolTip = "Let this bone lag and swing behind the pose instead of following it rigidly. "
				+ "The tail is simulated; the head always stays where its parent put it.",
		};

		_softToggle.Toggled = () => SetSelectedSoft( _softToggle.Value );

		_inspector.Layout.Add( _softToggle );

		_softFields = new Widget( _inspector ) { Layout = Layout.Column(), Visible = false };
		_softFields.Layout.Spacing = 4;
		_softFields.Layout.Margin = new Sandbox.UI.Margin( 14, 2, 0, 2 );

		// The defaults, the units and the "what does this number mean" text are all lifted from
		// SoftBone's own summaries rather than reinvented here. That file is where the reasoning
		// lives, and a second, drifting explanation on a tooltip would be worse than none.
		_softStiffness = AddSoftField( "Stiffness", 0,
			"How hard the bone snaps back to the pose, as an acceleration per unit of offset. Its "
				+ "frequency is sqrt(stiffness) radians a second - 40 is about one swing a second, 400 "
				+ "is a stiff twitch. Zero is a dead limb that only gravity moves." );

		_softDamping = AddSoftField( "Damping", 1,
			"How much of its speed the bone keeps after ONE SECOND, 0 to 1. At 1 a disturbed bone "
				+ "rings forever; at 0 it has no momentum at all. Limbs live low - a few percent - "
				+ "because a second is a long time for a swinging arm." );

		_softWeight = AddSoftField( "Weight", 2,
			"How hard gravity pulls this bone's tail, as a multiple of the solve's gravity. Per bone, "
				+ "because a forearm and a coat tail hang differently." );

		_softCone = AddSoftField( "Cone", 3,
			"The furthest the bone may stray from its animated direction, in degrees. This is the "
				+ "difference between soft and broken: without a limit a fast enough swing puts a "
				+ "forearm through the shoulder it hangs off, and stiffness cannot prevent it - "
				+ "stiffness is a rate, and a rate can be outrun. 180 means unlimited." );

		_inspector.Layout.Add( _softFields );
	}

	/// <summary>One labelled row in the softness block. The index says which of the four it is, so
	/// one edit handler can write the right number without four near-identical lambdas.</summary>
	private EffigyNumericField AddSoftField( string label, int which, string tip )
	{
		var row = new Widget( _softFields ) { Layout = Layout.Row() };
		row.Layout.Spacing = 4;
		row.Layout.Add( new Editor.Label( label ) { FixedWidth = 62, ToolTip = tip } );

		var field = new EffigyNumericField( row, 0f )
		{
			FixedWidth = 74,
			ToolTip = tip,
			ValueEdited = v => OnSoftFieldEdited( which, v ),
		};

		row.Layout.Add( field );
		row.Layout.AddStretchCell();

		_softFields.Layout.Add( row );

		return field;
	}

	/// <summary>
	/// Make the selected bone soft, or rigid again.
	///
	/// TURNING IT OFF THROWS THE NUMBERS AWAY, because Bone.Soft going null IS rigid - there is
	/// nowhere to park them, and a shadow copy kept so a re-tick could restore them would be state
	/// that exists only to go stale. Ticking it back on gives SoftBone's own defaults, which are a
	/// reasonable limb rather than four zeros.
	/// </summary>
	private void SetSelectedSoft( bool soft )
	{
		if ( _editingInspector || !HasSelectedBone )
			return;

		var bone = Skeleton.Bones[_selectedBone];

		if ( (bone.Soft is not null) == soft )
			return;

		RigChanging?.Invoke();

		bone.Soft = soft ? new SoftBone() : null;

		RefreshInspector();

		// The viewport draws soft bones differently and the problems list has opinions about their
		// numbers, so both are now out of date.
		_viewport.Update();
		RefreshProblems();

		ToolStateChanged?.Invoke();
		RigChanged?.Invoke();
	}

	/// <summary>
	/// One of the four numbers was typed into.
	///
	/// NO RigChanging HERE, matching the head and tail fields directly above, which deliberately do
	/// not record undo per keystroke either. Tuning a wobble is a dozen small adjustments in a row
	/// and a snapshot per digit would bury everything else on the stack.
	///
	/// The values are written RAW rather than clamped. RigDiagnostics already reports negative
	/// stiffness, damping outside 0 to 1, a zero cone and a cone over 180, and it reports them in
	/// the problems list a few inches below this field with the bone named. Silently correcting a
	/// number somebody typed is worse: it hides the typo and it fights the cursor.
	/// </summary>
	private void OnSoftFieldEdited( int which, float value )
	{
		if ( _editingInspector || !HasSelectedBone )
			return;

		if ( Skeleton.Bones[_selectedBone].Soft is not { } soft )
			return;

		switch ( which )
		{
			case 0: soft.Stiffness = value; break;
			case 1: soft.Damping = value; break;
			case 2: soft.Weight = value; break;
			case 3: soft.MaxAngle = value; break;
		}

		RefreshProblems();
		RigChanged?.Invoke();
	}

	/// <summary>Push the selected bone's softness into the tick and the four fields.</summary>
	private void RefreshSoftness()
	{
		var soft = HasSelectedBone ? Skeleton.Bones[_selectedBone].Soft : null;

		_softToggle.Value = soft is not null;
		_softFields.Visible = soft is not null;

		if ( soft is null )
			return;

		_softStiffness.SetValue( soft.Stiffness );
		_softDamping.SetValue( soft.Damping );
		_softWeight.SetValue( soft.Weight );
		_softCone.SetValue( soft.MaxAngle );
	}

	/// <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, Bindings, 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 );

		// INSIDE the guard, because Checkbox.Toggled fires on an assignment to Value just as it
		// does on a click - so without it, selecting a rigid bone would call SetSelectedSoft(false)
		// on the bone that was selected a moment ago.
		RefreshSoftness();

		_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();
		Bindings.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();
		RigChanged?.Invoke();
	}

	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();

		// The rig bar's Assign, Mirror and Delete are all scoped to the selection, so they go live
		// and dead with it. Clicking a bone in the tree is the commonest thing anyone does in this
		// panel and it never passes through the bar.
		ToolStateChanged?.Invoke();
	}

	// --- 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 ) )
							Bindings[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 ) )
			Bindings.Remove( body );

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

		RebuildTree();
		RefreshInspector();
	}

	/// <summary>Hang this bone under another, or make it a root. Same world pose, new parent —
	/// so a trigger and a mag follow the root in Marionette without being redrawn.</summary>
	private void SetBoneParent( int index, int newParent )
	{
		if ( index < 0 || index >= Skeleton.Count )
			return;

		if ( newParent < -1 || newParent >= Skeleton.Count || newParent == index )
			return;

		RigChanging?.Invoke();
		var moved = Skeleton.SetParent( index, newParent );
		RebuildTree();
		_viewport.SelectBone( moved );
		OnViewportBoneSelectionChanged( moved );
	}

	/// <summary>Select a bone by name after creating it, so Assign and Paint Weights light up
	/// without a second click that used to do nothing in the viewport.</summary>
	public void SelectBoneNamed( string name )
	{
		var index = string.IsNullOrEmpty( name ) ? -1 : Skeleton.IndexOf( name );

		if ( index < 0 )
			return;

		_viewport.SelectBone( index );
		OnViewportBoneSelectionChanged( index );
	}

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

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

		var parentMenu = menu.AddMenu( "Parent to", "account_tree" );
		var currentParent = Skeleton.Bones[index].Parent;

		var makeRoot = parentMenu.AddOption( "None (root)", "close", () => SetBoneParent( index, -1 ) );
		makeRoot.Enabled = currentParent >= 0;

		if ( Skeleton.Count > 1 )
			parentMenu.AddSeparator();

		for ( var i = 0; i < Skeleton.Count; i++ )
		{
			if ( i == index )
				continue;

			var cycle = false;

			for ( var p = i; p >= 0; p = Skeleton.Bones[p].Parent )
			{
				if ( p != index )
					continue;

				cycle = true;
				break;
			}

			if ( cycle )
				continue;

			var target = i;
			var option = parentMenu.AddOption( Skeleton.Bones[i].Name, null,
				() => SetBoneParent( index, target ) );
			option.Enabled = target != currentParent;
		}

		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.Bindings.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 );
		}
	}
}