Editor/RigControlEditor/RigViewport.cs
using Editor;
using Marionette;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette.Tools;
/// <summary>What dragging a bone does. Held E flips to the other one for as long as it's down,
/// so the common case (rotate) needs no modifier and the occasional one is still one key away.</summary>
internal enum BoneDragMode
{
Rotate,
Move
}
/// <summary>
/// The 3D panel - a skinned model in its own editor scene, with an FK rotate ring per bone
/// (see the Blue/Yellow circles on the Citizen in the reference screenshot) and a move handle
/// on whichever bone is selected.
///
/// Posing goes through MovieBoneAnimatorSystem.SetParentSpaceBone - the same entry point
/// MovieMaker's own bone dragging uses (Session/InverseKinematics.cs) to actually deform a
/// SkinnedModelRenderer's mesh. This was ruled out early on because the type isn't referenceable
/// without an explicit assembly reference - MovieMaker compiles to its own package assembly
/// (package.local.moviemaker.dll), which midnight_am.sbproj now lists under
/// Metadata.Compiler.AssemblyReferences. Without this, "posing" only ever moved a detached
/// GameObject (CreateBoneObjects, or GetBoneObject's returned proxy) that never fed back into
/// the render - confirmed directly: s&box's own docs call CreateBoneObjects legacy, saying it
/// only ever worked as an intermediate step MovieMaker's *own* internal baking read from, never
/// as a live "move this and the mesh follows" control on its own.
/// </summary>
internal sealed class RigViewport : Widget
{
private readonly SceneRenderingWidget _canvas;
private readonly CameraComponent _camera;
private readonly Gizmo.Instance _gizmoInstance;
private GameObject _modelObject;
private SkinnedModelRenderer _renderer;
private float _boneHandleRadius = 1f;
public Action<string> BoneSelected { get; set; }
public string SelectedBone { get; private set; }
/// <summary>The control rig whose constraints apply while posing. Null poses plain FK.</summary>
public RigDocument Rig { get; set; }
/// <summary>What dragging a bone's dot does.
///
/// Rotate is the default because skeletal animation is rotation - joints pivot, they don't
/// slide. Translating a bone stretches the skin and pulls the joint off its parent, which is
/// the single most common way a first pose ends up looking broken. Move is still here because
/// it's genuinely needed for root/IK-target bones, but it's the exception.</summary>
public BoneDragMode DragMode { get; set; } = BoneDragMode.Rotate;
/// <summary>Fired every frame a bone's pose actually changes from dragging - the current
/// frame's keyframe is written on every call rather than once at drag-end, since
/// BoneTrack.SetKeyframe overwriting the same frame repeatedly is harmless and there's no
/// confirmed "drag finished" signal from Gizmo.Control itself to hook instead.</summary>
public Action<string, Transform> BonePosed { get; set; }
/// <summary>Fired once when a bone drag begins, before anything has moved, and once when it
/// ends. Undo hangs off these: BonePosed fires every frame of a drag, so recording there would
/// bury the stack under hundreds of one-pixel entries. One drag is one undo step.</summary>
public Action<string> BoneDragStarted { get; set; }
public Action BoneDragEnded { get; set; }
private bool _suppressAutoKey;
// VIEWMODEL MODE
//
// First-person arms are not posed in world space - in game they hang off the camera, so the
// only framing that tells you whether a pose reads is the one the player actually gets. These
// defaults mirror ViewArmsComponent, which parents the arms to the camera object and sets
// LocalPosition = (0,0,-8): the camera sits at the origin looking forward and the model hangs
// below it. They are the numbers to tune, not laws - the correct offset depends on the FOV and
// the model, which is exactly why they're editable while you watch.
// THE FOUR VIEW MODES SET THEIR OWN CHECKBOXES. Each one writes through a backing field and
// calls RefreshViewToggles, rather than the strip being synced by whoever happened to change
// the value. Modes that alter what you can see and click - MoveWholeModel suppresses every
// bone handle - are only safe if their state is impossible to miss, and a display that some
// call sites remember to update is a display that will eventually lie.
/// <summary>Frames the model as a viewmodel hanging off the camera instead of as a world prop.</summary>
public bool ViewmodelMode
{
get => _viewmodelMode;
set
{
_viewmodelMode = value;
// The camera lock is meaningless with nothing to frame, so it can't outlive the mode
// it belongs to - otherwise the view stays pinned with no visible reason why.
if ( !value )
_lockCameraToView = false;
RefreshViewToggles();
}
}
/// <summary>Pins the camera to the player's eye instead of free-flying it. Only meaningful in
/// viewmodel mode, and it takes the mouse away from camera control - which is the point.</summary>
public bool LockCameraToView
{
get => _lockCameraToView;
set { _lockCameraToView = value; RefreshViewToggles(); }
}
private bool _viewmodelMode;
private bool _lockCameraToView;
private bool _showBoneHandles = true;
private bool _moveWholeModel;
/// <summary>Zero by default. The model places itself - its camera bone says where the eye
/// goes - so nothing needs shifting unless you deliberately want the arms sitting off from
/// where the rig thinks they should.</summary>
public Vector3 ViewmodelOffset { get; set; }
public Angles ViewmodelRotation { get; set; }
public float ViewmodelFov { get; set; } = 90f;
// What ApplyViewmodelFraming last wrote, so it can skip writing an identical value.
private Vector3 _appliedOffset = new( float.NaN, float.NaN, float.NaN );
private Angles _appliedRotation = new( float.NaN, float.NaN, float.NaN );
/// <summary>Hides the per-bone dots. A first-person rig puts a hundred handles between you and
/// the two you're actually moving; this is the way out of that.</summary>
public bool ShowBoneHandles
{
get => _showBoneHandles;
set { _showBoneHandles = value; RefreshViewToggles(); }
}
/// <summary>One gizmo on the model root instead of a handle per bone - for placing the whole
/// model, which in viewmodel mode is how you set where the arms sit relative to the eye.</summary>
public bool MoveWholeModel
{
get => _moveWholeModel;
set { _moveWholeModel = value; RefreshViewToggles(); }
}
public Action ViewmodelChanged { get; set; }
/// <summary>Reveals hidden bones without unhiding them - the way back when you've hidden
/// something you now need.</summary>
public bool ShowHiddenBones { get; set; }
private string _hoveredBone;
public bool IsHidden( string bone ) => Rig is not null && Rig.IsBoneHidden( bone );
/// <summary>
/// Whether twist bones get a handle at all. Off by default.
///
/// TWIST BONES SIT EXACTLY ON THE JOINT THEY TWIST, so their dot lands on top of - or a pixel
/// under - the dot for the bone you actually want. Clicking the shoulder gets you
/// arm_upper_R_twist about half the time, and because a twist bone rotates the mesh in a way
/// that looks vaguely like what you asked for, you don't necessarily notice you're posing the
/// wrong thing until the pose is wrong in a way you can't undo by eye.
///
/// Nothing here is animation data: this only controls whether a handle is drawn, exactly like
/// bone hiding. Twist bones are still driven by EvaluatePose and their keyframes still play.
/// </summary>
public bool ShowTwistBones { get; set; }
/// <summary>
/// Twist and roll helpers, matched by name.
///
/// Name matching rather than skeleton analysis, because the thing that actually identifies a
/// twist bone - that it shares a position with its parent and only ever rotates about one axis
/// - is not reliably detectable from a bind pose, and every rig that has them names them. The
/// list is deliberately short and additive: a bone wrongly classed as a twist is still fully
/// posable, it just needs the checkbox turned on, which is a far cheaper failure than a real
/// twist bone going unrecognised and continuing to steal clicks.
/// </summary>
public static bool IsTwistBone( string bone ) =>
!string.IsNullOrEmpty( bone )
&& (bone.Contains( "twist", StringComparison.OrdinalIgnoreCase )
|| bone.Contains( "roll", StringComparison.OrdinalIgnoreCase )
|| bone.Contains( "helper", StringComparison.OrdinalIgnoreCase ));
/// <summary>Right-click a bone dot to hide it. Hiding a whole chain in one go matters more
/// than it sounds: the useless bones on a rig are almost always a whole subtree - a weapon
/// root and everything parented under it - and hiding thirty of them one at a time is enough
/// friction that nobody bothers.</summary>
protected override void OnContextMenu( ContextMenuEvent e )
{
// RIGHT-DRAG IS THE LOOK-AROUND CONTROL, so right-click in empty space must do nothing.
//
// The menu used to open anywhere in the viewport, falling back to the selected bone when
// the cursor wasn't over one. That made the camera unusable: every attempt to look around
// popped a menu over the thing you were trying to look at.
//
// Requiring the cursor to be ON a dot also makes the menu mean something more specific -
// it acts on what you pointed at, not on whatever happened to be selected somewhere else
// on screen.
if ( _hoveredBone is not { } target )
return;
if ( Rig is null )
{
RigStatusBar.Show( "Hiding bones needs a Control Rig - set one in the BonesObject tab first" );
return;
}
var menu = new Menu( this );
menu.AddHeading( target );
if ( IsHidden( target ) )
{
menu.AddOption( "Unhide", "visibility", () => SetHidden( target, false, false ) );
}
else
{
menu.AddOption( "Hide Bone", "visibility_off", () => SetHidden( target, true, false ) );
menu.AddOption( "Hide Bone And Children", "layers_clear", () => SetHidden( target, true, true ) )
.StatusTip = "Hides this bone and everything parented under it";
}
menu.AddSeparator();
var showHidden = menu.AddOption( "Show Hidden Bones", "visibility", () =>
{
ShowHiddenBones = !ShowHiddenBones;
ViewmodelChanged?.Invoke();
} );
showHidden.Checkable = true;
showHidden.Checked = ShowHiddenBones;
var unhideAll = menu.AddOption( "Unhide All", "restart_alt", () =>
{
Rig.HiddenBones.Clear();
BoneVisibilityChanged?.Invoke();
} );
unhideAll.Enabled = Rig.HiddenBones.Count > 0;
unhideAll.Text = $"Unhide All ({Rig.HiddenBones.Count})";
menu.OpenAtCursor();
}
public Action BoneVisibilityChanged { get; set; }
private void SetHidden( string bone, bool hidden, bool includeChildren )
{
Apply( bone );
if ( includeChildren )
{
foreach ( var child in DescendantsOf( bone ) )
Apply( child );
}
// Hiding the selected bone would otherwise leave its control floating with nothing under
// it - the dot is gone but the gizmo isn't.
if ( hidden && !ShowHiddenBones && SelectedBone is { } selected && IsHidden( selected ) )
Select( null );
BoneVisibilityChanged?.Invoke();
void Apply( string name )
{
if ( hidden )
{
if ( !Rig.HiddenBones.Contains( name ) )
Rig.HiddenBones.Add( name );
return;
}
Rig.HiddenBones.Remove( name );
}
}
/// <summary>Every bone under this one, at any depth.</summary>
private IEnumerable<string> DescendantsOf( string parent )
{
foreach ( var child in ChildBoneNames( parent ).ToList() )
{
yield return child;
foreach ( var deeper in DescendantsOf( child ) )
yield return deeper;
}
}
/// <summary>Toggled by the rig_debug_drag console command. Off by default; this logs once per
/// frame of a drag, which is far too noisy to leave on.</summary>
public static bool DebugDrag { get; private set; }
[ConCmd( "rig_debug_drag" )]
public static void SetDebugDrag( int enabled )
{
DebugDrag = enabled != 0;
Log.Info( $"[rigdrag] drag logging {(DebugDrag ? "ON - drag a bone, then paste the log" : "off")}" );
}
public RigViewport( Widget parent ) : base( parent )
{
MinimumSize = 200;
Layout = Layout.Column();
BuildViewToggleBar();
_canvas = new SceneRenderingWidget( this );
_canvas.OnPreFrame += OnPreFrame;
_canvas.FocusMode = FocusMode.Click;
_canvas.Scene = Scene.CreateEditorScene();
using ( _canvas.Scene.Push() )
{
_camera = new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>( false );
_camera.BackgroundColor = Theme.ControlBackground;
_camera.ZFar = 4096;
_camera.Enabled = true;
var sun = new GameObject( true, "sun" ).GetOrAddComponent<DirectionalLight>( false );
sun.WorldRotation = Rotation.From( 45, 45, 0 );
sun.LightColor = Color.White;
sun.Enabled = true;
var ambient = new GameObject( true, "ambient" ).GetOrAddComponent<AmbientLight>( false );
ambient.Color = Theme.ControlBackground * 0.6f;
ambient.Enabled = true;
_canvas.Camera = _camera;
}
_gizmoInstance = _canvas.GizmoInstance;
Layout.Add( _canvas, 1 );
}
private Checkbox _firstPersonToggle;
private Checkbox _wholeModelToggle;
private Checkbox _showBonesToggle;
private Checkbox _showTwistToggle;
private Checkbox _armShaderToggle;
/// <summary>
/// A permanently visible strip of checkboxes above the viewport.
///
/// This used to be a single "videocam" button opening a menu, on the reasoning that these are
/// set-once-per-session options that shouldn't cost screen space. That reasoning was wrong,
/// and it was wrong in a specific way worth recording: two of these toggles change what is
/// VISIBLE AND CLICKABLE in the viewport. Move Whole Model suppresses every bone handle. With
/// the state hidden inside a menu, the symptom is "all my bones are gone" and the cause is
/// three clicks away - the tool looks broken rather than configured.
///
/// A mode that changes what you can see has to show that it's on, without being asked. The
/// cost is one row of screen space, which is the correct trade.
/// </summary>
private void BuildViewToggleBar()
{
var bar = Layout.AddRow();
bar.Margin = new Sandbox.UI.Margin( 6, 4 );
bar.Spacing = 8;
// ONE TOGGLE, NOT TWO. "Using Arms" gated "Lock Camera" behind it, which meant two clicks
// and a rule to learn for what is really a single question: am I looking through the
// player's eyes or not. Turning this on does both.
_firstPersonToggle = new Checkbox( "First Person View" )
{
ToolTip = "Look through the player's eye at the game's field of view. You can still look around - Reset Camera returns you to looking straight ahead."
};
_firstPersonToggle.Toggled = () => SetViewMode( () =>
{
ViewmodelMode = _firstPersonToggle.Value;
LockCameraToView = _firstPersonToggle.Value;
} );
bar.Add( _firstPersonToggle );
bar.AddSpacingCell( 12 );
_wholeModelToggle = new Checkbox( "Move Whole Model" )
{
ToolTip = "One gizmo on the model root instead of a handle per bone - for placing the arms relative to the eye. Drag to move, hold E to rotate. Hides the bone handles while it's on."
};
_wholeModelToggle.Toggled = () => SetViewMode( () => MoveWholeModel = _wholeModelToggle.Value );
bar.Add( _wholeModelToggle );
_showBonesToggle = new Checkbox( "Show Bone Handles" )
{
ToolTip = "Hide the per-bone dots when the rig is dense enough that they're in the way"
};
_showBonesToggle.Toggled = () => SetViewMode( () => ShowBoneHandles = _showBonesToggle.Value );
bar.Add( _showBonesToggle );
_showTwistToggle = new Checkbox( "Twist Bones" )
{
ToolTip = "Twist bones sit on top of the joint they twist, so their dots overlap the bone you're aiming for. Off by default; they still animate either way."
};
_showTwistToggle.Toggled = () => SetViewMode( () => ShowTwistBones = _showTwistToggle.Value );
bar.Add( _showTwistToggle );
bar.AddSpacingCell( 12 );
_armShaderToggle = new Checkbox( "Arm Shader" )
{
ToolTip = "Draw the model in the first-person arms material. On automatically for viewmodel arms, off for everything else - turn it off if it's painting a model that isn't arms."
};
_armShaderToggle.Toggled = () => SetViewMode( () => PixelStyle = _armShaderToggle.Value );
bar.Add( _armShaderToggle );
bar.AddStretchCell();
// In first person this straightens your head rather than dropping you out of the mode -
// otherwise the only way back from having looked around would be to leave and re-enter,
// which loses the framing you were judging.
bar.Add( new Button( "Reset Camera", "restart_alt" )
{
Clicked = () => SetViewMode( () =>
{
if ( LockCameraToView )
{
_camera.WorldRotation = Rotation.Identity;
return;
}
FrameCamera();
} ),
ToolTip = "First person: look straight ahead again. Otherwise: frame the whole model."
} );
RefreshViewToggles();
}
/// <summary>Applies a view-mode change and tells the window about it. The property setters
/// handle putting the checkboxes right; this is only about the one notification that used to
/// be hand-written after every toggle.</summary>
private void SetViewMode( Action change )
{
change();
ViewmodelChanged?.Invoke();
}
/// <summary>Guards against the refresh that a refresh causes: writing Value fires Toggled on
/// some widget versions, which would write the property, which would refresh again.</summary>
private bool _refreshingToggles;
/// <summary>Pulls every checkbox back into line with the real state. Called from the property
/// setters, so it covers changes that didn't come from clicking the strip - loading a document,
/// Reset Camera, or Using Arms switching the camera lock off underneath you.</summary>
private void RefreshViewToggles()
{
if ( _refreshingToggles || !_firstPersonToggle.IsValid() )
return;
_refreshingToggles = true;
try
{
_firstPersonToggle.Value = _viewmodelMode;
_wholeModelToggle.Value = _moveWholeModel;
_showBonesToggle.Value = _showBoneHandles;
_showTwistToggle.Value = ShowTwistBones;
// Nothing to add twist dots to when there are no dots at all.
_showTwistToggle.Enabled = _showBoneHandles && !_moveWholeModel;
_armShaderToggle.Value = PixelStyle;
// Says why the dots are gone at the moment they go, rather than leaving you to work
// out which of the two toggles did it.
_showBonesToggle.Enabled = !_moveWholeModel;
_showBonesToggle.ToolTip = _moveWholeModel
? "Hidden while Move Whole Model is on - there's one gizmo on the model root instead"
: "Hide the per-bone dots when the rig is dense enough that they're in the way";
}
finally
{
_refreshingToggles = false;
}
}
/// <summary>Puts the model where the game puts it: hanging off a camera at the origin. Applied
/// every frame rather than once, so dragging the root or editing the offset shows immediately.</summary>
/// <summary>
/// Puts the camera back on the eye after free-fly has had its turn, leaving the rotation
/// free-fly gave it.
///
/// The eye is the model's own `camera` bone where it has one - a viewmodel carries that bone
/// precisely to say where the player's head goes, so reading it beats any offset we could
/// invent. Falls back to the model's origin.
/// </summary>
private void PinEyePosition()
{
if ( !LockCameraToView || !_camera.IsValid() )
return;
_camera.WorldPosition = EyePosition();
_camera.FieldOfView = ViewmodelFov;
}
/// <summary>
/// THE EYE COMES FROM THE MODEL'S OWN CAMERA BONE, not from a number.
///
/// Viewmodels carry a bone marking where the player's head goes - first_person_arms_preview
/// has one called "camera", at the model's origin. Reading it means the framing is right for
/// any viewmodel, including ones whose eye isn't at their origin, with nothing to tune.
///
/// This replaced parking the camera at the world origin and shoving the model down 8 units, a
/// figure lifted from ViewArmsComponent - where it means something quite different, because
/// there the arms hang off the camera rather than the camera being placed against the arms.
/// The result was an eye 8 units above the real viewpoint with the arms below the bottom of
/// frame, which reads as the lock doing nothing at all.
/// </summary>
private Vector3 EyePosition()
{
if ( FindBoneData( "camera" ) is { } eye && _renderer.TryGetBoneTransform( eye, out var eyeWorld ) )
return eyeWorld.Position;
return _modelObject.IsValid() ? _modelObject.WorldPosition : Vector3.Zero;
}
private void ApplyViewmodelFraming()
{
if ( !ViewmodelMode )
return;
// ONLY WRITTEN WHEN IT ACTUALLY CHANGES. Assigning the renderer's transform every frame
// re-drives the whole model, which is a plausible way to lose the bone overrides posing
// depends on - and is pointless work regardless, since the value is usually identical to
// what's already there.
// Compared against what WE last applied, rather than against the object's current
// transform - reading a rotation back and comparing it needs an equality test on
// quaternions, and Angles is a plain struct that compares exactly.
if ( _modelObject.IsValid() && (_appliedOffset != ViewmodelOffset || _appliedRotation != ViewmodelRotation) )
{
_modelObject.WorldPosition = ViewmodelOffset;
_modelObject.WorldRotation = ViewmodelRotation.ToRotation();
_appliedOffset = ViewmodelOffset;
_appliedRotation = ViewmodelRotation;
}
// The camera itself is placed in PinEyePosition, which runs AFTER free-fly rather than
// before it - writing the camera here as well would overwrite the rotation the moment you
// tried to look around, and nothing on screen would explain why the mouse did nothing.
}
public void SetModel( Model model )
{
using var scope = _canvas.Scene.Push();
if ( _renderer.IsValid() )
{
_renderer.Enabled = false;
_renderer.Model = null;
}
_modelObject?.Destroy();
_modelObject = null;
_renderer = null;
_pixelMaterial = null;
Select( null );
if ( model is null )
return;
_modelObject = new GameObject( true, "rig" );
_renderer = _modelObject.GetOrAddComponent<SkinnedModelRenderer>( false );
_renderer.Model = model;
_renderer.UseAnimGraph = false;
_renderer.Enabled = true;
_boneHandleRadius = (model.Bounds.Size.Length * 0.012f).Clamp( 0.15f, 3f );
// THE ARM SHADER IS FOR ARMS. It used to default on for every model, which meant loading
// anything else - a Halo grunt, say - drew that model in the viewmodel arms' skin. It
// looked like the tool had corrupted the model.
//
// It exists to work around ONE model: the preview arms resolve to materials/dev/gray_25,
// a placeholder, so without an override they render as a white mannequin that reads as a
// missing material. Every other model already has materials worth showing, so the correct
// default for them is off.
//
// Guessed from the path rather than asked, because being asked on every load is worse than
// being wrong occasionally - and the checkbox in the toolbar makes being wrong a one-click
// problem that you can see.
PixelStyle = LooksLikeViewmodelArms( model );
RefreshViewToggles();
FrameCamera();
}
/// <summary>Whether a model is first-person arms, by resource path. A guess, deliberately -
/// there is no flag on a model saying "I am a viewmodel", and the alternative (inspecting
/// materials for the dev placeholder) is both slower and no more certain.</summary>
private static bool LooksLikeViewmodelArms( Model model ) =>
model?.ResourcePath is { } path
&& (path.Contains( "first_person", StringComparison.OrdinalIgnoreCase )
|| path.Contains( "viewmodel", StringComparison.OrdinalIgnoreCase ));
public SkinnedModelRenderer Renderer => _renderer;
public void Select( string bone )
{
SelectedBone = bone;
// Selecting a bone drops any prop selection, so there is never more than one gizmo on
// screen competing for the same drag. Guarded on null so clearing the bone selection -
// which is what selecting a PROP does - can't immediately clear the prop again.
if ( bone is not null )
SelectedReferenceProp = -1;
BoneSelected?.Invoke( bone );
}
private void FrameCamera()
{
if ( !_renderer.IsValid() || _renderer.Model is null )
return;
// Loading a model while the camera is pinned to the eye must not yank it away - the lock
// is the more specific intent, so it wins.
if ( LockCameraToView )
return;
var bounds = _renderer.Model.Bounds;
var distance = MathX.SphereCameraDistance( MathF.Max( bounds.Size.Length * 0.6f, 32f ), _camera.FieldOfView );
_camera.WorldPosition = bounds.Center + new Vector3( -1, -1, 0.5f ).Normal * distance;
_camera.WorldRotation = Rotation.LookAt( bounds.Center - _camera.WorldPosition, Vector3.Up );
}
/// <summary>Every bone in the model's own skeleton, with its current world transform - read
/// via TryGetBoneTransform, not a GameObject. No GameObject proxy is involved anywhere in this
/// file anymore; posing writes go straight through MovieBoneAnimatorSystem instead.</summary>
private IEnumerable<(BoneCollection.Bone Bone, Transform World)> LiveBones()
{
if ( !_renderer.IsValid() || _renderer.Model?.Bones is not { } bones )
yield break;
foreach ( var bone in bones.AllBones )
{
if ( _renderer.TryGetBoneTransform( bone, out var world ) )
yield return (bone, world);
}
}
public IEnumerable<string> BoneNames() => LiveBones().Select( x => x.Bone.Name );
/// <summary>Bones with no parent in the skeleton itself - the citizen_human rig has more than
/// one of these (pelvis, and a separate root_IK utility chain), so this is a list.</summary>
public IEnumerable<string> RootBoneNames() =>
LiveBones().Where( x => x.Bone.Parent is null ).Select( x => x.Bone.Name );
public IEnumerable<string> ChildBoneNames( string parentName ) =>
LiveBones().Where( x => x.Bone.Parent?.Name == parentName ).Select( x => x.Bone.Name );
private BoneCollection.Bone FindBoneData( string name ) =>
_renderer.IsValid() && _renderer.Model?.Bones is { } bones ? bones.GetBone( name ) : null;
public bool TryGetWorldTransform( string name, out Transform world )
{
world = default;
if ( !_renderer.IsValid() || FindBoneData( name ) is not { } bone )
return false;
return _renderer.TryGetBoneTransform( bone, out world );
}
/// <summary>A bone's current pose in parent space - the form keyframes are stored in. This is
/// what "key this bone where it is right now" needs, independent of any drag.</summary>
public bool TryGetLocalTransform( string name, out Transform local )
{
local = default;
if ( !_renderer.IsValid() || FindBoneData( name ) is not { } bone )
return false;
if ( !_renderer.TryGetBoneTransform( bone, out var world ) )
return false;
local = ParentWorld( bone ).ToLocal( world );
return true;
}
/// <summary>
/// Pose a bone by parent-space value rather than by dragging it - what the inspector's number
/// fields write through.
///
/// Deliberately the same tail as a gizmo drag: clamp to limits, write, carry the descendants,
/// then announce it. Typing 40 into a field and dragging the ring to 40 have to produce the
/// same result, and the only way to be sure of that is for them to run the same code. An
/// earlier plan had this write the keyframe directly and skip the viewport, which would have
/// been a second posing path to keep in step with the first.
/// </summary>
public void SetLocalTransform( string name, Transform local )
{
if ( !_renderer.IsValid() || FindBoneData( name ) is not { } bone )
return;
var world = ApplyLimits( bone, ParentWorld( bone ).ToWorld( local ) );
ApplyWorldTransform( bone, world );
PropagateToDescendants( bone, world );
NotifyPosed( bone, world );
}
/// <summary>Write a bone's new world-space transform.
///
/// SetBoneTransform is the right call and it does work - verified headlessly (rig_test_pose):
/// it registers a bone override that is still in place after a scene tick. What it does NOT do
/// is take effect immediately - reading the bone back in the same frame you wrote it still
/// returns the old value, because overrides are only folded into the pose during the scene's
/// bone update. That one-frame lag is expected here, not a bug: the drag re-reads the bone
/// fresh each frame, so it picks up the previous frame's write.
///
/// SceneModel.SetBoneWorldTransform is the trap to avoid - it's the one that reads back
/// instantly, which makes it look correct, but it sets no override and the very next tick
/// stomps it back to the bind pose.</summary>
private void ApplyWorldTransform( BoneCollection.Bone bone, Transform world )
{
_renderer.SetBoneTransform( bone, world );
}
/// <summary>
/// The bone's pose with no animation on it, read straight off the model.
///
/// This is the "no keyframe" value - what a bone looks like when the clip says nothing about
/// it - and it comes from BoneCollection.Bone.LocalTransform, which is model data and cannot
/// be affected by anything the renderer is currently showing.
///
/// It used to be snapshotted from the live renderer on the first frame after a model loaded,
/// and that was subtly, badly wrong: opening a clip that already HAS keyframes applies them
/// before that first frame, so the snapshot captured the posed arm and called it the rest
/// pose. Undo then restored to a "default" with the forearm already bent, because that WAS
/// the recorded default. Reading the model has no such timing to get wrong.
///
/// BONE.LOCALTRANSFORM IS MODEL-SPACE, NOT PARENT-SPACE, despite the name. It has to be
/// converted, and this is the conversion. Returning it raw exploded the mesh into spikes the
/// moment anything was dragged: every caller feeds this to parentWorld.ToWorld(), so each
/// bone had its entire model-space offset stacked on top of its parent's world transform, and
/// the error multiplied with depth - a shoulder barely moved, the fingers ended up in orbit.
///
/// Verified against the two places the engine reads it, which both convert exactly this way
/// and only call the RESULT a bind pose:
///
/// MovieMaker UpgradeProceduralBoneTracks: parentBindPose.ToLocal( bone.LocalTransform )
/// MovieMaker InverseKinematics: bone.Parent.LocalTransform.ToLocal( bone.LocalTransform )
///
/// The property name was found by reflection-dumping the type, which confirms a name exists
/// and says nothing about what space it's in. That gap is what this cost.
/// </summary>
private static Transform BindPoseFor( BoneCollection.Bone bone ) =>
bone.Parent is { } parent
? parent.LocalTransform.ToLocal( bone.LocalTransform )
: bone.LocalTransform;
/// <summary>A bone's parent's world transform, falling back to the model's own for roots.</summary>
private Transform ParentWorld( BoneCollection.Bone bone ) =>
bone.Parent is { } parent && _renderer.TryGetBoneTransform( parent, out var parentTx )
? parentTx
: _renderer.WorldTransform;
/// <summary>Run a world-space pose through any Limit constraints on that bone. Limits are
/// authored in parent space (that's the only space a joint angle means anything in), so this
/// converts down, clamps, and converts back.</summary>
private Transform ApplyLimits( BoneCollection.Bone bone, Transform world )
{
if ( Rig is null )
return world;
var parentWorld = ParentWorld( bone );
var local = RigConstraintSolver.ClampToLimits( Rig, bone.Name, parentWorld.ToLocal( world ) );
return parentWorld.ToWorld( local );
}
/// <summary>Push a keyed pose onto every bone that has one for this frame, skipping whichever
/// bone is being actively dragged so playback can't fight the hand doing the dragging.
/// _draggingBone (set inside DrawSelectedBoneControls, the only place it's safe to read
/// Gizmo.IsLeftMouseDown) stands in for the raw Gizmo property - EvaluatePose is called from
/// RigControlWindow.OnScrub, outside any active Gizmo context, and Gizmo.IsLeftMouseDown
/// throws a NullReferenceException unconditionally when read from there. That exception fired
/// on every single scrub and every playback tick, silently, the entire time.</summary>
private readonly List<GameObject> _referenceObjects = new();
private List<ReferenceProp> _referenceProps;
/// <summary>
/// Rebuilds the static reference models - the switch, the weapon, whatever the hands are
/// working against.
///
/// Rebuilt wholesale when the list changes and only transform-updated otherwise, because
/// destroying and respawning a model every frame would thrash the scene for no reason. A prop
/// following a bone is re-read every frame, since the bone moves.
/// </summary>
public void SetReferenceProps( List<ReferenceProp> props )
{
_referenceProps = props;
// Deleting a prop must not leave the gizmo pointing at an index that no longer exists, or
// at whatever prop happens to have shifted into that slot.
if ( SelectedReferenceProp >= (props?.Count ?? 0) )
SelectedReferenceProp = -1;
_propDragIndex = -1;
using var scope = _canvas.Scene.Push();
foreach ( var existing in _referenceObjects )
existing?.Destroy();
_referenceObjects.Clear();
if ( props is null )
return;
foreach ( var prop in props )
{
var models = prop?.AllModels.ToList();
if ( models is null || models.Count == 0 )
{
// A placeholder keeps indices lined up with the list, so the transform pass can
// pair them up without re-searching.
_referenceObjects.Add( null );
continue;
}
var go = new GameObject( true, string.IsNullOrWhiteSpace( prop.Name ) ? "reference" : prop.Name );
// ONE PARENT, ONE CHILD PER MODEL, rather than a renderer on the parent itself. The
// parent carries the placement and the children carry the meshes, so a prop made of
// three models is dragged, followed and hidden as one thing - which is the whole
// reason for a prop holding more than one model.
foreach ( var model in models )
{
var part = new GameObject( true, model.ResourceName ?? "part" );
part.Parent = go;
var renderer = part.GetOrAddComponent<ModelRenderer>( false );
renderer.Model = model;
renderer.Enabled = true;
}
_referenceObjects.Add( go );
}
}
/// <summary>Placement, every frame, so dragging a number in the panel moves the prop while
/// you watch rather than on some later refresh.</summary>
private void ApplyReferenceProps()
{
if ( _referenceProps is null )
return;
for ( var i = 0; i < _referenceProps.Count && i < _referenceObjects.Count; i++ )
{
var prop = _referenceProps[i];
var go = _referenceObjects[i];
if ( prop is null || !go.IsValid() )
continue;
go.Enabled = prop.Visible;
if ( !prop.Visible )
continue;
var local = prop.LocalTransform;
// Following a bone puts the prop in the hand and keeps it there while the hand moves,
// which is what you want for a weapon that's already held.
if ( !string.IsNullOrWhiteSpace( prop.FollowBone ) && TryGetWorldTransform( prop.FollowBone, out var boneWorld ) )
{
var followed = boneWorld.ToWorld( local );
go.WorldPosition = followed.Position;
go.WorldRotation = followed.Rotation;
go.WorldScale = followed.Scale;
continue;
}
go.WorldPosition = local.Position;
go.WorldRotation = local.Rotation;
go.WorldScale = local.Scale;
}
}
/// <summary>Which reference prop has the gizmo, as an index into the prop list. -1 for none.
/// An index rather than the object, because the list is rebuilt from the document whenever it
/// changes and holding a stale instance would keep a deleted prop selected.</summary>
public int SelectedReferenceProp { get; private set; } = -1;
/// <summary>Fired every frame a prop is dragged, and once each side of the drag - the same
/// three-signal shape bone dragging uses, so undo can record one step per drag rather than one
/// per frame.</summary>
public Action ReferencePropMoved { get; set; }
public Action ReferencePropDragStarted { get; set; }
public Action ReferencePropDragEnded { get; set; }
private int _propDragIndex = -1;
private Transform _propDragStart;
private Vector3 _propMoveDelta;
/// <summary>
/// Click a prop to select it, then drag its gizmo - move by default, hold E to rotate, the
/// same contract as a bone.
///
/// Props were placeable only by typing numbers into the panel, which is a poor way to answer
/// "is the switch within reach", since that question is about where the hand ends up and is
/// only answerable by looking.
/// </summary>
private void DrawReferenceProps()
{
if ( _referenceProps is null || MoveWholeModel || !ShowBoneHandles )
return;
// The selected prop's control runs AFTER the loop, outside every per-prop scope. Inside
// one, the scope carries the prop's own rotation, so the control's world-aligned basis
// wouldn't be world-aligned and its delta would come back in rotated space to be added to
// a world position - the same mismatch that had bone drags travelling up Z whichever
// arrow was grabbed.
(int Index, ReferenceProp Prop, Transform World)? selected = null;
for ( var i = 0; i < _referenceProps.Count && i < _referenceObjects.Count; i++ )
{
var prop = _referenceProps[i];
var go = _referenceObjects[i];
if ( prop is null || !go.IsValid() || !prop.Visible )
continue;
var isSelected = i == SelectedReferenceProp;
var world = new Transform( go.WorldPosition, go.WorldRotation );
using var scope = Gizmo.Scope( $"RefProp{i}", world );
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = isSelected ? Theme.Yellow : Theme.Green.WithAlpha( 0.7f );
Gizmo.Draw.SolidSphere( 0f, _boneHandleRadius * (isSelected ? 0.6f : 0.45f), 8, 8 );
Gizmo.Draw.IgnoreDepth = false;
if ( isSelected )
{
selected = (i, prop, world);
continue;
}
// THE WHOLE PROP IS THE TARGET, not a dot beside it. Clicking the switch to grab the
// switch is the obvious behaviour, and the dot alone was close to unclickable anyway:
// _boneHandleRadius is derived from the ARMS model's bounds, so on a first-person rig
// it's a fraction of a unit - a handle you have to hunt for.
//
// Same rule as bones about the selected one: no hitbox of ours on it, or it wins the
// hover test against the control's own handles and the drag never starts.
Gizmo.Hitbox.DepthBias = 0.01f;
if ( prop.Model is { } propModel )
Gizmo.Hitbox.BBox( propModel.Bounds.Grow( 0.5f ) );
else
Gizmo.Hitbox.Sphere( new Sphere( 0f, _boneHandleRadius ) );
if ( !Gizmo.IsHovered )
continue;
RigStatusBar.Show( $"{prop.Name} - click to select, then drag to move. Hold E to rotate." );
if ( Gizmo.WasLeftMousePressed )
{
SelectedReferenceProp = i;
// One gizmo on screen at a time - a bone and a prop both showing handles is two
// things claiming the same drag.
Select( null );
}
}
if ( selected is { } sel )
DragReferenceProp( sel.Index, sel.Prop, sel.World );
}
private void DragReferenceProp( int index, ReferenceProp prop, Transform world )
{
var dragging = _propDragIndex == index;
var start = dragging ? _propDragStart : world;
// Positioned at the prop but NOT rotated with it, so the arrows stay world-aligned like the
// scene editor's in global space, and the delta comes back in the space it's applied in.
using var scope = Gizmo.Scope( $"RefPropControl{index}", new Transform( start.Position ) );
Gizmo.Hitbox.DepthBias = 0.01f;
Transform moved;
if ( Editor.Application.IsKeyDown( KeyCode.E ) )
{
if ( !Gizmo.Control.Rotate( "prop-rotate", Rotation.Identity, out var rotation ) )
{
EndPropDragIfReleased();
return;
}
BeginPropDrag( index, world, ref start );
moved = new Transform( start.Position, rotation * start.Rotation, start.Scale );
}
else
{
if ( !Gizmo.Control.Position( "prop-move", Vector3.Zero, out var delta, Rotation.Identity ) )
{
EndPropDragIfReleased();
return;
}
BeginPropDrag( index, world, ref start );
_propMoveDelta += delta;
moved = new Transform( start.Position + _propMoveDelta, start.Rotation, start.Scale );
}
// A prop that follows a bone stores its placement RELATIVE TO THAT BONE - that's what
// makes it stay in the hand. Writing the world transform into it would put it in the hand
// exactly once, then send it flying the moment the hand moved.
if ( !string.IsNullOrWhiteSpace( prop.FollowBone ) && TryGetWorldTransform( prop.FollowBone, out var boneWorld ) )
moved = boneWorld.ToLocal( moved );
prop.Position = moved.Position;
prop.Rotation = moved.Rotation.Angles();
ReferencePropMoved?.Invoke();
}
private void BeginPropDrag( int index, Transform world, ref Transform start )
{
if ( _propDragIndex == index )
return;
ReferencePropDragStarted?.Invoke();
_propDragIndex = index;
_propDragStart = world;
_propMoveDelta = Vector3.Zero;
start = world;
}
/// <summary>Same reasoning as EndDragIfReleased for bones - a control reports false on any
/// frame its value didn't change, including frames where the button is still held.</summary>
private void EndPropDragIfReleased()
{
if ( Gizmo.IsLeftMouseDown || _propDragIndex < 0 )
return;
_propDragIndex = -1;
ReferencePropDragEnded?.Invoke();
}
/// <summary>The last pose lookup this viewport was given, kept so a drag can re-resolve the
/// dragged bone's descendants without the window having to hand it over again.</summary>
private Func<string, Transform?> _poseLookup;
/// <summary>
/// Rewrites every bone under <paramref name="root"/> from its own local pose and the parent's
/// new world transform.
///
/// NEEDED BECAUSE EVERY BONE IS PINNED IN WORLD SPACE. EvaluatePose writes a world-space
/// override for each bone, and a bone with a world override no longer inherits anything from
/// its parent - so rotating a shoulder moved the shoulder and left the arm behind. It worked
/// exactly once, before the children had been pinned, which is what made it look intermittent.
///
/// Parent-space writes would avoid this entirely, but SceneModel.SetParentSpaceBone is
/// internal, so world space is the only way in - and the cost of that is having to carry the
/// hierarchy ourselves, here.
/// </summary>
private void PropagateToDescendants( BoneCollection.Bone root, Transform rootWorld )
{
if ( !_renderer.IsValid() )
return;
var resolved = new Dictionary<string, Transform> { [root.Name] = rootWorld };
foreach ( var (bone, world) in LiveBones() )
{
if ( bone.Parent is not { } parent || !resolved.TryGetValue( parent.Name, out var parentWorld ) )
continue;
// Its own pose is unchanged - only where its parent is has moved. There's always an
// answer now: a keyframe if the clip has one, the model's bind pose if it doesn't.
var local = _poseLookup?.Invoke( bone.Name ) ?? BindPoseFor( bone );
var clamped = RigConstraintSolver.ClampToLimits( Rig, bone.Name, local );
var worldPose = parentWorld.ToWorld( clamped );
resolved[bone.Name] = worldPose;
ApplyWorldTransform( bone, worldPose );
}
}
public void EvaluatePose( Func<string, Transform?> poseForBone )
{
_poseLookup = poseForBone;
_suppressAutoKey = true;
try
{
// EVERY BONE IS DRIVEN EXPLICITLY, from its keyframe if it has one and from the bind
// pose if it doesn't. Nothing is left to whatever the renderer happens to be holding.
//
// Two bugs live here, and the fix for the first caused the second:
//
// Posing a bone leaves an override on the renderer. Originally this loop skipped bones
// with no keyframe, so a bone kept the override from the last time it was dragged even
// after undo removed its keyframes - the document changed and the screen didn't, which
// is why undo looked broken.
//
// Clearing all overrides first fixed that and broke dragging: the clear also wiped the
// bone being dragged, which this loop then deliberately skips, so it snapped to bind
// pose and the drag re-applied it the next frame - flickering between two poses.
// Clearing was too blunt an instrument. Driving each bone to a known value has neither
// hole: nothing is ever undefined, so nothing needs wiping.
// THE HIERARCHY IS RESOLVED HERE, not read back off the renderer.
//
// Keyframes are parent-space and the write API takes world space, so each bone needs
// its parent's world transform to convert. Reading that back from the renderer is
// wrong: bone writes only land on the next tick, so mid-loop the parent still reports
// its PREVIOUS pose. Every child was being placed against a stale parent and the
// error compounded down each chain - which is why undo appeared to mangle the model
// rather than simply restore the wrong pose.
//
// It was survivable while this loop only touched keyframed bones, because a stale
// parent that isn't itself moving converts correctly. Driving every bone made it
// obvious.
//
// So world transforms are accumulated in a local map as the loop walks down: a bone's
// parent is whatever THIS pass computed, not whatever the renderer last drew.
var resolved = new Dictionary<string, Transform>();
foreach ( var (bone, world) in LiveBones() )
{
// The dragged bone keeps its live transform, and must still be resolvable as a
// parent for anything under it.
if ( bone.Name == SelectedBone && _draggingBone )
{
resolved[bone.Name] = world;
continue;
}
// Keyframe if the clip has one, the model's bind pose if it doesn't. Never
// undefined, so nothing is left holding a stale override.
var local = poseForBone( bone.Name ) ?? BindPoseFor( bone );
var clamped = RigConstraintSolver.ClampToLimits( Rig, bone.Name, local );
// Skeletons list parents before children, so the parent is normally already
// resolved. The readback fallback only covers a rig that doesn't, where a stale
// parent is still better than none.
var parentWorld = bone.Parent is { } parent
? (resolved.TryGetValue( parent.Name, out var computed ) ? computed : ParentWorld( bone ))
: _renderer.WorldTransform;
var worldPose = parentWorld.ToWorld( clamped );
resolved[bone.Name] = worldPose;
ApplyWorldTransform( bone, worldPose );
}
}
finally
{
_suppressAutoKey = false;
}
}
/// <summary>SceneRenderingWidget renders its scene but never updates it, so a tool hosting its
/// own editor scene has to tick it. Bone overrides are only folded into the render pose during
/// that tick (rig_test_pose: a write reads back stale in the same frame and correct only after
/// a tick), so without this the model sat frozen and no amount of correct posing could have
/// shown up.
///
/// RealTime.Now/RealTime.Delta, which is what ShaderGraph's Preview passes. This used to run
/// off a hand-rolled Stopwatch - unnecessary, and a second clock to drift out of step with the
/// one the rest of the editor animates against.</summary>
private void TickScene()
{
if ( _canvas.Scene is not { } scene )
return;
scene.EditorTick( RealTime.Now, RealTime.Delta );
}
/// <summary>Wear the game's pixel-arms look instead of whatever the model ships with. The
/// preview arms model resolves to materials/dev/gray_25.vmat - a dev placeholder - which is
/// why it renders as a white mannequin, indistinguishable from a missing material.</summary>
public bool PixelStyle { get; set; } = true;
private Material _pixelMaterial;
private Texture _pixelSkin;
private bool _pixelSkinResolved;
private void ApplyPixelStyle()
{
if ( !_renderer.IsValid() )
return;
if ( !PixelStyle )
{
// Only clear an override we put there.
if ( _pixelMaterial is not null && _renderer.MaterialOverride == _pixelMaterial )
_renderer.MaterialOverride = null;
return;
}
_pixelMaterial ??= PixelArmsStyle.LoadMaterial();
if ( _pixelMaterial is null )
return;
if ( _renderer.MaterialOverride != _pixelMaterial )
_renderer.MaterialOverride = _pixelMaterial;
if ( !_pixelSkinResolved )
{
_pixelSkinResolved = true;
_pixelSkin = PixelArmsStyle.ResolveSkinTexture();
}
// Read the scene object AFTER setting the override - swapping materials rebuilds it, and
// it's null until the renderer has been drawn once anyway.
new PixelArmsStyle
{
ColorTexture = _pixelSkin,
// Both arms: this is a rig editor, not a shot.
HideSide = 0f,
// The vertex snap is in screen pixels, and in a tool window that's this widget.
ScreenSize = _canvas.Size
}.ApplyTo( _renderer.SceneObject );
}
private void OnPreFrame()
{
TickScene();
ApplyPixelStyle();
ApplyViewmodelFraming();
ApplyReferenceProps();
_gizmoInstance.Input.IsHovered = IsActiveWindow && _canvas.IsUnderMouse;
// FREE-FLY RUNS EVEN WHEN LOCKED, and the eye position is re-pinned afterwards.
//
// It used to be skipped outright, which pinned the view rigidly forward - and this model's
// bind pose has the arms hanging at its sides, below and behind the eye, so first person
// showed an empty screen with no way to go and look. Turning the lock off to find them and
// back on to judge them is exactly the back-and-forth this mode exists to remove.
//
// Letting the camera rotate but not travel keeps what the lock is actually for: the eye
// stays where the player's eye is, so distances and framing remain honest, while you can
// still turn your head. Reset Camera returns you to looking straight ahead.
if ( _gizmoInstance.FirstPersonCamera( _camera, _canvas ) )
_gizmoInstance.Input.IsHovered = false;
PinEyePosition();
_canvas.UpdateGizmoInputs( _gizmoInstance.Input.IsHovered );
// The ground grid is a world-space cue and actively misleading on a viewmodel, where
// nothing is standing on anything.
if ( !ViewmodelMode )
Gizmo.Draw.Grid( 0, Gizmo.GridAxis.XY );
DrawBoneHandles();
DrawReferenceProps();
DrawSelectedBoneReadout();
Cursor = Gizmo.HasHovered ? CursorShape.Finger : CursorShape.Arrow;
}
/// <summary>The mesh doesn't visually bend when you pose it (see the class header) - this is
/// the compensating feedback: the selected bone's actual numbers, live, so posing isn't done
/// fully blind.</summary>
private void DrawSelectedBoneReadout()
{
if ( SelectedBone is null )
return;
if ( !TryGetWorldTransform( SelectedBone, out var world ) )
return;
var bone = FindBoneData( SelectedBone );
var parentWorld = bone?.Parent is { } parent && _renderer.TryGetBoneTransform( parent, out var parentTx )
? parentTx
: _renderer.WorldTransform;
var local = parentWorld.ToLocal( world );
// Quiet, and only the numbers that change while posing. The bone name is the one thing
// worth reading at a glance, so it keeps full contrast; the values sit back.
var angles = local.Rotation.Angles();
Gizmo.Draw.Color = Color.White.WithAlpha( 0.85f );
Gizmo.Draw.ScreenText( SelectedBone, new Vector2( 12, 12 ), size: 13, flags: TextFlag.LeftTop );
Gizmo.Draw.Color = Color.White.WithAlpha( 0.4f );
Gizmo.Draw.ScreenText(
$"rot {angles.pitch:0.#} {angles.yaw:0.#} {angles.roll:0.#}\n" +
$"pos {local.Position.x:0.#} {local.Position.y:0.#} {local.Position.z:0.#}",
new Vector2( 12, 30 ), size: 11, flags: TextFlag.LeftTop );
}
private bool _draggingBone;
/// <summary>One dot per bone, click to select, click-and-drag to move it directly - hold E
/// while dragging to rotate in place instead. This replaces an earlier select-then-a-separate-
/// ring-appears-elsewhere design: MovieMaker's own docs describe bone posing as "click and
/// drag from a joint... hold E to rotate", a single unified control per bone, not two.
/// Unselected bones get a plain click-to-select hitbox. The selected bone gets no hitbox of
/// ours at all - only Gizmo.Control, which brings its own. Registering both is what broke
/// dragging for so long; see the comment on the selected branch below.</summary>
private void DrawBoneHandles()
{
// The selected bone's control is run after the loop, in its own top-level scope, so it
// isn't nested inside this bone's rotated drawing scope.
(BoneCollection.Bone Bone, Transform World)? selected = null;
string hovered = null;
// Placing the whole model is its own mode with its own single handle. Bone dots are
// suppressed while it's on, so there's exactly one thing on screen to grab - the whole
// reason this mode exists.
if ( MoveWholeModel )
{
RigStatusBar.Show( Editor.Application.IsKeyDown( KeyCode.E )
? "Whole model - drag to rotate. Let go of E to move instead."
: "Whole model - drag to move. Hold E to rotate instead." );
DragWholeModel();
return;
}
if ( !ShowBoneHandles )
{
// Handles hidden, but the selected bone still gets its control - otherwise turning
// dots off would silently take away the ability to pose.
if ( SelectedBone is { } stillSelected && FindBoneData( stillSelected ) is { } stillBone
&& _renderer.TryGetBoneTransform( stillBone, out var stillWorld ) )
DragSelectedBone( stillBone, stillWorld );
return;
}
// X-ray the skeleton. Most of a rig sits inside the mesh, so depth-tested dots are both
// invisible and unclickable - the hitboxes are already depth-biased to the front, so
// without this the clickable spot and the visible dot disagree.
Gizmo.Draw.IgnoreDepth = true;
foreach ( var (bone, world) in LiveBones() )
{
// Hidden bones are skipped HERE and nowhere else - EvaluatePose still drives them and
// their keyframes still play. This is the only place hiding is allowed to mean
// anything, so it can never cost you animation.
if ( IsHidden( bone.Name ) && !ShowHiddenBones )
continue;
var isSelected = bone.Name == SelectedBone;
var isTwist = IsTwistBone( bone.Name );
// A twist bone that's actually selected keeps its handle regardless - selecting one
// from the bone tree and then finding it has no gizmo would be the same class of
// invisible-mode bug the toggle bar exists to prevent.
if ( isTwist && !ShowTwistBones && !isSelected )
continue;
using var boneScope = Gizmo.Scope( $"Bone{bone.Index}", world );
var radius = _boneHandleRadius;
if ( bone.Parent is { } parentBone && _renderer.TryGetBoneTransform( parentBone, out var parentWorld ) )
{
Gizmo.Draw.Color = Theme.Blue.WithAlpha( 0.8f );
Gizmo.Draw.Line( 0f, world.PointToLocal( parentWorld.Position ) );
}
// Solid dot, not a hollow ring - the reference draws bones as filled white dots.
// The selected one is drawn fatter as well as yellow, so it's still findable in a
// dense area of the rig where a colour change alone is easy to lose.
//
// Twist bones, when shown, are drawn small and dim: they read as a satellite of the
// joint rather than as a peer of it. Size carries this rather than colour alone,
// because the two dots are at the same point - a colour difference between two
// coincident dots of equal size is invisible, since one simply covers the other.
Gizmo.Draw.Color = isSelected ? Theme.Yellow
: Gizmo.IsHovered ? Theme.Green
: isTwist ? Theme.Blue.WithAlpha( 0.55f )
: Color.White;
Gizmo.Draw.SolidSphere( 0f, radius * (isSelected ? 0.5f : isTwist ? 0.16f : 0.35f), 8, 8 );
// No hitbox of our own on the selected bone. Gizmo.Control registers its own hitboxes
// for its handles, and a sphere sitting at the same scope origin - depth-biased in
// front, no less - wins the hover test against them, so the control never sees the
// press and the drag never starts. That was the actual reason dragging did nothing.
if ( isSelected )
{
selected = (bone, world);
continue;
}
Gizmo.Hitbox.DepthBias = 0.01f;
// THE SMALLER HITBOX IS THE ACTUAL FIX, not the smaller dot. Two coincident spheres of
// equal radius make the winner of a click arbitrary; shrinking the twist bone's means
// the joint underneath wins everywhere except dead centre, so the bone you meant to
// grab is the one you get even with both shown.
Gizmo.Hitbox.Sphere( new Sphere( 0f, isTwist ? radius * 0.3f : radius ) );
if ( Gizmo.IsHovered )
{
hovered = bone.Name;
if ( Gizmo.WasLeftMousePressed )
Select( bone.Name );
}
}
// Kept for the right-click menu, which has no gizmo context of its own to hit-test in.
_hoveredBone = hovered;
// Named after the loop so the hint reflects this frame, not last frame's hover.
if ( hovered is not null )
{
RigStatusBar.Show( DragMode == BoneDragMode.Rotate
? $"{hovered} - click to select, then drag to rotate. Hold E to move instead."
: $"{hovered} - click to select, then drag to move. Hold E to rotate instead." );
}
else if ( SelectedBone is not null )
{
RigStatusBar.Show( $"{SelectedBone} selected - drag the gizmo to pose it" );
}
else
{
RigStatusBar.Clear();
}
// Hand the control back normal depth handling so it looks and behaves like the scene
// editor's own move gizmo.
Gizmo.Draw.IgnoreDepth = false;
if ( selected is { } sel )
DragSelectedBone( sel.Bone, sel.World );
DeselectOnEmptyClick( hovered );
}
/// <summary>
/// Click empty space, lose the selection - and with it the gizmo.
///
/// RUN AFTER DragSelectedBone, NOT BEFORE. The selected bone's control registers its own
/// hitboxes inside that call, and a click on one of them only counts as "something is
/// hovered" once it has. Checking first would treat every grab of the rotate ring as a click
/// on nothing and drop the selection the instant you tried to pose it.
///
/// Gizmo.HasHovered covers both kinds of target - our per-bone hitboxes and the control's own
/// handles - so this needs no list of what is clickable.
/// </summary>
private void DeselectOnEmptyClick( string hovered )
{
if ( MoveWholeModel )
return;
if ( SelectedBone is null && SelectedReferenceProp < 0 )
return;
// A drag that happens to finish over empty space is still a drag, not a click on nothing.
if ( _draggingBone || _dragBoneName is not null || _propDragIndex >= 0 )
return;
if ( hovered is not null || Gizmo.HasHovered )
return;
if ( !Gizmo.WasLeftMousePressed )
return;
SelectedReferenceProp = -1;
Select( null );
}
private bool _draggingModel;
private Vector3 _modelDragStart;
private Rotation _modelRotateStart;
private Vector3 _modelMoveDelta;
/// <summary>The whole-model handle. Same contract as bone dragging, and the same as
/// PositionEditorTool: zero in, per-frame delta out, accumulated onto the position captured
/// when the drag began, with the handle basis passed explicitly.</summary>
private void DragWholeModel()
{
if ( !_modelObject.IsValid() )
return;
var start = _draggingModel ? _modelDragStart : _modelObject.WorldPosition;
using var scope = Gizmo.Scope( "ModelRoot", new Transform( start ) );
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = Theme.Green;
Gizmo.Draw.SolidSphere( 0f, _boneHandleRadius * 0.7f, 8, 8 );
Gizmo.Draw.IgnoreDepth = false;
Gizmo.Hitbox.DepthBias = 0.01f;
var startRotation = _draggingModel ? _modelRotateStart : _modelObject.WorldRotation;
// Same hold-to-flip as bones: E borrows the other mode for as long as it's held. Move is
// the default here rather than rotate, because placing the arms relative to the eye is
// what this mode is for - the inverse of a bone, where rotation is the common case.
if ( Editor.Application.IsKeyDown( KeyCode.E ) )
{
// Cumulative since the grab, assigned rather than accumulated - see DragSelectedBone
// for why the two controls differ.
if ( !Gizmo.Control.Rotate( "model-rotate", Rotation.Identity, out var rotation ) )
{
if ( !Gizmo.IsLeftMouseDown )
_draggingModel = false;
return;
}
BeginModelDrag( ref start, ref startRotation );
ApplyModelTransform( start + _modelMoveDelta, rotation * startRotation );
return;
}
if ( !Gizmo.Control.Position( "model-move", Vector3.Zero, out var delta, Rotation.Identity ) )
{
if ( !Gizmo.IsLeftMouseDown )
_draggingModel = false;
return;
}
BeginModelDrag( ref start, ref startRotation );
_modelMoveDelta += delta;
ApplyModelTransform( start + _modelMoveDelta, startRotation );
}
/// <summary>Latches where the model was when the drag began. Both position and rotation are
/// captured together, so releasing E mid-drag can't leave one of them measured from a
/// different starting point than the other.</summary>
private void BeginModelDrag( ref Vector3 start, ref Rotation startRotation )
{
if ( _draggingModel )
return;
_draggingModel = true;
_modelDragStart = _modelObject.WorldPosition;
_modelRotateStart = _modelObject.WorldRotation;
_modelMoveDelta = Vector3.Zero;
start = _modelDragStart;
startRotation = _modelRotateStart;
}
/// <summary>
/// Writes the model's placement to whichever thing actually owns it.
///
/// In viewmodel mode the offset and rotation ARE the authored values - ApplyViewmodelFraming
/// derives the object's transform from them every frame, so writing the object directly would
/// be overwritten before you saw it.
/// </summary>
private void ApplyModelTransform( Vector3 position, Rotation rotation )
{
if ( ViewmodelMode )
{
ViewmodelOffset = position;
ViewmodelRotation = rotation.Angles();
ViewmodelChanged?.Invoke();
return;
}
_modelObject.WorldPosition = position;
_modelObject.WorldRotation = rotation;
}
private string _dragBoneName;
// The pose the drag started from, and the accumulated position delta - the same two pieces of
// state PositionEditorTool keeps (startPoints and moveDelta). Both are reset when a drag
// begins, and the live transform is never fed back into either.
private Transform _dragStart;
private Vector3 _moveDelta;
/// <summary>Latches the drag's starting pose on its first frame. Called only after a control
/// has reported movement, so a hover never counts as a drag.</summary>
private void BeginDrag( BoneCollection.Bone bone, Transform world, ref Transform start )
{
if ( _dragBoneName == bone.Name )
return;
// Announced before the first write lands, so whatever is listening can record the
// pre-drag pose.
BoneDragStarted?.Invoke( bone.Name );
_dragBoneName = bone.Name;
_dragStart = world;
_moveDelta = Vector3.Zero;
start = world;
}
/// <summary>
/// THE DRAG ENDS WHEN THE BUTTON IS RELEASED, not when the control stops reporting.
///
/// Gizmo.Control.Position/Rotate returns false on any frame its value didn't change - which
/// includes frames where the mouse is still held down but hasn't moved. Treating that as the
/// end of the drag was the rubberbanding: the anchor was dropped mid-drag and re-taken from
/// the already-moved position, while the control went on reporting the TOTAL offset since
/// mouse-down, so the very next frame added that whole offset again. Hold still for one frame
/// and the bone jumped; keep moving and it behaved. That is exactly the "inconsistent and
/// rubberbandy" symptom, and it applies to the model-root handle for the same reason.
///
/// Safe to read Gizmo.IsLeftMouseDown here: this runs inside an active Gizmo context. Reading
/// it from outside one - which EvaluatePose used to do - throws.
/// </summary>
private void EndDragIfReleased()
{
if ( Gizmo.IsLeftMouseDown )
return;
EndDrag();
}
private void EndDrag()
{
var was = _dragBoneName;
_dragBoneName = null;
_draggingBone = false;
// Only fire on an actual drag ending - this runs every frame the control isn't active.
if ( was is not null )
BoneDragEnded?.Invoke();
}
/// <summary>Drag the selected bone with a normal editor-style gizmo.
///
/// THE TWO CONTROLS DO NOT AGREE WITH EACH OTHER, which is the thing to know here. Taken from
/// the engine's own shipped tools rather than from the parameter names, which mislead - both
/// out params are named as if they were absolute values and neither one is:
///
/// Position (PositionEditorTool) hands back a PER-FRAME DELTA that you accumulate. You pass
/// Vector3.Zero, not the current position: Control.Position( n, Vector3.Zero, out var
/// delta, basis ) then moveDelta += delta.
///
/// Rotate (RotationEditorTool, and WidgetGallery's RotationTest) hands back the rotation
/// CUMULATIVE SINCE THE GRAB, which you assign rather than accumulate: moveDelta = delta.
///
/// Both are applied to a start pose captured on the drag's first frame, which is what makes
/// the difference survivable: neither one is ever fed back into itself.
///
/// Every sensitivity bug in this function came from getting that wrong. Passing Vector3.Zero
/// for position made the control work against the world origin instead of the bone, so a huge
/// mouse movement produced a tiny one - "can't move it unless I drag crazy far". Treating the
/// rotation delta as cumulative against a frozen anchor threw away all but one frame of it.
/// An earlier version had it the other way round and compounded the whole drag every frame.
///
/// There is no frozen anchor any more: absolute-out feeds from the live value, and delta-out
/// accumulates onto it. That is what the engine does, and it is self-correcting rather than
/// dependent on state we maintain.</summary>
private void DragSelectedBone( BoneCollection.Bone bone, Transform world )
{
var dragging = _dragBoneName == bone.Name;
// The start pose is captured on the first frame of the drag and everything is applied to
// THAT, exactly as PositionEditorTool applies its accumulated delta to startPoints. The
// live transform is never fed back in, so nothing compounds.
var start = dragging ? _dragStart : world;
// Handle basis. Identity means world-aligned arrows, like the scene editor in global
// space - and it is passed EXPLICITLY, because Control.Position takes the basis as a
// fourth argument and leaving it out is what made every axis drag along the same one.
var handleRotation = Rotation.Identity;
using var scope = Gizmo.Scope( $"BoneControl{bone.Index}", new Transform( start.Position ) );
Gizmo.Hitbox.DepthBias = 0.01f;
// E is a hold-to-flip, not a toggle - it borrows the other mode for as long as it's down
// and springs back, so you can nudge a bone's position mid-rotation-pass without losing
// your place.
var rotating = (DragMode == BoneDragMode.Rotate) != Editor.Application.IsKeyDown( KeyCode.E );
Transform newWorld;
if ( rotating )
{
// Rotate's value is CUMULATIVE since the grab - RotationEditorTool assigns it rather
// than accumulating, and applies it to the start rotation. Position's is per-frame.
// The two controls genuinely differ; this is not a typo.
if ( !Gizmo.Control.Rotate( "bone-rotate", Rotation.Identity, out var rotation ) )
{
EndDragIfReleased();
return;
}
BeginDrag( bone, world, ref start );
var basis = handleRotation;
var applied = basis * rotation * basis.Inverse;
newWorld = new Transform( start.Position, applied * start.Rotation, start.Scale );
}
else
{
if ( !Gizmo.Control.Position( "bone-move", Vector3.Zero, out var delta, handleRotation ) )
{
EndDragIfReleased();
return;
}
BeginDrag( bone, world, ref start );
// Per-frame delta, accumulated - "moveDelta += delta" - then applied to the start.
_moveDelta += delta;
newWorld = new Transform( start.Position + _moveDelta, start.Rotation, start.Scale );
}
_draggingBone = true;
// rig_debug_drag 1. Answers the one question staring at the code can't: does the write
// land and stay, or is something else putting the bone back? "wrote" is what this frame
// asked for; "readback" is what the renderer actually had at the START of this frame,
// i.e. the result of last frame's write. If readback tracks wrote one frame behind,
// posing is fine and the problem is elsewhere. If readback never moves, something is
// overwriting it every frame.
if ( DebugDrag )
{
Log.Info( $"[rigdrag] {bone.Name} mode={(rotating ? "rot" : "pos")} " +
$"readback={world.Position} wrote={newWorld.Position} " +
$"viewmodel={ViewmodelMode} locked={LockCameraToView}" );
}
// IK first: if this bone is an enabled IK target, dragging it should bend the chain
// behind it rather than tear the effector off its parent.
if ( RigConstraintSolver.FindIkFor( Rig, bone.Name ) is { } ik
&& RigConstraintSolver.TrySolveTwoBone( _renderer, bone, newWorld.Position, ik.PoleDirection, out var chain ) )
{
var weight = ik.Weight.Clamp( 0f, 1f );
// What this pass actually wrote, keyed by bone. THE WHOLE CHAIN MOVES IN THIS ONE
// FRAME, and bone writes don't land until the next tick - so a chain bone's keyframe
// has to be converted against its parent's NEW pose from this map, not against the
// renderer's readback, which still holds the pre-drag pose. Same reasoning as the
// `resolved` map in EvaluatePose, and the same failure if it's skipped: mid and end are
// keyed relative to a stale root, the error compounds down the chain, and the arm comes
// back mangled the next time the clip is evaluated.
var solved = new Dictionary<string, Transform>();
foreach ( var (chainBone, chainWorld) in chain )
{
var blended = chainWorld;
// Weight blends the solve against where the bone already was, so an IK constraint
// can be dialled in rather than being all-or-nothing.
if ( weight < 1f && _renderer.TryGetBoneTransform( chainBone, out var currentWorld ) )
blended = Transform.Lerp( currentWorld, chainWorld, weight, true );
// Recorded before the notify below reads it - the solver hands the chain back
// root-first, so a bone's parent is already in here by the time it's needed.
solved[chainBone.Name] = blended;
ApplyWorldTransform( chainBone, blended );
// Same reason as the plain drag: each solved bone has to carry whatever hangs off
// it. For an arm IK that's the hand and every finger under the wrist.
PropagateToDescendants( chainBone, blended );
NotifyPosed( chainBone, blended,
chainBone.Parent is { } chainParent && solved.TryGetValue( chainParent.Name, out var solvedParent )
? solvedParent
: null );
}
return;
}
newWorld = ApplyLimits( bone, newWorld );
ApplyWorldTransform( bone, newWorld );
// Everything below this bone has to be carried with it - see PropagateToDescendants.
// Without it, rotating a shoulder rotates only the shoulder.
PropagateToDescendants( bone, newWorld );
NotifyPosed( bone, newWorld );
}
/// <summary>The toolbar's Link icon - off, dragging a bone poses it live without writing a
/// keyframe, same as scrubbing between existing keys does.</summary>
public bool AutoKeyEnabled { get; set; } = true;
/// <summary>
/// Announce a bone's new pose as the parent-space value a keyframe is stored in.
///
/// <paramref name="parentWorld"/> is the parent's transform AS OF THIS FRAME'S WRITES, and any
/// caller that moved the parent in the same frame must pass it. Reading it back off the
/// renderer instead - which is what the fallback does - returns the parent's PREVIOUS pose,
/// because bone writes only fold into the pose on the next tick. That's harmless for a plain FK
/// drag, where only the dragged bone moves and its parent is genuinely still, and wrong for an
/// IK solve, where the whole chain moves at once and each bone would be keyed against a stale
/// parent.
/// </summary>
private void NotifyPosed( BoneCollection.Bone bone, Transform newWorld, Transform? parentWorld = null )
{
if ( _suppressAutoKey || !AutoKeyEnabled )
return;
BonePosed?.Invoke( bone.Name, (parentWorld ?? ParentWorld( bone )).ToLocal( newWorld ) );
}
public override void OnDestroyed()
{
base.OnDestroyed();
_canvas.Scene?.Destroy();
}
}