Editor viewport widget for Marionette rig editing. Displays a skinned model in an editor scene, draws per-bone handles and gizmos, manages viewmodel framing, selectable/movable scene objects and reference props, builds lighting, computes collision meshes for posing, and applies bone transforms through the renderer and rig constraint solver.
using Editor;
using Effigy;
using Marionette;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Marionette.Tools;
/// <summary>What dragging a bone does. Held E flips between rotate and move for as long as it's
/// down, so the common case (rotate) needs no modifier and the occasional one is still one key
/// away. Scale is reached through the toolbar only - it's the rare case, and a bone scale that
/// isn't deliberate is a good way to ruin a rig.</summary>
internal enum BoneDragMode
{
Rotate,
Move,
Scale
}
/// <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; }
/// <summary>The bone dot / hitbox scale the user set, or 1 for the model-derived default.
/// Persisted in an EditorCookie so it survives the window - the right handle size is a property
/// of you and your rig, not of the clip you happen to have open.</summary>
public float BoneHandleScale
{
get => _boneHandleScale;
set
{
_boneHandleScale = value;
EditorCookie.Set( "marionette.bonehandle.scale", value );
}
}
private float _boneHandleScale = 1f;
/// <summary>The per-bone dot and hitbox radius, scaled by the user's handle-size setting.</summary>
private float HandleRadius => _boneHandleRadius * BoneHandleScale;
/// <summary>
/// When posing, stop the dragged bone at the surface of reference props and objects instead of
/// letting it clip through them. On by default; the toolbar's Collide option turns it off.
/// Persisted like the handle size - which is a property of you and your workflow, not of the
/// clip you have open.
/// </summary>
public bool CollideWithProps
{
get => _collideWithProps;
set
{
_collideWithProps = value;
EditorCookie.Set( "marionette.collide", value );
}
}
private bool _collideWithProps = true;
/// <summary>How far the dragged point stays from a surface it runs into - the bone's own handle
/// radius, so a hand-sized handle rests against a prop rather than sinking to its centre line.</summary>
private float CollisionRadius => MathF.Max( HandleRadius, 0.25f );
/// <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 );
// PLAYER CONTROLLER REFERENCE
//
// The default s&box player controller (the walk controller a new project starts with) puts the
// eye 64 units up: BodyHeight 72 minus EyeDistanceFromTop 8, both straight out of the shipped
// "Player Controller" prefab. That height is the one thing a first-person rig editor can't
// derive from the arms model itself - the arms carry a camera bone at their origin, which says
// how the arms sit relative to the eye but nothing about how high the eye is above the ground.
//
// Turning the reference on spawns the controller's body (citizen.vmdl - the same model the
// controller's own SkinnedModelRenderer uses) standing on the ground, draws the eye height,
// and - in first-person mode - anchors the arms to that eye so the viewport frames what the
// player actually sees.
public const string PlayerBodyModelPath = "models/citizen/citizen.vmdl";
/// <summary>Eye height of the default s&box player controller, in world units. BodyHeight 72
/// minus EyeDistanceFromTop 8, straight out of the shipped "Player Controller" prefab.</summary>
public const float PlayerEyeHeight = 64f;
private const string PlayerControllerCookie = "marionette.playercontroller";
private bool _showPlayerController;
private GameObject _playerBodyObject;
/// <summary>Spawns the default player controller's body as a ground reference and anchors the
/// viewmodel arms to its eye. Persisted, like BoneHandleScale, because it's a property of how
/// you work rather than of the clip you have open.</summary>
public bool ShowPlayerController
{
get => _showPlayerController;
set
{
if ( _showPlayerController == value )
return;
_showPlayerController = value;
EditorCookie.Set( PlayerControllerCookie, value );
RefreshViewToggles();
}
}
/// <summary>Where the arms' camera bone goes in viewmodel mode. Zero normally - the arms place
/// themselves - but at the player's eye when the controller reference is on, so the framing is
/// from the real eye height.</summary>
private Vector3 ViewmodelAnchor => ShowPlayerController ? new Vector3( 0f, 0f, PlayerEyeHeight ) : Vector3.Zero;
/// <summary>Build or tear down the controller body. Idempotent, so it runs every frame and only
/// does real work when the reference toggles. Destroyed rather than hidden when off, the same
/// reasoning as Effigy's citizen size reference.</summary>
private void UpdatePlayerReference()
{
if ( !_showPlayerController )
{
if ( _playerBodyObject is not null )
{
_playerBodyObject.Destroy();
_playerBodyObject = null;
}
return;
}
if ( _playerBodyObject.IsValid() )
return;
var model = Model.Load( PlayerBodyModelPath );
if ( model is null || model.IsError )
{
Log.Warning( $"Marionette: player controller body '{PlayerBodyModelPath}' could not be loaded." );
_showPlayerController = false;
RefreshViewToggles();
return;
}
using var scope = _canvas.Scene.Push();
_playerBodyObject = new GameObject( true, "player_controller_reference" );
var renderer = _playerBodyObject.GetOrAddComponent<SkinnedModelRenderer>( false );
renderer.Model = model;
renderer.Enabled = true;
}
/// <summary>Ground-to-eye line and a dot at the eye, so the eye height is visible without
/// reading a number. Drawn in world space - the body is at the origin, the eye 64 units up.</summary>
private void DrawPlayerReference()
{
if ( !ShowPlayerController )
return;
var eye = new Vector3( 0f, 0f, PlayerEyeHeight );
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = Theme.Green.WithAlpha( 0.45f );
Gizmo.Draw.Line( Vector3.Zero, eye );
Gizmo.Draw.Color = Theme.Green;
Gizmo.Draw.SolidSphere( eye, 1.5f, 8, 8 );
Gizmo.Draw.IgnoreDepth = false;
}
/// <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 a selected bone would otherwise leave its control floating with nothing under
// it - the dot is gone but the gizmo isn't. Drop any hidden bones out of the selection.
if ( hidden && !ShowHiddenBones )
{
var removed = _selectedBones.RemoveWhere( IsHidden );
if ( removed > 0 )
{
if ( _selectedBones.Count == 0 )
_primaryBone = null;
else if ( _primaryBone is null || !_selectedBones.Contains( _primaryBone ) )
_primaryBone = _selectedBones.First();
BoneSelected?.Invoke( SelectedBone );
}
}
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 )
{
_boneHandleScale = EditorCookie.Get( "marionette.bonehandle.scale", 1f );
_collideWithProps = EditorCookie.Get( "marionette.collide", true );
_showPlayerController = EditorCookie.Get( PlayerControllerCookie, false );
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;
_canvas.Camera = _camera;
}
// The clip has not been loaded yet, so this puts up the default sun and ambient. A clip
// with lights of its own replaces them the moment the window calls SetLights.
BuildLights();
_gizmoInstance = _canvas.GizmoInstance;
Layout.Add( _canvas, 1 );
}
private Checkbox _firstPersonToggle;
private Checkbox _playerControllerToggle;
private Checkbox _wholeModelToggle;
private Checkbox _showBonesToggle;
private Checkbox _showTwistToggle;
private Checkbox _armShaderToggle;
private Checkbox _collideToggle;
/// <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 );
// The default s&box player controller's body, standing at the origin as a reference, with
// its eye marked 64 units up. The arms carry their own camera bone but nothing that says
// how high the player's head is above the ground - this is that missing number, and the
// body you align props against. In first-person mode it also anchors the arms to that eye.
_playerControllerToggle = new Checkbox( "Player Controller" )
{
ToolTip = "Spawn the default player controller's body at the origin and mark its eye (64 units up). In first-person view, anchor the arms to that eye so what you see is the real in-game view."
};
_playerControllerToggle.Toggled = () => SetViewMode( () => ShowPlayerController = _playerControllerToggle.Value );
bar.Add( _playerControllerToggle );
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 );
// Collision while posing. It changes how a bone behaves when you drag it (stop at a surface
// vs clip through), which is exactly the kind of hidden state this strip exists to make
// visible - so it lives here as a labelled checkbox rather than as an icon in the toolbar.
_collideToggle = new Checkbox( "Collide" )
{
ToolTip = "Stop a posed bone at reference props and objects instead of clipping through them. Turn off for poses that need to reach inside something."
};
_collideToggle.Toggled = () => CollideWithProps = _collideToggle.Value;
bar.Add( _collideToggle );
bar.AddSpacingCell( 12 );
// The bone dot / handle size, in one place. On a dense rig the dots overlap and every
// click grabs the wrong bone, so this is a first-class setting rather than a hidden one.
bar.Add( new Editor.Label( "Handle Size" ) { FixedWidth = 62, ToolTip = "Size of the bone dots and their clickable area" } );
var sizeSlider = new FloatSlider( this )
{
Minimum = 0.25f,
Maximum = 3f,
Step = 0.05f,
Value = BoneHandleScale,
FixedWidth = 110,
ToolTip = "Size of the bone dots and their clickable area. Smaller makes adjacent bones easier to pick apart."
};
sizeSlider.OnValueEdited = () => BoneHandleScale = sizeSlider.Value;
bar.Add( sizeSlider );
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;
_playerControllerToggle.Value = ShowPlayerController;
_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;
_collideToggle.Value = CollideWithProps;
// 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()
{
// The MAIN model's camera bone - a prop that happens to carry one of its own is not where
// the player's eye is.
if ( FindBone( "camera" ) is { Subject: RigTrackName.RootSubject } eye && eye.TryGetWorld( 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 != ViewmodelAnchor + ViewmodelOffset || _appliedRotation != ViewmodelRotation) )
{
_modelObject.WorldPosition = ViewmodelAnchor + ViewmodelOffset;
_modelObject.WorldRotation = ViewmodelRotation.ToRotation();
_appliedOffset = ViewmodelAnchor + 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;
// A rigged Effigy model carries one convex hull per bone. Model Physics turns those into a
// body per bone that follows the skeleton as it is posed - the exact thing Model Collider
// cannot do. MotionEnabled false drives physics FROM the renderer, and Renderer wired is what
// lets a dragged bone pull its body along. Added disabled and configured first, so the
// physics is created with the model and renderer already in place.
var physics = _modelObject.GetOrAddComponent<ModelPhysics>( false );
physics.Model = model;
physics.Renderer = _renderer;
physics.MotionEnabled = false;
physics.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;
/// <summary>
/// One bone, and the object it belongs to.
///
/// EVERY BONE LOOKUP NEEDS A RENDERER NOW. A clip can animate several models at once - the
/// arms, the weapon, the door - and each carries its own skeleton, in which "root" is a
/// perfectly ordinary name to find twice. A BoneCollection.Bone on its own no longer says
/// which model it came from, so it is never passed around alone: this carries the renderer
/// that owns it and the qualified name its track is stored under, together, and every posing
/// path takes one of these instead.
/// </summary>
private sealed class RigBone
{
public string Subject;
public SkinnedModelRenderer Renderer;
public BoneCollection.Bone Bone;
/// <summary>How big this object's handles are - derived from its own bounds, so a magazine
/// doesn't get the dots of the character holding it.</summary>
public float Radius;
/// <summary>The name the clip stores this bone's track under. See RigTrackName.</summary>
public string Key => RigTrackName.Qualify( Subject, Bone.Name );
public string Name => Bone.Name;
public int Index => Bone.Index;
public RigBone Parent => Bone.Parent is { } parent
? new RigBone { Subject = Subject, Renderer = Renderer, Bone = parent, Radius = Radius }
: null;
public bool TryGetWorld( out Transform world )
{
world = default;
return Renderer.IsValid() && Renderer.TryGetBoneTransform( Bone, out world );
}
}
/// <summary>An animated object in this clip: the main model, or anything in the viewport that
/// turned out to carry a skeleton. Named the way its tracks are qualified - empty for the main
/// model, "door" for an object, "door/handle" for one of its parts.</summary>
private readonly record struct RigSubject( string Name, SkinnedModelRenderer Renderer, float Radius );
/// <summary>
/// Every object whose bones this clip can pose.
///
/// The main model first, then everything in the viewport that has a skeleton and is actually
/// drawn - a hidden thing is left out, because its handles would float over a model that is not
/// there.
/// </summary>
private IEnumerable<RigSubject> Subjects()
{
if ( _renderer.IsValid() )
yield return new RigSubject( RigTrackName.RootSubject, _renderer, _boneHandleRadius );
foreach ( var movable in _movables )
{
if ( !movable.Skinned.IsValid() || !movable.Object.IsValid() || !movable.Object.Enabled )
continue;
yield return new RigSubject( movable.Key, movable.Skinned, RadiusFor( movable.Skinned.Model ) );
}
}
/// <summary>Handle size from a model's own bounds - the same rule SetModel uses for the main
/// one, applied per object so a small prop's bones stay grabbable without swamping it.</summary>
private static float RadiusFor( Model model ) =>
model is null ? 1f : (model.Bounds.Size.Length * 0.012f).Clamp( 0.15f, 3f );
/// <summary>Resolve a track name - "hand_R", or "magazine/latch" - to the bone it means.
/// Null when the object isn't loaded or the bone isn't in its skeleton, which is the ordinary
/// answer for a clip opened against a different model.</summary>
private RigBone FindBone( string key )
{
if ( string.IsNullOrEmpty( key ) )
return null;
// THE LONGEST OBJECT NAME THE KEY STARTS WITH WINS, rather than splitting at the first
// slash and hoping. An object's parts are named "door/handle", so a bone inside one is
// "door/handle/spine" - split naively that reads as an object called "door" with a bone
// called "handle/spine", which exists nowhere. Matching against the objects that are
// actually loaded cannot make that mistake, at whatever depth.
//
// It also means a bone whose OWN name contains a slash still resolves: no object claims a
// prefix of it, so it falls through to the main model with its name intact. Rare rather
// than impossible, and a clip that names one must not quietly stop animating.
RigSubject? best = null;
string boneName = null;
foreach ( var candidate in Subjects() )
{
string bone;
if ( candidate.Name.Length == 0 )
{
bone = key;
}
else if ( key.Length > candidate.Name.Length
&& key[candidate.Name.Length] == RigTrackName.Separator
&& key.StartsWith( candidate.Name, StringComparison.Ordinal ) )
{
bone = key[(candidate.Name.Length + 1)..];
}
else
{
continue;
}
if ( candidate.Renderer.Model?.Bones?.GetBone( bone ) is null )
continue;
if ( best is { } current && current.Name.Length >= candidate.Name.Length )
continue;
best = candidate;
boneName = bone;
}
if ( best is not { } subject )
return null;
return new RigBone
{
Subject = subject.Name,
Renderer = subject.Renderer,
Bone = subject.Renderer.Model.Bones.GetBone( boneName ),
Radius = subject.Radius
};
}
/// <summary>Every selected bone. A plain replace leaves just <paramref name="bone"/>; an
/// additive select toggles it in or out, which is how Shift-click builds up a finger-full of
/// bones to pose together.</summary>
private readonly HashSet<string> _selectedBones = new();
/// <summary>The bone the Inspector / Timeline / tutorial read - the most recently touched one
/// still in the selection, or null when nothing is selected.</summary>
private string _primaryBone;
public string SelectedBone => _selectedBones.Count > 0 ? _primaryBone : null;
public IReadOnlyCollection<string> SelectedBones => _selectedBones;
public void Select( string bone, bool additive = false )
{
if ( additive && bone is not null )
{
// Toggle: Shift-clicking a selected bone takes it back out of the set.
if ( !_selectedBones.Remove( bone ) )
_selectedBones.Add( bone );
}
else
{
_selectedBones.Clear();
if ( bone is not null )
_selectedBones.Add( 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 )
_selectedMovable = null;
if ( bone is not null && _selectedBones.Contains( bone ) )
_primaryBone = bone;
if ( _selectedBones.Count == 0 )
_primaryBone = null;
else if ( _primaryBone is null || !_selectedBones.Contains( _primaryBone ) )
_primaryBone = _selectedBones.First();
BoneSelected?.Invoke( SelectedBone );
}
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<(RigBone Bone, Transform World)> LiveBones()
{
foreach ( var subject in Subjects() )
{
if ( subject.Renderer.Model?.Bones is not { } bones )
continue;
foreach ( var bone in bones.AllBones )
{
if ( !subject.Renderer.TryGetBoneTransform( bone, out var world ) )
continue;
yield return (new RigBone
{
Subject = subject.Name,
Renderer = subject.Renderer,
Bone = bone,
Radius = subject.Radius
}, world);
}
}
}
public IEnumerable<string> BoneNames() => LiveBones().Select( x => x.Bone.Key );
/// <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. With several
/// objects loaded it is every object's roots, qualified, which is what makes the bone tree show
/// the weapon's skeleton under the weapon rather than mixed in with the hand's.</summary>
public IEnumerable<string> RootBoneNames() =>
LiveBones().Where( x => x.Bone.Bone.Parent is null ).Select( x => x.Bone.Key );
public IEnumerable<string> ChildBoneNames( string parentName )
{
var (subject, bone) = RigTrackName.Split( parentName );
return LiveBones()
.Where( x => x.Bone.Subject == subject && x.Bone.Bone.Parent?.Name == bone )
.Select( x => x.Bone.Key );
}
public bool TryGetWorldTransform( string name, out Transform world )
{
world = default;
return FindBone( name ) is { } bone && bone.TryGetWorld( 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 ( FindBone( name ) is not { } bone || !bone.TryGetWorld( 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 ( FindBone( 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( RigBone bone, Transform world )
{
bone.Renderer.SetBoneTransform( bone.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( RigBone bone ) =>
bone.Bone.Parent is { } parent
? parent.LocalTransform.ToLocal( bone.Bone.LocalTransform )
: bone.Bone.LocalTransform;
/// <summary>A bone's parent's world transform, falling back to the model's own for roots.</summary>
private static Transform ParentWorld( RigBone bone ) =>
bone.Parent is { } parent && parent.TryGetWorld( out var parentTx )
? parentTx
: bone.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( RigBone bone, Transform world )
{
if ( Rig is null )
return world;
var parentWorld = ParentWorld( bone );
// The qualified name, so a limit can be authored on a prop's bone as readily as on the
// main model's - and so the main model's own limits, which are stored under bare bone
// names, keep matching exactly as they did.
var local = RigConstraintSolver.ClampToLimits( Rig, bone.Key, 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>
/// <summary>
/// One thing in the viewport that is not a bone: an object, one part of an object, or a
/// reference prop.
///
/// THEY ARE ALL THE SAME THING TO POSE. Each is a named transform with a model hanging off it,
/// dragged by a gizmo and keyed to a track under its name. Objects arrived after reference
/// props and the two were briefly separate code paths, which meant every fix to dragging,
/// selection or keying had to be made twice - and was, wrongly, at least once. One list, one
/// draw, one drag.
///
/// The read/write pair is what keeps this honest: a movable does not own its numbers, it knows
/// how to fetch and store them on whatever it came from.
/// </summary>
private sealed class RigMovable
{
/// <summary>The name its track is stored under - "door" for an object, "door/handle" for
/// one of its parts. See RigTrackName.</summary>
public string Key;
/// <summary>What to show a person, without the object's name repeated on every part.</summary>
public string Label;
public GameObject Object;
/// <summary>The part's parent object, or null for something placed in the world. A part is
/// posed INSIDE its object, so its transform is read and written in the object's space.</summary>
public RigMovable Owner;
public Func<Transform> Read;
public Action<Transform> Write;
public Func<bool> Visible;
/// <summary>A bone of the main model this follows, if any. Empty for most things.</summary>
public Func<string> FollowBone = () => "";
/// <summary>Its skeleton, when the model it draws has one - that is what makes the object's
/// own bones poseable alongside the main model's. Null for a plain mesh.</summary>
public SkinnedModelRenderer Skinned;
/// <summary>The model used for the click target. Read from what was actually spawned, so a
/// mesh built from an OBJ is as grabbable as a compiled one.</summary>
public Model Model;
}
private readonly List<RigMovable> _movables = new();
private List<ReferenceProp> _referenceProps;
private List<RigObject> _objects;
/// <summary>
/// One solid surface a dragged bone must not pass through - a reference prop or one part of an
/// object, in the local space of its mesh, placed by a live GameObject. The mesh is static; the
/// GameObject's transform is where it is right now, so a prop dragged elsewhere collides where
/// it is drawn, not where it used to be.
/// </summary>
private sealed class Collider
{
public PolyMesh Mesh;
public MeshBVH Bvh;
public GameObject Object;
}
private readonly List<Collider> _colliders = new();
/// <summary>
/// The folder the open .riganim lives in, so a part that names an OBJ beside it can be found.
///
/// Set by the window on load and on save. Empty for a clip that has never been saved, in which
/// case only absolute paths resolve - see RigObjMeshes.Resolve.
/// </summary>
public string DocumentFolder { get; set; }
public void SetReferenceProps( List<ReferenceProp> props )
{
_referenceProps = props;
RebuildMovables();
}
/// <summary>The view camera, for anything that wants to place something where you are
/// looking from. Zero before the canvas has built its camera.</summary>
public Transform ViewCamera => _camera.IsValid() ? _camera.WorldTransform : Transform.Zero;
/// <summary>The viewport's vertical field of view, so a camera placed "at the camera" can
/// adopt the lens it was framed with.</summary>
public float ViewFov => _camera.IsValid() ? _camera.FieldOfView : 80f;
/// <summary>The clip's lights. See RigAnimDocument.Lights - null or empty puts the default
/// sun and ambient back.</summary>
public void SetLights( List<RigLight> lights )
{
_lights = lights;
BuildLights();
}
/// <summary>The clip's cameras. See RigAnimDocument.Cameras - drawn as frustums, not as
/// scene objects, so they never block or light anything.</summary>
public void SetCameras( List<RigCamera> cameras )
{
_cameras = cameras;
}
private List<RigLight> _lights;
private List<RigCamera> _cameras;
private readonly List<GameObject> _lightObjects = new();
/// <summary>
/// Rebuilds the viewport's lighting from the clip.
///
/// WHOLESALE, AND EVERY TIME. Lights are a handful of objects with no state worth preserving
/// across an edit - unlike the movables, where a rebuild costs a model respawn and loses a
/// drag in progress. Destroying and rebuilding four lights when a colour swatch changes is
/// cheaper than the bookkeeping to update them in place, and it cannot drift out of step
/// with the list.
///
/// AN EMPTY LIST IS THE DEFAULT LIGHTING, NOT DARKNESS. Every clip written before lights
/// existed has one, and opening one has to look exactly as it did before. Only a clip that
/// says something about lighting gets to turn the defaults off - and then it replaces them
/// entirely rather than adding to them, because a key light you cannot see the effect of
/// past a sun you did not ask for is not a key light.
/// </summary>
private void BuildLights()
{
if ( !_canvas.IsValid() || _canvas.Scene is null )
return;
using var scope = _canvas.Scene.Push();
foreach ( var existing in _lightObjects )
existing?.Destroy();
_lightObjects.Clear();
var wanted = _lights?.Where( l => l is not null && l.Enabled ).ToList() ?? new List<RigLight>();
if ( wanted.Count == 0 )
{
DefaultLighting();
return;
}
foreach ( var light in wanted )
Build( light );
}
/// <summary>
/// The lighting this viewport has always had: one white sun over the shoulder and a flat
/// ambient tinted to the editor theme.
///
/// Kept as code rather than as a seeded list on every new clip, because a default that is
/// written into the document is a default nobody can change later - improve it here and
/// every clip that never touched its lighting picks the improvement up. The Lights panel can
/// still copy it into a clip as editable entries, which is the right way to start from it
/// and then adjust; see RigLightsPanel.
/// </summary>
private void DefaultLighting()
{
var sun = new GameObject( true, "sun" );
var directional = sun.GetOrAddComponent<DirectionalLight>( false );
directional.WorldRotation = Rotation.From( 45, 45, 0 );
directional.LightColor = Color.White;
directional.Enabled = true;
_lightObjects.Add( sun );
var ambient = new GameObject( true, "ambient" );
var fill = ambient.GetOrAddComponent<AmbientLight>( false );
fill.Color = Theme.ControlBackground * 0.6f;
fill.Enabled = true;
_lightObjects.Add( ambient );
}
/// <summary>One light from the clip, as the engine component its kind maps to.</summary>
private void Build( RigLight light )
{
var go = new GameObject( true, string.IsNullOrWhiteSpace( light.Name ) ? "light" : light.Name );
_lightObjects.Add( go );
switch ( light.Kind )
{
case RigLightKind.Ambient:
{
var ambient = go.GetOrAddComponent<AmbientLight>( false );
ambient.Color = light.Tint();
ambient.Enabled = true;
return;
}
case RigLightKind.Point:
{
go.WorldPosition = light.Position;
var point = go.GetOrAddComponent<PointLight>( false );
point.LightColor = light.Tint();
point.Radius = light.Range;
point.Shadows = light.Shadows;
point.Enabled = true;
return;
}
case RigLightKind.Spot:
{
go.WorldPosition = light.Position;
go.WorldRotation = light.Rotation.ToRotation();
var spot = go.GetOrAddComponent<SpotLight>( false );
spot.LightColor = light.Tint();
spot.Radius = light.Range;
// Clamped rather than trusted: an outer cone inside the inner one is a spot with
// a hard edge and no core, which reads as the light being broken rather than as
// two numbers being the wrong way round.
spot.ConeInner = MathF.Min( light.ConeInner, light.ConeOuter );
spot.ConeOuter = MathF.Max( light.ConeInner, light.ConeOuter );
spot.Shadows = light.Shadows;
spot.Enabled = true;
return;
}
default:
{
go.WorldRotation = light.Rotation.ToRotation();
var sun = go.GetOrAddComponent<DirectionalLight>( false );
sun.LightColor = light.Tint();
sun.Shadows = light.Shadows;
sun.Enabled = true;
return;
}
}
}
/// <summary>The clip's objects - the things with parts, which are animation rather than
/// scenery. See RigAnimDocument.Objects.</summary>
public void SetObjects( List<RigObject> objects )
{
_objects = objects;
RebuildMovables();
}
/// <summary>
/// Rebuilds every non-bone thing in the viewport from the document.
///
/// Wholesale, when the lists change, and only transform-updated otherwise - destroying and
/// respawning models every frame would thrash the scene for no reason.
/// </summary>
private void RebuildMovables()
{
// A selection is a name, so it survives a rebuild: adding a part must not deselect the one
// you were working on just because the list it lives in was rebuilt under it.
_propDragKey = null;
using var scope = _canvas.Scene.Push();
foreach ( var existing in _movables )
existing.Object?.Destroy();
_movables.Clear();
_colliders.Clear();
BuildObjects();
BuildReferenceProps();
// A name that no longer exists cannot stay selected - the gizmo would be floating over
// something that has been deleted.
if ( _selectedMovable is not null && Find( _selectedMovable ) is null )
_selectedMovable = null;
}
private void BuildObjects()
{
if ( _objects is null )
return;
foreach ( var owner in _objects )
{
if ( owner is null || string.IsNullOrWhiteSpace( owner.Name ) )
continue;
// The object itself is an empty parent carrying the placement; every part is a child
// with its own transform and its own model. That is what makes "move the door" and
// "open the handle" two different drags on the same thing.
var root = new GameObject( true, owner.Name );
var movable = new RigMovable
{
Key = owner.Name,
Label = owner.Name,
Object = root,
Read = () => owner.LocalTransform,
Write = t =>
{
owner.Position = t.Position;
owner.Rotation = t.Rotation.Angles();
owner.Scale = t.Scale.x;
},
Visible = () => owner.Visible,
FollowBone = () => owner.FollowBone,
};
_movables.Add( movable );
if ( owner.Parts is null )
continue;
var partMovables = new Dictionary<RigObjectPart, RigMovable>();
foreach ( var part in owner.Parts )
{
if ( part is null || string.IsNullOrWhiteSpace( part.Name ) )
continue;
var partObject = new GameObject( true, part.Name );
partObject.Parent = root;
var model = ModelForPart( owner, part );
_movables.Add( partMovables[part] = new RigMovable
{
Key = RigTrackName.Qualify( owner.Name, part.Name ),
Label = part.Name,
Object = partObject,
Owner = movable,
Read = () => part.LocalTransform,
Write = t =>
{
part.Position = t.Position;
part.Rotation = t.Rotation.Angles();
part.Scale = t.Scale.x;
},
Visible = () => part.Visible,
Skinned = Draw( partObject, model ),
Model = model,
} );
AddCollider( partObject, CollisionMeshForPart( owner, part ) );
}
// A PART THAT FOLLOWS ANOTHER HANGS UNDER IT - the eyes under the head. Done as a second
// pass because a part can follow one listed after it. Its numbers are already stored
// relative to what it follows, so parenting the objects is all following takes: every
// place that treats Owner as "the space this is posed in" - placing, dragging, keying -
// is now simply the head's space instead of the object's.
foreach ( var (part, follower) in partMovables )
{
if ( owner.ParentOf( part ) is not { } leaderPart || !partMovables.TryGetValue( leaderPart, out var leader ) )
continue;
follower.Object.Parent = leader.Object;
follower.Owner = leader;
}
}
}
private void BuildReferenceProps()
{
if ( _referenceProps is null )
return;
foreach ( var prop in _referenceProps )
{
if ( prop is null )
continue;
var models = prop.AllModels.ToList();
if ( models.Count == 0 )
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.
SkinnedModelRenderer skinned = null;
foreach ( var model in models )
{
var part = new GameObject( true, model.ResourceName ?? "part" );
part.Parent = go;
skinned ??= Draw( part, model );
AddCollider( part, CollisionMeshFromModel( model ) );
}
_movables.Add( new RigMovable
{
Key = prop.Name,
Label = prop.Name,
Object = go,
Read = () => prop.LocalTransform,
Write = t =>
{
prop.Position = t.Position;
prop.Rotation = t.Rotation.Angles();
prop.Scale = t.Scale.x;
},
Visible = () => prop.Visible,
FollowBone = () => prop.FollowBone,
Skinned = skinned,
Model = models[0],
} );
}
}
/// <summary>Register one solid surface a dragged bone must stop at. The mesh stays in its own
/// local space; the GameObject carries where it is now, and a hidden prop's object is disabled
/// so it stops colliding the moment it is got out of the way.</summary>
private void AddCollider( GameObject go, PolyMesh mesh )
{
if ( mesh is null || mesh.FaceCount == 0 || !go.IsValid() )
return;
_colliders.Add( new Collider
{
Mesh = mesh,
Bvh = MeshBVH.Build( mesh ),
Object = go
} );
}
/// <summary>
/// A compiled model's collision mesh: its physics triangle meshes when it ships any, otherwise
/// its bounding box.
///
/// THE SAME GEOMETRY THE GAME COLLIDES WITH where possible - a prop's physics shapes are exactly
/// what a character in game can and cannot pass through, so posing against them keeps the
/// viewport honest. Hulls are deliberately skipped: they carry edges, not triangles. A model with
/// no physics at all falls back to its bounding box, so a hull-less prop still stops a hand
/// instead of letting it sink through - coarse, but never nothing.
/// </summary>
private static PolyMesh CollisionMeshFromModel( Model model )
{
if ( model is null )
return null;
var physics = PhysicsMeshFromModel( model );
return physics ?? BoundsBox( model );
}
/// <summary>The model's physics triangle meshes as one PolyMesh, or null when it ships none.</summary>
private static PolyMesh PhysicsMeshFromModel( Model model )
{
if ( model?.Physics?.Parts is not { } parts )
return null;
var mesh = new PolyMesh();
foreach ( var part in parts )
{
if ( part?.Meshes is null )
continue;
var partTransform = part.Transform;
foreach ( var physicsMesh in part.Meshes )
{
var vertices = physicsMesh.GetVertices();
var indices = physicsMesh.GetIndices();
if ( vertices is null || indices is null || indices.Length < 3 )
continue;
var first = mesh.VertexCount;
foreach ( var v in vertices )
{
var p = partTransform.PointToWorld( v );
mesh.AddVertex( new Vec3( p.x, p.y, p.z ) );
}
for ( var i = 0; i + 2 < indices.Length; i += 3 )
mesh.AddFace( new[] { first + indices[i], first + indices[i + 1], first + indices[i + 2] } );
}
}
return mesh.FaceCount > 0 ? mesh : null;
}
/// <summary>A box around the model's bounds, in the model's own space, for a prop with no physics.</summary>
private static PolyMesh BoundsBox( Model model )
{
var size = model.Bounds.Size;
if ( size.x <= 0f || size.y <= 0f || size.z <= 0f )
return null;
var center = model.Bounds.Center;
return MeshTransform.Transformed(
Primitives.Box( size.x, size.y, size.z ),
Xform.Translate( new Vec3( center.x, center.y, center.z ) ) );
}
/// <summary>The collision mesh for one part of an object: its own compiled model if it has one,
/// otherwise the lump of the object's OBJ it draws.</summary>
private PolyMesh CollisionMeshForPart( RigObject owner, RigObjectPart part )
{
if ( part.Model is not null )
return CollisionMeshFromModel( part.Model );
if ( string.IsNullOrWhiteSpace( owner.ObjSource ) )
return null;
var path = RigObjMeshes.Resolve( owner.ObjSource, DocumentFolder );
if ( path is null )
return null;
foreach ( var piece in RigObjMeshes.Pieces( path ) )
{
if ( !string.IsNullOrEmpty( part.ObjPart ) && piece.Name != part.ObjPart )
continue;
return piece.Mesh;
}
return null;
}
/// <summary>
/// Stop the dragged point at the nearest prop/object surface it would cross, rather than
/// letting it clip through.
///
/// The segment is cast from where the drag grabbed to where it asks to go now, and the answer
/// is the closest entry point pushed out along that face's normal by the bone's radius - so a
/// hand slides across a desk rather than stopping short of it. Casting from the grab point
/// rather than from last frame means a quick drag through a prop still lands on the near side
/// instead of tunnelling out the far one.
/// </summary>
private Vector3 ClampToProps( Vector3 from, Vector3 to )
{
if ( !CollideWithProps || _colliders.Count == 0 )
return to;
var delta = to - from;
var length = delta.Length;
if ( length < 0.0001f )
return to;
var bestT = float.MaxValue;
var bestPoint = to;
foreach ( var collider in _colliders )
{
// Active, not Enabled - a hidden prop disables its PARENT object, so a child mesh's own
// flag still reads true while the prop is got out of the way.
if ( collider.Object is not { } go || !go.IsValid() || !go.Active )
continue;
var world = go.WorldTransform;
var localFrom = world.PointToLocal( from );
var localTo = world.PointToLocal( to );
var localDir = localTo - localFrom;
var localLength = localDir.Length;
if ( localLength < 0.0001f )
continue;
var hit = collider.Bvh.Raycast(
collider.Mesh,
new Vec3( localFrom.x, localFrom.y, localFrom.z ),
new Vec3( localDir.x / localLength, localDir.y / localLength, localDir.z / localLength ) );
if ( hit is not { } h || h.Distance > localLength )
continue;
var t = h.Distance / localLength;
if ( t >= bestT )
continue;
var worldPoint = world.PointToWorld( new Vector3( h.Point.x, h.Point.y, h.Point.z ) );
var normal = (world.Rotation * new Vector3( h.Normal.x, h.Normal.y, h.Normal.z )).Normal;
bestT = t;
bestPoint = worldPoint + normal * CollisionRadius;
}
return bestT <= 1f ? bestPoint : to;
}
/// <summary>
/// The world position of the deepest descendant of <paramref name="bone"/> under the given bone
/// world transform - the point that sweeps the largest arc when the bone rotates, and therefore
/// the one that reaches a prop first.
///
/// Computed from local poses the same way <see cref="PropagateToDescendants"/> poses them, so it
/// agrees with what a drag actually writes rather than with the renderer's one-frame-stale
/// readback.
/// </summary>
private Vector3 DescendantTip( RigBone bone, Transform boneWorld )
{
var resolved = new Dictionary<string, Transform> { [bone.Key] = boneWorld };
var tip = boneWorld.Position;
var tipDistance = 0f;
foreach ( var (candidate, _) in LiveBones() )
{
if ( candidate.Subject != bone.Subject )
continue;
if ( candidate.Parent is not { } parent || !resolved.TryGetValue( parent.Key, out var parentWorld ) )
continue;
var local = _poseLookup?.Invoke( candidate.Key ) ?? BindPoseFor( candidate );
var world = parentWorld.ToWorld( local );
resolved[candidate.Key] = world;
var distance = (world.Position - boneWorld.Position).Length;
if ( distance > tipDistance )
{
tipDistance = distance;
tip = world.Position;
}
}
return tip;
}
/// <summary>Whether the tip's straight path from <paramref name="from"/> to <paramref name="to"/>
/// crosses a prop surface - the same clamp the move drag uses, asked as a yes/no.</summary>
private bool Penetrates( Vector3 from, Vector3 to )
{
var clamped = ClampToProps( from, to );
return (clamped - to).Length > 0.001f;
}
/// <summary>
/// Reduce a proposed rotation so its descendant tip stops at the first prop surface it would
/// cross, rather than sweeping through it.
///
/// The tip sweeps an arc but only its endpoints are known, so the straight chord between the
/// grab-pose tip and the proposed tip is used as the approximation - exact enough for the
/// per-frame rotation increments a drag produces. When the chord crosses a surface the rotation
/// is scaled back with a binary search to the largest amount that doesn't, which leaves the tip
/// resting just off the surface.
/// </summary>
private Rotation ClampRotation( RigBone bone, Transform start, Rotation applied )
{
if ( !CollideWithProps || _colliders.Count == 0 )
return applied;
var tipStart = DescendantTip( bone, start );
var full = new Transform( start.Position, applied * start.Rotation, start.Scale );
var tipFull = DescendantTip( bone, full );
if ( !Penetrates( tipStart, tipFull ) )
return applied;
var lo = 0f;
var hi = 1f;
for ( var i = 0; i < 8; i++ )
{
var mid = (lo + hi) * 0.5f;
var limited = Rotation.Slerp( Rotation.Identity, applied, mid );
var worldMid = new Transform( start.Position, limited * start.Rotation, start.Scale );
var tipMid = DescendantTip( bone, worldMid );
if ( Penetrates( tipStart, tipMid ) )
hi = mid;
else
lo = mid;
}
return Rotation.Slerp( Rotation.Identity, applied, lo );
}
/// <summary>
/// Puts a model on an object, skinned when it brings a skeleton worth posing.
///
/// SKINNED ONLY WHEN THERE IS SOMETHING TO SKIN. A SkinnedModelRenderer on a static mesh costs
/// a bone update per frame for a skeleton of one, and would put a lone handle on every crate in
/// the viewport. Returns the skinned renderer, or null - that return is what registers the
/// thing as an object whose bones can be posed.
/// </summary>
private static SkinnedModelRenderer Draw( GameObject on, Model model )
{
if ( model is null )
return null;
if ( HasSkeleton( model ) )
{
var boned = on.GetOrAddComponent<SkinnedModelRenderer>( false );
boned.Model = model;
// Nothing generates a pose for these but us. Left on, the graph would drive the bones
// every frame and fight every override posing writes.
boned.UseAnimGraph = false;
boned.Enabled = true;
return boned;
}
var renderer = on.GetOrAddComponent<ModelRenderer>( false );
renderer.Model = model;
renderer.Enabled = true;
return null;
}
/// <summary>
/// The model a part draws: its own compiled model, or a lump of the object's OBJ.
///
/// A missing file is reported once, here, rather than by drawing nothing and leaving you to
/// work out whether the part is broken or just invisible. The part stays either way - its
/// keyframes are animation, and a file that has moved must not cost you those.
/// </summary>
private Model ModelForPart( RigObject owner, RigObjectPart part )
{
if ( part.Model is not null )
return part.Model;
if ( string.IsNullOrWhiteSpace( owner.ObjSource ) )
return null;
var path = RigObjMeshes.Resolve( owner.ObjSource, DocumentFolder );
if ( path is null )
{
Log.Warning( $"[Marionette] \"{owner.Name}\" cannot find its mesh file: {owner.ObjSource}" );
return null;
}
var model = RigObjMeshes.Load( path, part.ObjPart );
if ( model is null )
Log.Warning( $"[Marionette] \"{owner.Name}/{part.Name}\" - {Path.GetFileName( path )} has no part called \"{part.ObjPart}\"" );
return model;
}
/// <summary>Whether a model brings a skeleton worth posing. More than one bone, because a
/// static model still compiles with a single root - treating that as a rig would put a handle
/// on the middle of every crate.</summary>
private static bool HasSkeleton( Model model ) => (model?.Bones?.AllBones?.Count ?? 0) > 1;
private RigMovable Find( string key ) =>
string.IsNullOrEmpty( key ) ? null : _movables.FirstOrDefault( m => m.Key == key );
/// <summary>Placement, every frame, so dragging a number in the panel moves things while you
/// watch rather than on some later refresh.</summary>
private void ApplyMovables()
{
foreach ( var movable in _movables )
{
if ( !movable.Object.IsValid() )
continue;
// Everything above it has to be showing too - hiding the head hides the eyes.
var visible = movable.Visible();
for ( var up = movable.Owner; visible && up is not null; up = up.Owner )
visible = up.Visible();
movable.Object.Enabled = visible;
if ( !visible )
continue;
var local = movable.Read();
// A PART IS PLACED INSIDE ITS OBJECT. Writing a part in world space would tear it off
// the thing it belongs to the moment the object moved - which is the one thing an
// object made of parts must never do.
if ( movable.Owner is not null )
{
movable.Object.LocalPosition = local.Position;
movable.Object.LocalRotation = local.Rotation;
movable.Object.LocalScale = local.Scale;
continue;
}
// Following a bone puts the thing in the hand and keeps it there while the hand moves,
// which is what you want for a weapon that is already held.
if ( !string.IsNullOrWhiteSpace( movable.FollowBone() )
&& TryGetWorldTransform( movable.FollowBone(), out var boneWorld ) )
{
local = boneWorld.ToWorld( local );
}
movable.Object.WorldPosition = local.Position;
movable.Object.WorldRotation = local.Rotation;
movable.Object.WorldScale = local.Scale;
}
}
/// <summary>Which object, part or prop has the gizmo, by name. A name rather than an index,
/// because the lists are rebuilt from the document whenever they change and an index would
/// quietly come to mean whatever shifted into that slot.</summary>
private string _selectedMovable;
public string SelectedReferencePropName => _selectedMovable;
/// <summary>Fired every frame something 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; }
/// <summary>
/// Fired with the thing's name and its new local transform every frame it is dragged - the
/// exact counterpart of BonePosed, so the timeline records a part exactly as it records a bone.
/// </summary>
public Action<string, Transform> ReferencePropPosed { get; set; }
/// <summary>Fired with the selected name, or null when nothing is. Selection is one shared idea
/// across the window: picking something in the viewport marks its timeline lane and its row in
/// the objects tree, and clicking either of those selects it here.</summary>
public Action<string> ReferencePropSelected { get; set; }
/// <summary>Selects an object, part or prop by name - how a click on its timeline lane or its
/// tree row gets back here. Clears the bone selection, since one gizmo on screen at a time is
/// the same rule a click in the viewport follows.</summary>
public bool SelectReferenceProp( string name )
{
if ( Find( name ) is null )
return false;
_selectedMovable = name;
Select( null );
ReferencePropSelected?.Invoke( name );
return true;
}
/// <summary>
/// Places everything the clip has an answer for at this frame, the way EvaluatePose places
/// every bone.
///
/// Only names the lookup answers for are touched. Something with no keyframes keeps the
/// placement it was given by hand - scrubbing is not allowed to quietly move the workbench you
/// are posing against.
/// </summary>
public void ApplyPartPose( Func<string, Transform?> poseForPart )
{
if ( poseForPart is null )
return;
foreach ( var movable in _movables )
{
// The thing being dragged is already where the mouse says it is; overwriting it from
// the clip mid-drag is the same flicker EvaluatePose avoids for the dragged bone.
if ( movable.Key == _propDragKey )
continue;
if ( poseForPart( movable.Key ) is not { } local )
continue;
movable.Write( local );
}
// PLACED NOW, NOT NEXT FRAME. An object's bones are posed in WORLD space, converted against
// the object's transform - so if the object were left to move on the next frame, every bone
// in it would be placed against where it used to be. On a scrub that reads as a rigged prop
// coming apart.
ApplyMovables();
}
private string _propDragKey;
private Transform _propDragStart;
private Vector3 _propMoveDelta;
/// <summary>
/// Click something to select it, then drag its gizmo - move by default, hold E to rotate, the
/// same contract as a bone.
///
/// A PART IS CLICKED BEFORE ITS OBJECT. Parts are drawn after their parents and win ties, so
/// clicking the handle grabs the handle; the object itself is grabbed by its dot, or from the
/// objects tree. Without that rule the door leaf, being the bigger target, would swallow every
/// click meant for anything mounted on it.
/// </summary>
private void DrawMovables()
{
if ( MoveWholeModel || !ShowBoneHandles )
return;
// The selected thing's control runs AFTER the loop, outside every per-item scope. Inside
// one, the scope carries that item's own rotation, so the control's world-aligned basis
// would not 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.
(RigMovable Movable, Transform World)? selected = null;
// Answered AFTER the loop. Whoever asked will change the document, which rebuilds this
// list - doing that from inside the foreach would pull the list out from under it.
string picked = null;
foreach ( var movable in _movables )
{
if ( !movable.Object.IsValid() || !movable.Object.Enabled )
continue;
var isSelected = movable.Key == _selectedMovable;
var world = new Transform( movable.Object.WorldPosition, movable.Object.WorldRotation );
using var scope = Gizmo.Scope( $"Movable{movable.Key}", world );
// A part reads green like its object but smaller - it is a piece of something, not a
// peer of it, and size is what carries that when the dots are close together.
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = isSelected ? Theme.Yellow : Theme.Green.WithAlpha( movable.Owner is null ? 0.7f : 0.5f );
Gizmo.Draw.SolidSphere( 0f, HandleRadius * (isSelected ? 0.6f : movable.Owner is null ? 0.45f : 0.3f), 8, 8 );
Gizmo.Draw.IgnoreDepth = false;
if ( isSelected )
{
selected = (movable, world);
continue;
}
// THE WHOLE MESH IS THE TARGET, not a dot beside it. Clicking the switch to grab the
// switch is the obvious behaviour, and the dot alone is close to unclickable on a
// first-person rig, where the handle radius is a fraction of a unit.
//
// 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;
// A rigged mesh gets the dot only, and only while its bone handles are on screen: its
// bones sit inside these bounds, and a body-sized box in front of them wins every
// hover, so they would be visible and unclickable.
if ( movable.Model is { } model && !(movable.Skinned.IsValid() && ShowBoneHandles) )
Gizmo.Hitbox.BBox( model.Bounds.Grow( 0.5f ) );
else
Gizmo.Hitbox.Sphere( new Sphere( 0f, HandleRadius ) );
if ( !Gizmo.IsHovered )
continue;
RigStatusBar.Show( _pickMovable is not null
? $"{_pickPrompt} - {RigTrackName.Display( movable.Key )}"
: $"{RigTrackName.Display( movable.Key )} - click to select, then drag to move. Hold E to rotate." );
if ( Gizmo.WasLeftMousePressed && _pickMovable is not null )
{
picked = movable.Key;
continue;
}
if ( Gizmo.WasLeftMousePressed )
{
_selectedMovable = movable.Key;
// One gizmo on screen at a time - a bone and an object both showing handles is two
// things claiming the same drag.
Select( null );
ReferencePropSelected?.Invoke( movable.Key );
}
}
if ( picked is not null && _pickMovable is { } answer )
{
_pickMovable = null;
answer( picked );
// The answer may have rebuilt every movable, including the selected one - its control
// comes back next frame from the new list.
return;
}
if ( selected is { } sel )
DragMovable( sel.Movable, sel.World );
}
/// <summary>
/// The clip's cameras, each drawn as a small body and a wire frustum showing its lens.
///
/// DRAWN, NOT SPAWNED. A camera has no business being a scene object - it neither lights nor
/// blocks anything - so these are pure gizmo lines. IgnoreDepth keeps them readable through the
/// mesh, the same way the bone skeleton is, and they sit behind everything else so a frustum
/// never swallows a click meant for a bone or a part.
/// </summary>
private void DrawCameras()
{
if ( _cameras is null || _cameras.Count == 0 )
return;
Gizmo.Draw.IgnoreDepth = true;
foreach ( var camera in _cameras )
{
if ( camera is null || !camera.Enabled )
continue;
var rot = camera.Rotation.ToRotation();
var forward = rot.Forward;
var right = rot.Right;
var up = rot.Up;
var fov = camera.FieldOfView.Clamp( 1f, 179f );
var far = MathF.Max( camera.ZFar, camera.ZNear + 1f );
var aspect = 16f / 9f;
// A frustum corner at distance d: the centre plus the half-width and half-height the
// vertical FOV opens up at that distance, with sx/sy carrying the corner's sign.
var tan = MathF.Tan( fov * 0.5f * MathF.PI / 180f );
Vector3 Corner( float d, float sx, float sy ) =>
camera.Position + forward * d
+ right * (sx * d * tan * aspect)
+ up * (sy * d * tan);
// Near and far plane corners, in the same order so the two rectangles can be joined.
Vector3[] Near = { Corner( camera.ZNear, -1f, -1f ), Corner( camera.ZNear, 1f, -1f ), Corner( camera.ZNear, 1f, 1f ), Corner( camera.ZNear, -1f, 1f ) };
Vector3[] Far = { Corner( far, -1f, -1f ), Corner( far, 1f, -1f ), Corner( far, 1f, 1f ), Corner( far, -1f, 1f ) };
Gizmo.Draw.Color = Theme.Blue.WithAlpha( 0.9f );
// The four rays from the camera to the far corners - the shape of the shot.
for ( var i = 0; i < 4; i++ )
{
Gizmo.Draw.Line( camera.Position, Far[i] );
Gizmo.Draw.Line( Near[i], Far[i] );
}
// The near and far rectangles, so the lens's extent reads even at a glance.
for ( var i = 0; i < 4; i++ )
{
var j = (i + 1) % 4;
Gizmo.Draw.Line( Near[i], Near[j] );
Gizmo.Draw.Line( Far[i], Far[j] );
}
// The camera itself: a dot where it sits, and a short spine into the shot.
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.SolidSphere( camera.Position, HandleRadius * 0.5f, 8, 8 );
Gizmo.Draw.Color = Theme.Blue.WithAlpha( 0.9f );
Gizmo.Draw.Line( camera.Position, camera.Position + forward * 8f );
}
Gizmo.Draw.IgnoreDepth = false;
}
/// <summary>Set while the next click on an object, part or prop answers a question - "which
/// part should this follow?" - instead of selecting it.</summary>
private Action<string> _pickMovable;
private string _pickPrompt;
/// <summary>
/// Asks for the next object, part or prop clicked in the viewport, rather than selecting it.
///
/// PICKED IN THE VIEWPORT, NOT FROM A LIST, because the thing you want is the thing you can see.
/// An import names its parts mesh_6 and mesh_13; nobody knows which of those is the head, but
/// everybody can click it. Clicking empty space cancels.
/// </summary>
public void PickMovable( string prompt, Action<string> picked )
{
_pickMovable = picked;
_pickPrompt = prompt;
RigStatusBar.Show( prompt );
}
private void DragMovable( RigMovable movable, Transform world )
{
var dragging = _propDragKey == movable.Key;
var start = dragging ? _propDragStart : world;
// Positioned at the thing 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 is applied
// in.
using var scope = Gizmo.Scope( $"MovableControl{movable.Key}", 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( movable, 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( movable, world, ref start );
_propMoveDelta += delta;
moved = new Transform( start.Position + _propMoveDelta, start.Rotation, start.Scale );
}
// EVERYTHING IS STORED IN THE SPACE IT IS POSED IN. A part is stored inside its object and
// a follower inside the bone it follows - writing a world transform into either would put
// it in place exactly once, then send it flying the moment the thing it belongs to moved.
if ( movable.Owner is { Object: { } ownerObject } && ownerObject.IsValid() )
moved = ownerObject.WorldTransform.ToLocal( moved );
else if ( !string.IsNullOrWhiteSpace( movable.FollowBone() )
&& TryGetWorldTransform( movable.FollowBone(), out var boneWorld ) )
moved = boneWorld.ToLocal( moved );
movable.Write( moved );
ReferencePropMoved?.Invoke();
// Same gate as a bone: with Link off, dragging poses live and writes nothing.
if ( AutoKeyEnabled )
ReferencePropPosed?.Invoke( movable.Key, moved );
}
private void BeginPropDrag( RigMovable movable, Transform world, ref Transform start )
{
if ( _propDragKey == movable.Key )
return;
ReferencePropDragStarted?.Invoke();
_propDragKey = movable.Key;
_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 || _propDragKey is null )
return;
_propDragKey = null;
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( RigBone root, Transform rootWorld )
{
var resolved = new Dictionary<string, Transform> { [root.Key] = rootWorld };
foreach ( var (bone, world) in LiveBones() )
{
// Only the dragged bone's own object - another model's skeleton has its own hierarchy
// and nothing under this bone.
if ( bone.Subject != root.Subject )
continue;
if ( bone.Parent is not { } parent || !resolved.TryGetValue( parent.Key, 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.Key ) ?? BindPoseFor( bone );
var clamped = RigConstraintSolver.ClampToLimits( Rig, bone.Key, local );
var worldPose = parentWorld.ToWorld( clamped );
resolved[bone.Key] = 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() )
{
// Dragged bones keep their live transform, and must still be resolvable as a
// parent for anything under them.
if ( _draggingBone && _selectedBones.Contains( bone.Key ) )
{
resolved[bone.Key] = 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.Key ) ?? BindPoseFor( bone );
var clamped = RigConstraintSolver.ClampToLimits( Rig, bone.Key, 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. Keyed by the qualified name, so two objects
// that both call a bone "root" cannot resolve against each other.
var parentWorld = bone.Parent is { } parent
? (resolved.TryGetValue( parent.Key, out var computed ) ? computed : ParentWorld( bone ))
: bone.Renderer.WorldTransform;
var worldPose = parentWorld.ToWorld( clamped );
resolved[bone.Key] = 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();
UpdatePlayerReference();
ApplyPixelStyle();
ApplyViewmodelFraming();
ApplyMovables();
_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 );
DrawPlayerReference();
DrawBoneHandles();
DrawMovables();
DrawCameras();
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 = FindBone( SelectedBone );
if ( bone is null )
return;
var parentWorld = ParentWorld( bone );
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();
// Scale only shows once it's not 1 - it's noise for the ninety-nine-in-a-hundred poses
// that don't touch it, and the thing you're actively judging when you are scaling.
var scale = local.Scale == Vector3.One
? ""
: $"\nscl {local.Scale.x:0.#} {local.Scale.y:0.#} {local.Scale.z:0.#}";
Gizmo.Draw.Color = Color.White.WithAlpha( 0.85f );
Gizmo.Draw.ScreenText( RigTrackName.Display( 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.#}{scale}",
new Vector2( 12, 30 ), size: 11, flags: TextFlag.LeftTop );
}
private bool _draggingBone;
/// <summary>One dot per bone, click to select, Shift-click to add or remove, click-and-drag to
/// pose - hold E to flip rotate/move, or use the toolbar for scale. A single selected bone gets
/// a gizmo on itself; several selected bones get one gizmo on their centroid that poses the lot
/// together. 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()
{
// Selected bones are collected through the loop, then the control is run after it in its
// own top-level scope, so it isn't nested inside a bone's rotated drawing scope.
var selectedBones = new List<(RigBone Bone, Transform World)>();
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 bones still get their control - otherwise turning
// dots off would silently take away the ability to pose.
foreach ( var name in _selectedBones )
{
if ( FindBone( name ) is { } bone && bone.TryGetWorld( out var world ) )
selectedBones.Add( (bone, world) );
}
if ( selectedBones.Count == 1 )
DragSelectedBone( selectedBones[0].Bone, selectedBones[0].World );
else if ( selectedBones.Count > 1 )
DragSelectedBones( selectedBones );
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.Key ) && !ShowHiddenBones )
continue;
var isSelected = _selectedBones.Contains( bone.Key );
// Twist is a property of the bone's own name inside its skeleton, not of the qualified
// track name - a prop's twist bone is as much a twist bone as the arms'.
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;
if ( isSelected )
selectedBones.Add( (bone, world) );
// The subject is in the scope id: two objects can hold a bone at the same index, and
// two gizmo scopes sharing an id are one scope as far as hit testing is concerned.
using var boneScope = Gizmo.Scope( $"Bone{bone.Subject}:{bone.Index}", world );
// This object's own handle size, not the main model's - see RigBone.Radius.
var radius = bone.Radius * BoneHandleScale;
if ( bone.Parent is { } parentBone && parentBone.TryGetWorld( out var parentWorld ) )
{
var parentLocal = world.PointToLocal( parentWorld.Position );
// A dark ribbon behind the blue one so the skeleton reads against any mesh -
// light or dark - without the line itself getting thicker on screen.
Gizmo.Draw.Color = Color.Black.WithAlpha( 0.45f );
Gizmo.Draw.LineThickness = 2.5f;
Gizmo.Draw.Line( 0f, parentLocal );
Gizmo.Draw.Color = Theme.Blue;
Gizmo.Draw.LineThickness = 1.5f;
Gizmo.Draw.Line( 0f, parentLocal );
Gizmo.Draw.LineThickness = 1f;
}
// 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.
var dotRadius = radius * (isSelected ? 0.5f : isTwist ? 0.16f : 0.35f);
// A thin dark halo behind the plain dot so a white handle doesn't vanish against a
// pale mesh. Drawn slightly larger, then the dot itself covers the middle - selected
// and twist dots are already high-contrast and don't need it.
if ( !isSelected && !isTwist )
{
Gizmo.Draw.Color = Color.Black.WithAlpha( 0.5f );
Gizmo.Draw.SolidSphere( 0f, dotRadius * 1.4f, 8, 8 );
}
Gizmo.Draw.Color = isSelected ? Theme.Yellow
: Gizmo.IsHovered ? Theme.Green
: isTwist ? Theme.Blue.WithAlpha( 0.55f )
: Color.White;
Gizmo.Draw.SolidSphere( 0f, dotRadius, 8, 8 );
// No hitbox of our own on a single 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.
//
// With a group selected the control lives at the centroid, away from any one bone, so
// the selected bones keep their hitboxes - that's what lets Shift-click take one back
// out of the set.
if ( isSelected && _selectedBones.Count == 1 )
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.Key;
if ( Gizmo.WasLeftMousePressed )
Select( bone.Key, Editor.Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Shift ) );
}
}
// 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( DragHint( RigTrackName.Display( hovered ) ) );
}
else if ( _selectedBones.Count > 1 )
{
RigStatusBar.Show( $"{_selectedBones.Count} bones selected - drag the gizmo to pose them together" );
}
else if ( SelectedBone is not null )
{
RigStatusBar.Show( $"{RigTrackName.Display( 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 ( selectedBones.Count == 1 )
DragSelectedBone( selectedBones[0].Bone, selectedBones[0].World );
else if ( selectedBones.Count > 1 )
DragSelectedBones( selectedBones );
DeselectOnEmptyClick( hovered );
}
/// <summary>Status-bar copy for a hovered bone - names the current drag mode and reminds that
/// Shift-click extends the selection rather than replacing it.</summary>
private string DragHint( string subject )
{
var (verb, flip) = DragMode switch
{
BoneDragMode.Rotate => ("rotate", "Hold E to move instead."),
BoneDragMode.Move => ("move", "Hold E to rotate instead."),
_ => ("scale", "")
};
return $"{subject} - click to select, Shift-click to add, drag to {verb}. {flip}".TrimEnd();
}
/// <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;
// Mid-pick, a click on nothing means "never mind", not "deselect" - the thing being asked
// about stays selected so it can be asked about again.
if ( _pickMovable is not null && Gizmo.WasLeftMousePressed && hovered is null && !Gizmo.HasHovered )
{
_pickMovable = null;
RigStatusBar.Show( "Cancelled" );
return;
}
if ( SelectedBone is null && _selectedMovable is null )
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 || _groupDragTargets is not null || _propDragKey is not null )
return;
if ( hovered is not null || Gizmo.HasHovered )
return;
if ( !Gizmo.WasLeftMousePressed )
return;
_selectedMovable = null;
Select( null );
ReferencePropSelected?.Invoke( 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, HandleRadius * 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 - ViewmodelAnchor;
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;
// Group drag state. _groupDragTargets holds the top-most selected bones (a selected bone with
// a selected ancestor is carried by that ancestor, not transformed directly), captured with
// their start transforms; _groupPivot is the centroid the gizmo and the transform act around.
private List<(RigBone Bone, Transform Start)> _groupDragTargets;
private Vector3 _groupPivot;
private Vector3 _groupMoveDelta;
/// <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( RigBone bone, Transform world, ref Transform start )
{
if ( _dragBoneName == bone.Key )
return;
// Announced before the first write lands, so whatever is listening can record the
// pre-drag pose.
BoneDragStarted?.Invoke( bone.Key );
_dragBoneName = bone.Key;
_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 wasDragging = _dragBoneName is not null || _groupDragTargets is not null;
_dragBoneName = null;
_groupDragTargets = null;
_draggingBone = false;
// Only fire on an actual drag ending - this runs every frame the control isn't active.
if ( wasDragging )
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( RigBone bone, Transform world )
{
var dragging = _dragBoneName == bone.Key;
// 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.Subject}:{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 mode = EffectiveDragMode();
Transform newWorld;
switch ( mode )
{
case BoneDragMode.Rotate:
{
// 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;
// Collision: rotating a bone swings its descendants - a hand, say - through an arc,
// and this stops that arc at the first prop surface it would cross instead of letting
// it sweep straight through.
applied = ClampRotation( bone, start, applied );
newWorld = new Transform( start.Position, applied * start.Rotation, start.Scale );
break;
}
case BoneDragMode.Move:
{
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 );
break;
}
default:
{
// Scale, like Rotate, reports CUMULATIVE since the grab - the value passed in is
// the baseline (1), the value handed back is the factor to multiply by. Uniform,
// because Source2 scale is 1D and a joint scale that isn't uniform skews the mesh
// in a way no rig wants.
if ( !Gizmo.Control.Scale( "bone-scale", 1f, out var scale ) )
{
EndDragIfReleased();
return;
}
BeginDrag( bone, world, ref start );
var s = scale.Clamp( 0.01f, 100f );
// A bone scales in place about its own joint - position and rotation hold, the
// scale multiplies.
newWorld = new Transform( start.Position, start.Rotation, start.Scale * s );
break;
}
}
_draggingBone = true;
// Collision for a move: stop the dragged point at the surface of a prop or object instead of
// clipping through it. Rotate is handled inside the rotate case (ClampRotation), and scale
// leaves the point at the grab spot so it has nothing to clamp. This runs before the IK
// solve, so a hand reaching for a surface rests on it rather than punching through.
newWorld = new Transform( ClampToProps( start.Position, newWorld.Position ), newWorld.Rotation, newWorld.Scale );
// 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.Key} mode={mode} " +
$"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.Key ) is { } ik
&& RigConstraintSolver.TrySolveTwoBone( bone.Renderer, bone.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 (chainBoneData, chainWorld) in chain )
{
// The solver works in one skeleton and hands back its raw bones, so each one is
// re-attached to the object the drag started in.
var chainBone = new RigBone
{
Subject = bone.Subject,
Renderer = bone.Renderer,
Bone = chainBoneData,
Radius = bone.Radius
};
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 && chainBone.TryGetWorld( 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.Key] = 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.Key, 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 drag mode the gizmo should act in this frame, with E's hold-to-flip applied.
/// E flips rotate/move and never implies scale - scale is deliberate, reached from the toolbar.</summary>
private BoneDragMode EffectiveDragMode()
{
if ( !Editor.Application.IsKeyDown( KeyCode.E ) )
return DragMode;
return DragMode switch
{
BoneDragMode.Rotate => BoneDragMode.Move,
BoneDragMode.Move => BoneDragMode.Rotate,
_ => DragMode
};
}
/// <summary>
/// Drag several selected bones as a group, from one gizmo at their centroid.
///
/// Only the TOP-MOST selected bones are transformed and keyed: a selected child is carried by
/// its selected parent through PropagateToDescendants, so transforming both would apply the
/// parent's rotation and then the child's own on top - double motion. The transform itself is
/// applied about the centroid (rotate and scale move each bone around it), which is what makes
/// a handful of fingers curl as one hand rather than each bone spinning on its own joint.
///
/// IK is deliberately not run here - it's a single-bone solve, and a group drag is plain FK.
/// </summary>
private void DragSelectedBones( List<(RigBone Bone, Transform World)> targets )
{
var pivot = _groupDragTargets is not null ? _groupPivot : Centroid( targets );
using var scope = Gizmo.Scope( "BoneGroup", new Transform( pivot ) );
Gizmo.Hitbox.DepthBias = 0.01f;
var mode = EffectiveDragMode();
// Rotate and Scale hand back values CUMULATIVE since the grab; Position hands back a
// per-frame delta. Same contract as DragSelectedBone - see the note there.
var rotation = Rotation.Identity;
var moveDelta = Vector3.Zero;
var scale = 1f;
switch ( mode )
{
case BoneDragMode.Rotate:
if ( !Gizmo.Control.Rotate( "bone-rotate", Rotation.Identity, out rotation ) )
{
EndDragIfReleased();
return;
}
break;
case BoneDragMode.Move:
if ( !Gizmo.Control.Position( "bone-move", Vector3.Zero, out moveDelta, Rotation.Identity ) )
{
EndDragIfReleased();
return;
}
break;
default:
if ( !Gizmo.Control.Scale( "bone-scale", 1f, out scale ) )
{
EndDragIfReleased();
return;
}
scale = scale.Clamp( 0.01f, 100f );
break;
}
BeginGroupDrag( targets );
// Position's delta accumulates onto the frozen start, after BeginGroupDrag has zeroed it.
if ( mode == BoneDragMode.Move )
_groupMoveDelta += moveDelta;
_draggingBone = true;
foreach ( var (bone, start) in _groupDragTargets )
{
var newWorld = TransformGroup( start, _groupPivot, rotation, _groupMoveDelta, scale, mode );
// Collision only for a translation - a rotation or scale has no single direction to
// clamp against, and a group rotate into a prop is a pose to fix by eye, not a clip.
if ( mode == BoneDragMode.Move )
newWorld = new Transform( ClampToProps( start.Position, newWorld.Position ), newWorld.Rotation, newWorld.Scale );
newWorld = ApplyLimits( bone, newWorld );
ApplyWorldTransform( bone, newWorld );
// Carries unselected descendants - and any selected ones nested under this bone -
// with the moved parent.
PropagateToDescendants( bone, newWorld );
NotifyPosed( bone, newWorld );
}
}
/// <summary>Latches the group drag's targets and centroid on its first frame of movement.
/// Captured as the TOP-MOST bones only, so a parent and its selected children are transformed
/// once, not once each.</summary>
private void BeginGroupDrag( List<(RigBone Bone, Transform World)> targets )
{
if ( _groupDragTargets is not null )
return;
var tops = TopMost( targets );
_groupDragTargets = tops;
_groupPivot = Centroid( tops );
_groupMoveDelta = Vector3.Zero;
BoneDragStarted?.Invoke( SelectedBone ?? (tops.Count > 0 ? tops[0].Bone.Key : null) );
}
/// <summary>
/// The selected bones with no selected ancestor - those are the ones the group transform is
/// applied to; anything under one follows it through the hierarchy.
///
/// The rule is <see cref="BoneSelection.TopMost"/>, out in the kernel where a test can reach
/// it. What stays here is the only part that needs the engine's bone objects: flattening them
/// into a name-to-parent map. That map deliberately includes ANCESTORS THAT ARE NOT SELECTED,
/// because the walk has to step over them to find the selected bone above - testing the
/// immediate parent, which is what this did before, double-transformed a bone whenever the
/// selection skipped a generation.
/// </summary>
private static List<(RigBone Bone, Transform World)> TopMost( List<(RigBone Bone, Transform World)> bones )
{
// Qualified names throughout, so a bone in one object is never taken for the ancestor of a
// bone in another - selecting the hand and the weapon's own root has to transform both.
var parents = new Dictionary<string, string>();
foreach ( var (bone, _) in bones )
{
for ( var b = bone.Bone; b is not null; b = b.Parent )
{
parents[RigTrackName.Qualify( bone.Subject, b.Name )] =
b.Parent is { } parent ? RigTrackName.Qualify( bone.Subject, parent.Name ) : null;
}
}
var tops = new HashSet<string>( BoneSelection.TopMost(
bones.Select( t => t.Bone.Key ),
name => parents.TryGetValue( name, out var parent ) ? parent : null ) );
return bones.Where( t => tops.Contains( t.Bone.Key ) ).ToList();
}
private static Vector3 Centroid( List<(RigBone Bone, Transform World)> bones )
{
if ( bones.Count == 0 )
return Vector3.Zero;
var sum = Vector3.Zero;
foreach ( var (_, world) in bones )
sum += world.Position;
return sum / (float)bones.Count;
}
/// <summary>Apply a group transform to one bone's start pose. Rotate and scale act about the
/// group pivot; move is a plain translation of every bone by the same delta.</summary>
private static Transform TransformGroup( Transform start, Vector3 pivot, Rotation rotation, Vector3 move, float scale, BoneDragMode mode ) => mode switch
{
BoneDragMode.Rotate => new Transform( pivot + rotation * (start.Position - pivot), rotation * start.Rotation, start.Scale ),
BoneDragMode.Move => new Transform( start.Position + move, start.Rotation, start.Scale ),
_ => new Transform( pivot + (start.Position - pivot) * scale, start.Rotation, start.Scale * scale )
};
/// <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( RigBone bone, Transform newWorld, Transform? parentWorld = null )
{
if ( _suppressAutoKey || !AutoKeyEnabled )
return;
BonePosed?.Invoke( bone.Key, (parentWorld ?? ParentWorld( bone )).ToLocal( newWorld ) );
}
public override void OnDestroyed()
{
base.OnDestroyed();
_canvas.Scene?.Destroy();
}
}