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