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