Editor viewport widget for the Weapon Animator tool. It creates and manages a preview scene, camera, lighting, UI buttons and gizmos, draws grids and skeleton overlays, handles input for camera and picking, applies evaluated animation poses to preview renderers, and computes occlusion and visual styles for bones and arms.
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Editor;
using Sandbox;
namespace SboxWeaponAnimator.Editor;
internal readonly record struct GridVisualStyle(
float MinorOpacity,
float MajorOpacity,
float AxisOpacity,
float MinorWidth,
float MajorWidth,
float AxisWidth )
{
public static GridVisualStyle Resolve( float opacity, float lineWeight )
{
var alpha = Math.Clamp( opacity, 0, 0.5f );
var weight = Math.Clamp( lineWeight, 0.1f, 2.0f );
return new GridVisualStyle(
alpha * 0.42f,
alpha * 0.70f,
alpha,
weight * 0.38f,
weight * 0.58f,
weight * 0.78f );
}
}
internal readonly record struct ViewportRimLightStyle(
bool Enabled,
float Intensity,
Color Color )
{
public static ViewportRimLightStyle Resolve(
bool enabled,
float intensity,
bool fullBright )
{
var safeIntensity = WeaponAnimationMath.IsFinite( intensity )
? Math.Clamp( intensity, 0, 12 )
: 4.0f;
return new ViewportRimLightStyle(
enabled && !fullBright && safeIntensity > 0.001f,
safeIntensity,
WeaponAnimatorTheme.Cyan * safeIntensity );
}
}
internal enum SkeletonBoneKind
{
Weapon,
Arm,
Twist,
Ik
}
/// <summary>
/// <paramref name="Hollow"/> draws the bone as a wireframe orb instead of a filled dot. Solid means
/// "you pose this directly"; hollow means the bone is derived - driven by a constraint or kept only
/// as an export helper. Shape reads at a glance where a size difference alone does not.
/// </summary>
internal readonly record struct SkeletonBoneStyle(
bool Visible,
Color Color,
float AlphaScale,
float RadiusScale,
bool Hollow )
{
/// <summary>
/// The Facepunch arms ship four IK helper bones (`hand_*_to_*_ikrule`) kept through compilation
/// by BoneMarkup even though they skin nothing, and the host builder adds `ik_hand_R`/`ik_hand_L`
/// parented to weapon_root. Nothing reads any of them, and their default binding offset puts
/// them well in front of the weapon, so they trail long lines across the viewport.
/// </summary>
public static SkeletonBoneKind Classify( HostBone bone )
{
// Checked ahead of the weapon test on purpose: weapon rigs commonly ship their own IK
// targets (weapon_IK_hand_R), and those are helpers whichever rig they arrived from.
// Hiding is display-only, so a false positive costs visibility, never generated output.
if ( HasIkToken( bone.Name ) )
return SkeletonBoneKind.Ik;
if ( bone.IsWeaponBone )
return SkeletonBoneKind.Weapon;
// Twist bones deform the mesh, so they stay visible and clickable - just quieter.
return bone.Name.Contains( "_twist", StringComparison.OrdinalIgnoreCase )
? SkeletonBoneKind.Twist
: SkeletonBoneKind.Arm;
}
/// <summary>
/// Matches `ik` and `ikrule` as whole underscore-delimited tokens rather than as substrings, so
/// `weapon_IK_hand_R` and `hand_R_to_weapon_ikrule` are caught while ordinary names that merely
/// contain the letters - `spike`, `strike_plate` - are not.
/// </summary>
private static bool HasIkToken( string name )
{
foreach ( var token in name.Split( '_', StringSplitOptions.RemoveEmptyEntries ) )
{
if ( token.Equals( "ik", StringComparison.OrdinalIgnoreCase )
|| token.Equals( "ikrule", StringComparison.OrdinalIgnoreCase ) )
return true;
}
return false;
}
public static SkeletonBoneStyle Resolve(
SkeletonBoneKind kind,
int depth,
int maxDepth,
bool showIk )
{
if ( kind == SkeletonBoneKind.Weapon )
return new SkeletonBoneStyle( true, WeaponAnimatorTheme.Amber, 1.0f, 1.0f, false );
if ( kind == SkeletonBoneKind.Ik )
return new SkeletonBoneStyle( showIk, WeaponAnimatorTheme.Coral, 1.0f, 1.0f, true );
var fraction = maxDepth > 0
? Math.Clamp( depth / (float)maxDepth, 0, 1 )
: 0;
var color = WeaponAnimatorTheme.BoneDepthColor( fraction );
// Twist bones are driven by TiltTwist constraints, so they read as hollow. They keep close
// to full size because a wireframe orb needs the room to be legible at all.
return kind == SkeletonBoneKind.Twist
? new SkeletonBoneStyle( true, color, 0.7f, 0.9f, true )
: new SkeletonBoneStyle( true, color, 1.0f, 1.0f, false );
}
}
internal readonly record struct SkeletonOverlayStyle(
bool DrawThroughMeshes,
float VisibleAlpha,
float OccludedAlpha )
{
/// <summary>
/// Occluded bones use smaller marks so they stay readable without competing with visible bones.
/// </summary>
public const float OccludedDotScale = 0.55f;
public const float OccludedLineThickness = 0.6f;
public const int OcclusionGradientSegments = 10;
/// <summary>
/// Pushes an occluded bone most of the way to grey. Hue carries depth along the arm chain, so
/// draining it is what makes "behind something" read as a different category rather than just a
/// dimmer version of the same thing. A little colour is left so weapon and arm stay tellable.
/// </summary>
public static Color Occlude( Color color )
{
var luminance = (color.r * 0.299f) + (color.g * 0.587f) + (color.b * 0.114f);
return Color.Lerp(
color,
new Color( luminance, luminance, luminance, color.a ),
0.8f );
}
public static float OcclusionDepthClearance( float cameraDistance )
{
var safeDistance = WeaponAnimationMath.IsFinite( cameraDistance )
? MathF.Max( cameraDistance, 0 )
: 0;
var markerRadius = Math.Clamp( safeDistance / 180.0f, 0.08f, 0.45f );
return MathF.Max( markerRadius * 0.35f, 0.05f );
}
public static bool IsOccludingDepth(
float targetDistance,
float hitDistance )
{
return WeaponAnimationMath.IsFinite( targetDistance )
&& WeaponAnimationMath.IsFinite( hitDistance )
&& targetDistance - hitDistance > OcclusionDepthClearance( targetDistance );
}
public static SkeletonOverlayStyle Resolve( bool xray, float baseAlpha )
{
var safe = WeaponAnimationMath.IsFinite( baseAlpha )
? Math.Clamp( baseAlpha, 0, 1 )
: 1.0f;
return new SkeletonOverlayStyle(
xray && safe > 0.001f,
safe,
safe * 0.28f );
}
public SkeletonLineVisual ResolveLineVisual(
SkeletonBoneStyle bone,
bool occluded )
{
var color = occluded ? Occlude( bone.Color ) : bone.Color;
var alpha = (occluded ? OccludedAlpha : VisibleAlpha)
* bone.AlphaScale
* 0.45f;
return new SkeletonLineVisual(
color.WithAlpha( alpha ),
occluded ? OccludedLineThickness : 1.0f );
}
}
internal readonly record struct SkeletonLineVisual(
Color Color,
float Thickness )
{
public static SkeletonLineVisual Lerp(
SkeletonLineVisual start,
SkeletonLineVisual end,
float fraction )
{
var t = WeaponAnimationMath.IsFinite( fraction )
? Math.Clamp( fraction, 0, 1 )
: 0;
return new SkeletonLineVisual(
Color.Lerp( start.Color, end.Color, t ),
start.Thickness + ((end.Thickness - start.Thickness) * t) );
}
}
internal static class SkeletonOcclusionPolicy
{
public static bool IsOccludedByArm(
bool targetIsWeaponBone,
int targetArmSide,
int hitArmSide )
{
return hitArmSide != 0
&& (targetIsWeaponBone
|| (targetArmSide != 0 && targetArmSide != hitArmSide));
}
}
internal readonly record struct ArmPreviewVisualStyle(
bool UseFlatMaterial,
Color Tint )
{
public static ArmPreviewVisualStyle Resolve(
WeaponAnimatorStage stage,
bool fullBright )
{
if ( fullBright )
{
return new ArmPreviewVisualStyle(
true,
new Color( 0.78f, 0.55f, 0.43f ) );
}
return stage == WeaponAnimatorStage.Animate
? new ArmPreviewVisualStyle( false, Color.White )
: new ArmPreviewVisualStyle(
false,
new Color( 0.42f, 0.84f, 0.92f, 0.42f ) );
}
}
public enum WeaponAnimatorTransformMode
{
Move,
Rotate,
Scale
}
internal sealed class RotationSnapStepWidget : Widget
{
private readonly LineEdit _edit;
private readonly Func<float> _getValue;
private readonly Action<float> _setValue;
public RotationSnapStepWidget(
Func<float> getValue,
Action<float> setValue,
Widget parent ) : base( parent )
{
_getValue = getValue;
_setValue = setValue;
FixedWidth = 55;
FixedHeight = 28;
ToolTip = "Rotation snap angle";
SetStyles(
"background-color: rgb(20,23,26);" +
"border: 1px solid rgba(255,255,255,0.09);" +
"border-radius: 3px;" );
Layout = Layout.Row();
Layout.Margin = 0;
Layout.Spacing = 0;
_edit = new LineEdit( this )
{
FixedHeight = 26,
ToolTip = ToolTip
};
_edit.SetStyles(
"background-color: transparent; border: none;" +
"color: rgb(224,229,234); font-size: 11px;" +
"text-align: right; padding: 0 1px 0 2px;" );
_edit.TextEdited += ApplyText;
_edit.EditingFinished += Refresh;
Layout.Add( _edit, 1 );
var suffix = WeaponAnimatorTheme.Label( "°", this );
suffix.FixedWidth = 9;
suffix.Alignment = TextFlag.Center;
Layout.Add( suffix );
var buttons = Layout.AddColumn();
buttons.Add( new IconButton( "keyboard_arrow_up", () => Step( 1 ) )
{
Background = Color.Transparent,
FixedWidth = 16,
FixedHeight = 13,
IconSize = 12,
ToolTip = "Increase rotation snap angle"
} );
buttons.Add( new IconButton( "keyboard_arrow_down", () => Step( -1 ) )
{
Background = Color.Transparent,
FixedWidth = 16,
FixedHeight = 13,
IconSize = 12,
ToolTip = "Decrease rotation snap angle"
} );
Refresh();
}
public void Refresh()
{
if ( _edit.IsFocused )
return;
_edit.Text = _getValue().ToString( "0.##", CultureInfo.InvariantCulture );
_edit.CursorPosition = 0;
Update();
}
private void ApplyText( string text )
{
if ( float.TryParse(
text,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var value )
&& WeaponAnimationMath.IsFinite( value ) )
_setValue( value );
}
private void Step( int direction )
{
_edit.Blur();
_setValue( WeaponAnimatorViewport.AdjustRotationSnapAngle(
_getValue(),
direction ) );
Refresh();
}
}
public sealed class WeaponAnimatorViewport : SceneRenderingWidget
{
private const int LegacyIdleRepairVersion = 3;
private const float ScaleGizmoSensitivity = 0.005f;
private const string ArmsOccluderTag = "weaponanim_arms_occluder";
private static readonly float[] RotationSnapSteps =
[0.25f, 0.5f, 1, 5, 15, 30, 45, 90, 180];
private Rect TransformReadoutRect =>
new( 260, Width < 620 ? 46 : 10, 104, 28 );
private readonly WeaponAnimatorController _controller;
private readonly CameraComponent _camera;
private readonly PointLight _rimLight;
private readonly Material _flatArmsMaterial;
private readonly WeaponAnimatorButton _moveModeButton;
private readonly WeaponAnimatorButton _rotateModeButton;
private readonly WeaponAnimatorButton _scaleModeButton;
private readonly WeaponAnimatorButton _spaceButton;
private readonly WeaponAnimatorButton _rotationSnapButton;
private readonly RotationSnapStepWidget _rotationSnapStep;
private readonly WeaponAnimatorButton _orbitCameraButton;
private readonly WeaponAnimatorButton _freeLookCameraButton;
private readonly WeaponAnimatorButton _lightingButton;
private string _transformModeText = "";
private SkinnedModelRenderer? _sourceRenderer;
private SkinnedModelRenderer? _armsRenderer;
private ModelHitboxes? _armsHitboxes;
private SkinnedModelRenderer? _hostRenderer;
private HostSkeleton? _hostSkeleton;
private HostSkeleton? _boneDepthSource;
private readonly Dictionary<string, int> _boneDepths =
new( StringComparer.OrdinalIgnoreCase );
private readonly Dictionary<string, Transform> _occlusionPose =
new( StringComparer.OrdinalIgnoreCase );
private readonly HashSet<string> _occludedBones =
new( StringComparer.OrdinalIgnoreCase );
private Transform _occlusionCameraTransform;
private bool _occlusionCacheValid;
private bool _occlusionFollowupPending;
private string _lastOcclusionDiagnostic = "";
private RealTimeSince _sinceOcclusionTrace = 99;
private int _maxBoneDepth;
private string _loadedSource = "";
private string _loadedHost = "";
private string _lastDiagnosticSelection = "";
private int _legacyIdleRepairVersionChecked;
private bool _sourcePoseDiagnosticsLogged;
private int _sourcePoseDiagnosticFrames;
private bool _armPoseDiagnosticsLogged;
private int _armPoseDiagnosticFrames;
private Vector2 _lastMouse;
private string _calibrationGizmoTarget = "";
private Transform _calibrationGizmoStartWorld;
private Transform _calibrationGizmoStartLocal;
private Vector3 _calibrationGizmoMoveDelta;
private Vector3 _calibrationGizmoScaleDelta;
private string _animationGizmoTarget = "";
private RigControlKind _animationGizmoKind;
private Transform _animationGizmoStartLocal;
private Transform _animationGizmoStartWorld;
private Transform? _animationGizmoStartParent;
private Vector3 _animationGizmoMoveDelta;
private Vector3 _animationGizmoScaleDelta;
private RealTimeSince _sinceCameraSpeedChanged = 99;
public ViewportPickMode PickMode { get; set; }
/// <summary>
/// Which custom anchor a <see cref="ViewportPickMode.CustomAnchor"/> pick will place.
/// </summary>
public Guid PickAnchorId { get; set; }
public bool IsPlaying => _controller.IsPlaying;
public WeaponAnimatorTransformMode TransformMode { get; private set; }
public Vector3 ModelDimensions => _sourceRenderer?.Model?.Bounds.Size ?? Vector3.Zero;
public bool ConsumesFreeLookMovementShortcut =>
_controller.Document.Workspace.FreeLookCamera
&& !_controller.Document.Workspace.FirstPersonPreview
&& IsActiveWindow
&& IsUnderMouse
&& PickMode == ViewportPickMode.None;
public event Action<string>? StatusChanged;
public event Action<Vector3>? ModelDimensionsChanged;
public event Action? LegacyIdleRepaired;
public WeaponAnimatorViewport(
WeaponAnimatorController controller,
Widget? parent = null ) : base( parent )
{
_controller = controller;
MinimumSize = new Vector2( 420, 280 );
FocusMode = FocusMode.Click;
MouseTracking = true;
Scene = Scene.CreateEditorScene();
using ( Scene.Push() )
{
_camera = new GameObject( true, "weapon_animator_camera" )
.GetOrAddComponent<CameraComponent>( false );
_camera.BackgroundColor = WeaponAnimatorTheme.Background;
_camera.ZNear = 0.5f;
_camera.ZFar = 8192;
_camera.Enabled = true;
Camera = _camera;
var ambient = new GameObject( true, "ambient" )
.GetOrAddComponent<AmbientLight>( false );
ambient.Color = new Color( 0.26f, 0.29f, 0.33f );
ambient.Enabled = true;
var key = new GameObject( true, "key_light" )
.GetOrAddComponent<DirectionalLight>( false );
key.WorldRotation = Rotation.From( 38, 135, 0 );
key.LightColor = new Color( 1.0f, 0.92f, 0.82f ) * 1.3f;
key.SkyColor = new Color( 0.18f, 0.22f, 0.27f );
key.Enabled = true;
_rimLight = new GameObject( true, "rim_light" )
.GetOrAddComponent<PointLight>( false );
_rimLight.WorldPosition = new Vector3( -32, 38, 28 );
_rimLight.Radius = 160;
}
_flatArmsMaterial = Material.Load( "materials/dev/primary_white.vmat" );
ApplyViewportRenderStyle();
_moveModeButton = AddTransformModeButton(
"open_with",
"Move (W)",
WeaponAnimatorTransformMode.Move,
new Vector2( 10, 10 ) );
_rotateModeButton = AddTransformModeButton(
"360",
"Rotate (E)",
WeaponAnimatorTransformMode.Rotate,
new Vector2( 41, 10 ) );
_scaleModeButton = AddTransformModeButton(
"zoom_out_map",
"Scale (R)",
WeaponAnimatorTransformMode.Scale,
new Vector2( 72, 10 ) );
_spaceButton = new WeaponAnimatorButton( "", "public", this )
{
IsToggle = true,
Clicked = ToggleTransformSpace,
Position = new Vector2( 119, 10 ),
FixedWidth = 28,
FixedHeight = 28
};
_spaceButton.Raise();
_rotationSnapButton = new WeaponAnimatorButton(
"",
"rotate_90_degrees_cw",
this )
{
IsToggle = true,
Clicked = ToggleRotationSnap,
Position = new Vector2( 166, 10 ),
FixedWidth = 28,
FixedHeight = 28,
ToolTip = "Toggle rotation snapping"
};
_rotationSnapButton.Raise();
_rotationSnapStep = new RotationSnapStepWidget(
() => _controller.Document.Workspace.RotationSnapDegrees,
SetRotationSnapDegrees,
this )
{
Position = new Vector2( 197, 10 )
};
_rotationSnapStep.Raise();
_orbitCameraButton = AddViewportActionButton(
"360",
"Orbit camera",
() => SetCameraMode( false ) );
_freeLookCameraButton = AddViewportActionButton(
"videocam",
"Free look camera — RMB look, WASD move, wheel changes speed, Shift moves faster",
() => SetCameraMode( true ) );
_lightingButton = AddViewportActionButton(
"light_mode",
"Toggle lit / full bright",
ToggleViewportLighting );
PositionViewportActions();
_controller.DocumentChanged += OnDocumentChanged;
_controller.PoseChanged += Update;
_controller.SelectionChanged += OnSelectionChanged;
_controller.TimelineChanged += Update;
RefreshTransformOverlay();
RefreshViewportCameraButtons();
RebuildPreview();
}
protected override void OnResize()
{
base.OnResize();
PositionViewportActions();
}
public override void OnDestroyed()
{
EndCalibrationGizmoDrag();
EndAnimationGizmoDrag();
_controller.DocumentChanged -= OnDocumentChanged;
_controller.PoseChanged -= Update;
_controller.SelectionChanged -= OnSelectionChanged;
_controller.TimelineChanged -= Update;
ReleasePreviewScene();
base.OnDestroyed();
}
public void ReleasePreviewScene()
{
if ( Scene.IsValid() )
Scene.Destroy();
Scene = null;
_sourceRenderer = null;
_armsRenderer = null;
_armsHitboxes = null;
_hostRenderer = null;
_hostSkeleton = null;
_occlusionCacheValid = false;
}
public void TogglePlayback()
{
_controller.TogglePlayback();
}
public void StopPlayback()
{
_controller.PausePlayback();
}
public void SetTransformMode( WeaponAnimatorTransformMode mode )
{
if ( TransformMode == mode )
{
RefreshTransformOverlay();
return;
}
EndCalibrationGizmoDrag();
EndAnimationGizmoDrag();
TransformMode = mode;
RefreshTransformOverlay();
StatusChanged?.Invoke( $"{TransformModeName( mode )} gizmo selected." );
Update();
}
private WeaponAnimatorButton AddTransformModeButton(
string icon,
string tooltip,
WeaponAnimatorTransformMode mode,
Vector2 position )
{
var button = new WeaponAnimatorButton( "", icon, this )
{
IsToggle = true,
Clicked = () => SetTransformMode( mode ),
Position = position,
FixedWidth = 28,
FixedHeight = 28,
ToolTip = tooltip
};
button.Raise();
return button;
}
private WeaponAnimatorButton AddViewportActionButton(
string icon,
string tooltip,
Action clicked )
{
var button = new WeaponAnimatorButton( "", icon, this )
{
IsToggle = true,
Clicked = clicked,
FixedWidth = 28,
FixedHeight = 28,
ToolTip = tooltip
};
button.Raise();
return button;
}
private void PositionViewportActions()
{
if ( _lightingButton is null )
return;
var right = MathF.Max( Width - 10, 113 );
_lightingButton.Position = new Vector2( right - 28, 10 );
_freeLookCameraButton.Position = new Vector2( right - 72, 10 );
_orbitCameraButton.Position = new Vector2( right - 103, 10 );
}
private void SetCameraMode( bool freeLook )
{
var workspace = _controller.Document.Workspace;
if ( workspace.FreeLookCamera == freeLook )
{
RefreshViewportCameraButtons();
return;
}
_controller.UpdateWorkspacePreference(
freeLook ? "Free look camera" : "Orbit camera",
state =>
{
state.FirstPersonPreview = false;
if ( freeLook )
{
state.CameraPosition = _camera.WorldPosition;
}
else
{
var rotation = Rotation.From( state.CameraAngles );
state.CameraFocus = state.CameraPosition
+ rotation.Forward * state.CameraDistance;
}
state.FreeLookCamera = freeLook;
} );
RefreshViewportCameraButtons();
UpdateCamera();
}
private void ToggleViewportLighting()
{
_controller.UpdateWorkspacePreference(
"Viewport lighting",
state => state.FullBrightViewport = !state.FullBrightViewport );
RefreshViewportCameraButtons();
UpdateCamera();
}
private void RefreshViewportCameraButtons()
{
var workspace = _controller.Document.Workspace;
RefreshTransformModeButton(
_orbitCameraButton,
!workspace.FreeLookCamera );
RefreshTransformModeButton(
_freeLookCameraButton,
workspace.FreeLookCamera );
_lightingButton.IsChecked = workspace.FullBrightViewport;
_lightingButton.Tint = workspace.FullBrightViewport
? WeaponAnimatorTheme.Amber * 0.55f
: WeaponAnimatorTheme.SurfaceRaised;
_lightingButton.ToolTip = workspace.FullBrightViewport
? "Full bright — click for Lit"
: "Lit — click for Full bright";
}
private void ToggleTransformSpace()
{
_controller.Mutate(
"Transform coordinate space",
document => document.Workspace.LocalGizmos =
!document.Workspace.LocalGizmos );
RefreshTransformOverlay();
}
private void ToggleRotationSnap()
{
_controller.UpdateWorkspacePreference(
"Rotation snapping",
state => state.SnapRotation = !state.SnapRotation );
RefreshTransformOverlay();
Update();
}
private void SetRotationSnapDegrees( float value )
{
if ( !WeaponAnimationMath.IsFinite( value ) )
return;
_controller.UpdateWorkspacePreference(
"Rotation snap angle",
state => state.RotationSnapDegrees = Math.Clamp( value, 0.25f, 180.0f ) );
RefreshTransformOverlay();
Update();
}
private void RefreshTransformOverlay()
{
var workspace = _controller.Document.Workspace;
var local = workspace.LocalGizmos;
RefreshTransformModeButton(
_moveModeButton,
TransformMode == WeaponAnimatorTransformMode.Move );
RefreshTransformModeButton(
_rotateModeButton,
TransformMode == WeaponAnimatorTransformMode.Rotate );
RefreshTransformModeButton(
_scaleModeButton,
TransformMode == WeaponAnimatorTransformMode.Scale );
_spaceButton.IsChecked = !local;
_spaceButton.Tint = local
? WeaponAnimatorTheme.SurfaceRaised
: WeaponAnimatorTheme.Cyan * 0.55f;
_spaceButton.ToolTip = local
? "Local space — click for World"
: "World space — click for Local";
RefreshTransformModeButton(
_rotationSnapButton,
workspace.SnapRotation );
_rotationSnapStep.Refresh();
GizmoInstance.Settings.SnapToAngles = workspace.SnapRotation;
GizmoInstance.Settings.AngleSpacing =
WeaponAnimationMath.IsFinite( workspace.RotationSnapDegrees )
? Math.Clamp( workspace.RotationSnapDegrees, 0.25f, 180.0f )
: 15.0f;
_transformModeText =
$"{TransformModeName( TransformMode ).ToUpperInvariant()} · {(local ? "LOCAL" : "WORLD")}";
}
private static void RefreshTransformModeButton(
WeaponAnimatorButton button,
bool selected )
{
button.IsChecked = selected;
button.Tint = selected
? WeaponAnimatorTheme.Cyan * 0.55f
: WeaponAnimatorTheme.SurfaceRaised;
}
private static string TransformModeName( WeaponAnimatorTransformMode mode ) =>
mode switch
{
WeaponAnimatorTransformMode.Rotate => "Rotate",
WeaponAnimatorTransformMode.Scale => "Scale",
_ => "Move"
};
public void SetPickMode( ViewportPickMode mode, Guid anchorId = default )
{
PickMode = mode;
PickAnchorId = anchorId;
StatusChanged?.Invoke( mode == ViewportPickMode.None
? "Pick mode cleared."
: $"Click the weapon surface or a bone to set {PickLabel( mode )}." );
}
public void FitCamera()
{
var bounds = _sourceRenderer?.Bounds ?? _hostRenderer?.Bounds;
if ( bounds is null )
return;
var workspace = _controller.Document.Workspace;
workspace.CameraFocus = bounds.Value.Center;
workspace.CameraDistance =
MathF.Max( bounds.Value.Size.Length * 1.25f, 12 );
if ( workspace.FreeLookCamera )
{
var rotation = Rotation.From( workspace.CameraAngles );
workspace.CameraPosition = workspace.CameraFocus
- rotation.Forward * workspace.CameraDistance;
}
UpdateCamera();
}
public void RebuildPreview()
{
if ( !Scene.IsValid() )
return;
_occlusionCacheValid = false;
using ( Scene.Push() )
{
_sourceRenderer?.GameObject.Destroy();
_armsRenderer?.GameObject.Destroy();
_hostRenderer?.GameObject.Destroy();
_sourceRenderer = null;
_armsRenderer = null;
_armsHitboxes = null;
_hostRenderer = null;
_hostSkeleton = null;
_loadedSource = "";
_loadedHost = "";
_sourcePoseDiagnosticsLogged = false;
_sourcePoseDiagnosticFrames = 0;
_armPoseDiagnosticsLogged = false;
_armPoseDiagnosticFrames = 0;
var document = _controller.Document;
if ( !string.IsNullOrWhiteSpace( document.Source.CompiledModelPath ) )
{
// Remember failed loads too. Retrying a full scene rebuild every frame creates
// overlapping renderers while the scene processes deferred destruction.
_loadedSource = document.Source.CompiledModelPath;
var sourceModel = Model.Load( document.Source.CompiledModelPath );
if ( sourceModel is not null && !sourceModel.IsError )
{
var sourceObject = new GameObject( true, "source_weapon_preview" );
_sourceRenderer = sourceObject.GetOrAddComponent<SkinnedModelRenderer>( false );
_sourceRenderer.Model = sourceModel;
_sourceRenderer.Enabled = true;
ModelDimensionsChanged?.Invoke( sourceModel.Bounds.Size );
}
else
{
Log.Warning(
$"[Weapon Animator] source preview model is unavailable: "
+ $"'{document.Source.CompiledModelPath}'. "
+ "The viewport will wait for a path change or a manual rebuild." );
}
}
var armsModel = Model.Load( HostSkeletonBuilder.ProductionArmsModel );
if ( armsModel is null || armsModel.IsError )
armsModel = HostSkeletonBuilder.LoadArmProfile();
if ( armsModel is not null && !armsModel.IsError )
{
var armsObject = new GameObject( true, "facepunch_arms_preview" );
armsObject.Tags.Add( ArmsOccluderTag );
_armsRenderer = armsObject.GetOrAddComponent<SkinnedModelRenderer>( false );
_armsRenderer.Model = armsModel;
_armsRenderer.Enabled = true;
_armsRenderer.Tint = new Color( 0.42f, 0.84f, 0.92f, 0.42f );
_armsHitboxes = armsObject.GetOrAddComponent<ModelHitboxes>( false );
_armsHitboxes.Renderer = _armsRenderer;
_armsHitboxes.Target = armsObject;
_armsHitboxes.Enabled = true;
}
if ( document.ActiveStage == WeaponAnimatorStage.Animate
&& !string.IsNullOrWhiteSpace( document.Source.PreviewHostPath ) )
{
// Failed host loads wait for a path change or an explicit rebuild.
_loadedHost = document.Source.PreviewHostPath;
var hostModel = Model.Load( document.Source.PreviewHostPath );
if ( hostModel is not null && !hostModel.IsError )
{
_hostRenderer = new GameObject( true, "animation_host_preview" )
.GetOrAddComponent<SkinnedModelRenderer>( false );
_hostRenderer.Model = hostModel;
_hostRenderer.Enabled = true;
_hostRenderer.UseAnimGraph = false;
SuppressHostRendering();
_hostSkeleton = HostSkeletonBuilder.BuildCached( document );
if ( _sourceRenderer.IsValid() )
{
_sourceRenderer!.WorldTransform = Transform.Zero;
_sourceRenderer.BoneMergeTarget = null;
}
if ( _armsRenderer.IsValid() )
{
_armsRenderer!.WorldTransform = Transform.Zero;
_armsRenderer.BoneMergeTarget = null;
_armsRenderer.Tint = Color.White;
}
}
else
{
Log.Warning(
$"[Weapon Animator] animation host preview is unavailable: "
+ $"'{document.Source.PreviewHostPath}'. "
+ "The viewport will wait for a path change or a manual rebuild." );
}
}
}
if ( _controller.Document.Workspace.CameraDistance <= 0 )
FitCamera();
Update();
}
protected override void PreFrame()
{
Scene.EditorTick( RealTime.Now, RealTime.Delta );
GizmoInstance.Input.IsHovered = IsActiveWindow && IsUnderMouse;
UpdateGizmoInputs( GizmoInstance.Input.IsHovered );
FinishCalibrationGizmoDragIfReleased();
FinishAnimationGizmoDragIfReleased();
if ( RepairLegacyIdleIfNeeded() )
return;
EnsurePreviewCurrent();
AdvancePlayback();
UpdateFreeLookMovement();
UpdateCamera();
ApplyViewportRenderStyle();
DrawWorkspaceGrid();
if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
DrawCalibration();
else
DrawAnimation();
DrawScreenGuides();
DrawViewportToolReadout();
DrawCameraSpeedOverlay();
Cursor = Gizmo.HasHovered || PickMode != ViewportPickMode.None
? CursorShape.Finger
: _controller.Document.Workspace.FreeLookCamera
&& global::Editor.Application.MouseButtons.HasFlag( MouseButtons.Right )
&& IsUnderMouse
? CursorShape.Blank
: CursorShape.Arrow;
}
private void DrawWorkspaceGrid()
{
var style = GridVisualStyle.Resolve(
_controller.Document.Workspace.GridOpacity,
_controller.Document.Workspace.GridLineThickness );
if ( style.AxisOpacity <= 0 )
return;
var spacing = MathF.Max( Gizmo.Settings.GridSpacing, 1 );
var desiredExtent = MathF.Max(
128,
_controller.Document.Workspace.CameraDistance * 6 );
var halfLines = Math.Clamp(
(int)MathF.Ceiling( desiredExtent / spacing ),
8,
64 );
var extent = halfLines * spacing;
using var scope = Gizmo.Scope( "weapon_animator_grid" );
for ( var index = -halfLines; index <= halfLines; index++ )
{
if ( index == 0 )
continue;
var coordinate = index * spacing;
var major = index % 4 == 0;
Gizmo.Draw.Color = Color.White.WithAlpha(
major ? style.MajorOpacity : style.MinorOpacity );
Gizmo.Draw.LineThickness = major ? style.MajorWidth : style.MinorWidth;
Gizmo.Draw.Line(
new Vector3( coordinate, -extent, 0 ),
new Vector3( coordinate, extent, 0 ) );
Gizmo.Draw.Line(
new Vector3( -extent, coordinate, 0 ),
new Vector3( extent, coordinate, 0 ) );
}
Gizmo.Draw.LineThickness = style.AxisWidth;
Gizmo.Draw.Color = new Color( 0.90f, 0.28f, 0.38f ).WithAlpha( style.AxisOpacity );
Gizmo.Draw.Line( new Vector3( -extent, 0, 0 ), new Vector3( extent, 0, 0 ) );
Gizmo.Draw.Color = new Color( 0.58f, 0.78f, 0.20f ).WithAlpha( style.AxisOpacity );
Gizmo.Draw.Line( new Vector3( 0, -extent, 0 ), new Vector3( 0, extent, 0 ) );
Gizmo.Draw.LineThickness = 1;
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
var delta = e.LocalPosition - _lastMouse;
_lastMouse = e.LocalPosition;
if ( (e.ButtonState & MouseButtons.Right) == 0
|| _controller.Document.Workspace.FirstPersonPreview )
return;
var workspace = _controller.Document.Workspace;
workspace.CameraAngles = new Angles(
Math.Clamp( workspace.CameraAngles.pitch + delta.y * 0.22f, -88, 88 ),
workspace.CameraAngles.yaw - delta.x * 0.22f,
0 );
_controller.MarkWorkspacePreferenceChanged( "Viewport camera rotation" );
UpdateCamera();
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
_lastMouse = e.LocalPosition;
if ( !e.LeftMouseButton || PickMode == ViewportPickMode.None )
return;
if ( TryPickSourceSurface( e.LocalPosition, out var localPosition ) )
{
ApplyPickedPoint( localPosition );
e.Accepted = true;
}
}
protected override void OnMouseWheel( WheelEvent e )
{
if ( _controller.Document.Workspace.FirstPersonPreview )
return;
var workspace = _controller.Document.Workspace;
if ( workspace.FreeLookCamera )
{
var direction = Math.Sign( e.Delta );
_controller.UpdateWorkspacePreference(
"Free look camera speed",
state => state.CameraMoveSpeed = AdjustCameraSpeed(
state.CameraMoveSpeed,
direction ) );
_sinceCameraSpeedChanged = 0;
e.Accept();
Update();
return;
}
workspace.CameraDistance = Math.Clamp(
workspace.CameraDistance * (e.Delta > 0 ? 0.9f : 1.1f),
2,
4096 );
_controller.MarkWorkspacePreferenceChanged( "Orbit camera distance" );
e.Accept();
}
private void OnDocumentChanged()
{
_occlusionCacheValid = false;
RefreshTransformOverlay();
RefreshViewportCameraButtons();
var document = _controller.Document;
if ( document.Source.CompiledModelPath != _loadedSource
|| (document.ActiveStage == WeaponAnimatorStage.Animate
&& document.Source.PreviewHostPath != _loadedHost)
|| (document.ActiveStage == WeaponAnimatorStage.Calibrate && _hostRenderer.IsValid()) )
{
RebuildPreview();
return;
}
Update();
}
private void EnsurePreviewCurrent()
{
if ( !Scene.IsValid() )
return;
var requestedSource = _controller.Document.Source.CompiledModelPath;
if ( _sourceRenderer is null
&& ShouldRetryMissingSourcePreview( requestedSource, _loadedSource ) )
RebuildPreview();
}
internal static bool ShouldRetryMissingSourcePreview(
string requestedSource,
string attemptedSource ) =>
!string.IsNullOrWhiteSpace( requestedSource )
&& !requestedSource.Equals( attemptedSource, StringComparison.OrdinalIgnoreCase );
private void AdvancePlayback()
{
_controller.AdvancePlayback( RealTime.Delta );
}
private void UpdateCamera()
{
if ( !_camera.IsValid() )
return;
var document = _controller.Document;
_camera.DebugMode = document.Workspace.FullBrightViewport
? SceneCameraDebugMode.FullBright
: SceneCameraDebugMode.Normal;
if ( document.Workspace.FirstPersonPreview )
{
_camera.WorldPosition = Vector3.Zero;
_camera.WorldRotation = Rotation.Identity;
var aspect = GuideAspect( document.Calibration.AspectGuide );
var horizontalRadians = document.Calibration.HorizontalFov.DegreeToRadian();
_camera.FieldOfView = (2.0f * MathF.Atan(
MathF.Tan( horizontalRadians * 0.5f ) / aspect )).RadianToDegree();
return;
}
var rotation = Rotation.From( document.Workspace.CameraAngles );
if ( document.Workspace.FreeLookCamera )
{
_camera.WorldPosition = document.Workspace.CameraPosition;
_camera.WorldRotation = rotation;
_camera.FieldOfView = 48;
return;
}
var focus = document.Workspace.CameraFocus;
_camera.WorldPosition = focus - rotation.Forward * document.Workspace.CameraDistance;
_camera.WorldRotation = Rotation.LookAt( focus - _camera.WorldPosition, Vector3.Up );
_camera.FieldOfView = 48;
}
private void ApplyViewportRenderStyle()
{
var document = _controller.Document;
var rim = ViewportRimLightStyle.Resolve(
document.Workspace.RimLightEnabled,
document.Workspace.RimLightIntensity,
document.Workspace.FullBrightViewport );
_rimLight.Enabled = rim.Enabled;
_rimLight.LightColor = rim.Color;
if ( !_armsRenderer.IsValid() )
return;
var arms = ArmPreviewVisualStyle.Resolve(
document.ActiveStage,
document.Workspace.FullBrightViewport );
_armsRenderer!.MaterialOverride = arms.UseFlatMaterial
? _flatArmsMaterial
: null;
_armsRenderer.Tint = arms.Tint;
}
private void UpdateFreeLookMovement()
{
var workspace = _controller.Document.Workspace;
if ( !workspace.FreeLookCamera
|| workspace.FirstPersonPreview
|| !IsActiveWindow
|| !IsUnderMouse
|| PickMode != ViewportPickMode.None
|| Gizmo.Pressed.Any )
return;
var rotation = Rotation.From( workspace.CameraAngles );
var movement = Vector3.Zero;
if ( global::Editor.Application.IsKeyDown( KeyCode.W ) )
movement += rotation.Forward;
if ( global::Editor.Application.IsKeyDown( KeyCode.S ) )
movement += rotation.Backward;
if ( global::Editor.Application.IsKeyDown( KeyCode.A ) )
movement += rotation.Left;
if ( global::Editor.Application.IsKeyDown( KeyCode.D ) )
movement += rotation.Right;
if ( movement.IsNearZeroLength )
return;
var fast = global::Editor.Application.KeyboardModifiers
.HasFlag( KeyboardModifiers.Shift );
var speed = workspace.CameraMoveSpeed * 100.0f * (fast ? 8.0f : 1.0f);
workspace.CameraPosition += movement.Normal * speed * RealTime.Delta;
_controller.MarkWorkspacePreferenceChanged( "Free look camera position" );
}
internal static float AdjustCameraSpeed( float currentSpeed, int direction )
{
currentSpeed = Math.Clamp( currentSpeed, 0.25f, 100.0f );
var adjustment = currentSpeed < 5.0f
? 0.25f
: currentSpeed < 20.0f
? 1.0f
: MathF.Round( currentSpeed * 0.1f / 2.5f ) * 2.5f;
return Math.Clamp(
currentSpeed + adjustment * Math.Sign( direction ),
0.25f,
100.0f );
}
internal static float AdjustRotationSnapAngle( float currentAngle, int direction )
{
if ( !WeaponAnimationMath.IsFinite( currentAngle ) )
currentAngle = 15;
var nearest = 0;
var nearestDistance = float.MaxValue;
for ( var index = 0; index < RotationSnapSteps.Length; index++ )
{
var distance = MathF.Abs( currentAngle - RotationSnapSteps[index] );
if ( distance >= nearestDistance )
continue;
nearest = index;
nearestDistance = distance;
}
var target = Math.Clamp(
nearest + Math.Sign( direction ),
0,
RotationSnapSteps.Length - 1 );
return RotationSnapSteps[target];
}
private void DrawCalibration()
{
var document = _controller.Document;
if ( _sourceRenderer.IsValid() )
{
_sourceRenderer!.BoneMergeTarget = null;
_sourceRenderer.WorldTransform = WeaponAnimationMath.Compose(
document.Calibration.PhysicalTransform,
document.Calibration.FramingTransform );
_sourceRenderer.ClearPhysicsBones();
}
if ( _armsRenderer.IsValid() )
{
_armsRenderer!.BoneMergeTarget = null;
_armsRenderer.WorldTransform = Transform.Zero;
}
DrawMeasurement();
DrawAnchors();
if ( document.Workspace.ShowSkeleton )
DrawRendererSkeleton( _sourceRenderer, WeaponAnimatorTheme.Amber, allowXray: true );
// Calibration only ever poses the weapon as a whole, plus its anchors. Selecting a bone
// no longer suppresses the rig gizmo, which previously left the page with no gizmo at all.
if ( CalibrationSelection.Resolve( document, document.Workspace.SelectedControl ) is { } anchor )
DrawSelectedAnchorControl( anchor );
else
DrawWholeRigControl();
}
private void DrawAnimation()
{
if ( !_hostRenderer.IsValid() || _hostSkeleton is null )
return;
var document = _controller.Document;
var clip = document.GetSelectedClip();
var pose = AnimationPoseEvaluator.Evaluate(
document,
_hostSkeleton,
clip,
document.Workspace.TimelineTime,
includeWorkingPose: true );
_hostRenderer!.ClearPhysicsBones();
foreach ( var bone in _hostRenderer.Model.Bones.AllBones )
{
if ( pose.Model.TryGetValue( bone.Name, out var modelTransform ) )
_hostRenderer.SetBoneTransform( bone, modelTransform );
}
SuppressHostRendering();
ApplyWeaponPoseToSourceRenderer( pose );
ApplyArmPoseToRenderer( pose );
document.Binding.PrimaryHand.Reachable = pose.PrimaryReachable;
document.Binding.SupportHand.Reachable = pose.SupportReachable;
DrawGripTethers( pose );
if ( document.Workspace.ShowSkeleton )
DrawHostSkeleton( pose, 1.0f, useRenderedArms: true, allowXray: true );
if ( document.Workspace.ShowOnionSkins && clip is not null )
DrawOnionSkins( clip );
DrawAnimationControl();
}
private void ApplyWeaponPoseToSourceRenderer( EvaluatedPose pose )
{
if ( !_sourceRenderer.IsValid() || _hostSkeleton is null )
return;
var document = _controller.Document;
var sourceRoot = document.Rig.FindBone( document.Rig.SourceSkeletonRootId );
var rootTransform = WeaponAnimationMath.Compose(
document.Calibration.PhysicalTransform,
document.Calibration.FramingTransform );
if ( sourceRoot is not null
&& pose.Model.TryGetValue( "weapon_root", out var desiredRootWorld ) )
{
rootTransform = WeaponPoseProjection.SolveRendererTransform(
sourceRoot.BindModelTransform,
desiredRootWorld );
}
_sourceRenderer!.BoneMergeTarget = null;
_sourceRenderer.WorldTransform = rootTransform;
_sourceRenderer.ClearPhysicsBones();
foreach ( var definition in document.Rig.RetainedBones() )
{
if ( definition.Id.Equals(
document.Rig.SourceSkeletonRootId,
StringComparison.OrdinalIgnoreCase ) )
continue;
var sourceBone = _sourceRenderer.Model.Bones.GetBone( definition.Name );
if ( sourceBone is not null
&& WeaponPoseProjection.TryGetSourceWorldOverride(
document,
pose,
definition,
out var transform )
&& _hostSkeleton.ByName.TryGetValue( definition.Name, out var hostBone )
&& pose.Local.TryGetValue( definition.Name, out var currentLocal )
&& !WeaponPoseProjection.TransformNear(
currentLocal,
_hostSkeleton.GetBindLocal( hostBone ) ) )
{
// Native bind transforms remain untouched; only authored deltas use overrides.
_sourceRenderer.SetBoneTransform(
sourceBone,
_sourceRenderer.WorldTransform.ToLocal( transform ) );
}
}
ApplyPreviewVisibility();
LogSourcePoseDiagnostics( pose );
}
private void ApplyPreviewVisibility()
{
if ( !_sourceRenderer.IsValid() )
return;
var document = _controller.Document;
var clip = document.GetSelectedClip();
foreach ( var part in document.Rig.VisibilityParts )
{
var visible = WeaponVisibilityEvaluator.Evaluate(
part,
clip,
document.Workspace.TimelineTime );
if ( part.RenderMode == VisibilityRenderMode.BodyGroup )
{
if ( string.IsNullOrWhiteSpace( part.BodyGroupName )
|| !_sourceRenderer!.HasBodyGroups )
continue;
try
{
_sourceRenderer.SetBodyGroup(
part.BodyGroupName,
visible
? part.VisibleBodyGroupValue
: part.HiddenBodyGroupValue );
}
catch ( Exception ex )
{
Log.Warning(
$"[Weapon Animator] preview bodygroup '{part.BodyGroupName}' failed: {ex.Message}" );
}
continue;
}
if ( !string.IsNullOrWhiteSpace( part.BodyGroupName )
&& _sourceRenderer!.HasBodyGroups )
{
try
{
_sourceRenderer.SetBodyGroup(
part.BodyGroupName,
part.VisibleBodyGroupValue );
}
catch
{
// Switching back to bone mode should not leave the old bodygroup hidden.
}
}
if ( visible || string.IsNullOrWhiteSpace( part.BoneName ) )
continue;
var root = _sourceRenderer!.Model.Bones.GetBone( part.BoneName );
if ( root is null )
continue;
var collapsed = new Transform(
Vector3.Down * 4000.0f,
Rotation.Identity,
Vector3.One * 0.001f );
var queue = new Queue<BoneCollection.Bone>();
queue.Enqueue( root );
while ( queue.Count > 0 )
{
var bone = queue.Dequeue();
_sourceRenderer.SetBoneTransform( bone, collapsed );
foreach ( var child in bone.Children )
queue.Enqueue( child );
}
}
}
private void ApplyArmPoseToRenderer( EvaluatedPose pose )
{
if ( !_armsRenderer.IsValid() )
return;
_armsRenderer!.BoneMergeTarget = null;
_armsRenderer.WorldTransform = Transform.Zero;
_armsRenderer.ClearPhysicsBones();
foreach ( var bone in _armsRenderer.Model.Bones.AllBones )
{
if ( pose.Model.TryGetValue( bone.Name, out var modelTransform ) )
_armsRenderer.SetBoneTransform( bone, modelTransform );
}
LogArmPoseDiagnostics( pose );
}
private void LogArmPoseDiagnostics( EvaluatedPose pose )
{
if ( _armPoseDiagnosticsLogged || !_armsRenderer.IsValid() )
return;
if ( ++_armPoseDiagnosticFrames < 3 )
return;
_armPoseDiagnosticsLogged = true;
var compared = 0;
var mismatches = 0;
foreach ( var bone in _armsRenderer!.Model.Bones.AllBones )
{
if ( !pose.Model.TryGetValue( bone.Name, out var expected )
|| !_armsRenderer.TryGetBoneTransform( bone, out var actual ) )
continue;
compared++;
if ( WeaponPoseProjection.TransformNear( expected, actual, 0.001f ) )
continue;
mismatches++;
if ( mismatches <= 4 )
{
Log.Warning(
$"[Weapon Animator] arm pose mismatch '{bone.Name}': "
+ $"expected={expected}, actual={actual}." );
}
}
Log.Info(
$"[Weapon Animator] arm pose bridge checked {compared} bones; "
+ $"{mismatches} renderer override mismatches." );
}
private void LogSourcePoseDiagnostics( EvaluatedPose pose )
{
if ( _sourcePoseDiagnosticsLogged || !_sourceRenderer.IsValid() )
return;
if ( ++_sourcePoseDiagnosticFrames < 3 )
return;
_sourcePoseDiagnosticsLogged = true;
var compared = 0;
var mismatches = 0;
var hiddenVisibilityBones = HiddenVisibilityBonesAtPlayhead();
foreach ( var definition in _controller.Document.Rig.RetainedBones() )
{
if ( hiddenVisibilityBones.Contains( definition.Name ) )
continue;
var sourceBone = _sourceRenderer!.Model.Bones.GetBone( definition.Name );
if ( sourceBone is null
|| !WeaponPoseProjection.TryGetSourceWorldOverride(
_controller.Document,
pose,
definition,
out var expected )
|| !_sourceRenderer.TryGetBoneTransform( sourceBone, out var actual ) )
continue;
compared++;
var positionDelta = expected.Position.Distance( actual.Position );
var rotationDelta = MathF.Max(
(expected.Rotation.Forward - actual.Rotation.Forward).Length,
(expected.Rotation.Up - actual.Rotation.Up).Length );
var scaleDelta = (expected.Scale - actual.Scale).Length;
if ( positionDelta <= 0.001f
&& rotationDelta <= 0.001f
&& scaleDelta <= 0.001f )
continue;
mismatches++;
Log.Warning(
$"[Weapon Animator] source pose mismatch '{definition.Name}': "
+ $"position={positionDelta:0.######}, "
+ $"rotation={rotationDelta:0.######}, "
+ $"scale={scaleDelta:0.######}; "
+ $"expected={expected}, actual={actual}." );
}
Log.Info(
$"[Weapon Animator] source pose bridge checked {compared} retained bones; "
+ $"{mismatches} renderer override mismatches." );
if ( _hostSkeleton is not null
&& _hostSkeleton.ByName.TryGetValue( "root", out var hostRoot )
&& _hostSkeleton.ByName.TryGetValue( "weapon_root", out var weaponRoot )
&& pose.Model.TryGetValue( "weapon_root", out var rootWorld )
&& pose.Local.TryGetValue( "weapon_root", out var rootLocal ) )
{
Log.Info(
$"[Weapon Animator] root bridge: hostRootBind={hostRoot.BindModelTransform}, "
+ $"weaponRootBindModel={weaponRoot.BindModelTransform}, "
+ $"weaponRootBindLocal={_hostSkeleton.GetBindLocal( weaponRoot )}, "
+ $"poseRootWorld={rootWorld}, poseRootLocal={rootLocal}, "
+ $"sourceRenderer={_sourceRenderer!.WorldTransform}." );
}
}
private HashSet<string> HiddenVisibilityBonesAtPlayhead()
{
var document = _controller.Document;
var clip = document.GetSelectedClip();
var hidden = document.Rig.VisibilityParts
.Where( x =>
x.RenderMode == VisibilityRenderMode.BoneBranch
&& !WeaponVisibilityEvaluator.Evaluate(
x,
clip,
document.Workspace.TimelineTime ) )
.Select( x => x.BoneName )
.Where( x => !string.IsNullOrWhiteSpace( x ) )
.ToHashSet( StringComparer.OrdinalIgnoreCase );
if ( hidden.Count == 0 )
return hidden;
var changed = true;
while ( changed )
{
changed = false;
foreach ( var bone in document.Rig.RetainedBones() )
{
if ( hidden.Contains( bone.Name )
|| !hidden.Contains( bone.ParentName ) )
continue;
hidden.Add( bone.Name );
changed = true;
}
}
return hidden;
}
private bool RepairLegacyIdleIfNeeded()
{
if ( _legacyIdleRepairVersionChecked == LegacyIdleRepairVersion
|| _controller.Document.ActiveStage != WeaponAnimatorStage.Animate )
return false;
_legacyIdleRepairVersionChecked = LegacyIdleRepairVersion;
var repaired = false;
_controller.Mutate(
"Repair generated Idle bind pose",
document =>
{
var repairedLegacy = WeaponAnimationMigration.RepairLegacyIdleBindPose(
document,
_hostSkeleton );
var repairedSelectionWrites = _hostSkeleton is not null
&& IdleBindPoseService.RepairUnintendedSelectionWrites(
document,
_hostSkeleton );
repaired = repairedLegacy || repairedSelectionWrites;
} );
if ( !repaired )
return false;
LegacyIdleRepaired?.Invoke();
StatusChanged?.Invoke(
"Restored the generated Idle clip to the current calibrated bind pose. "
+ "A versioned backup will be created on save." );
return true;
}
private void SuppressHostRendering()
{
if ( !_hostRenderer.IsValid() )
return;
// The host owns bones only. Its carrier mesh must never enter the authoring viewport.
_hostRenderer!.Tint = Color.Transparent;
_hostRenderer.SceneObject.RenderingEnabled = false;
}
private void OnSelectionChanged()
{
_occlusionCacheValid = false;
_lastOcclusionDiagnostic = "";
Update();
if ( _controller.Document.ActiveStage != WeaponAnimatorStage.Animate )
return;
var selected = _controller.Document.Workspace.SelectedBone;
if ( !selected.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase )
|| selected.Equals( _lastDiagnosticSelection, StringComparison.OrdinalIgnoreCase ) )
return;
_lastDiagnosticSelection = selected;
var clip = _controller.Document.GetSelectedClip();
var rootTrack = clip?.Tracks.FirstOrDefault( x =>
x.Target.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase ) );
Log.Info(
$"[Weapon Animator] Preview diagnostic: selected=weapon_root, "
+ $"sourceModel={_loadedSource}, hostModel={_loadedHost}, "
+ $"sourceScale={_sourceRenderer?.WorldTransform.Scale}, "
+ $"hostRendering={_hostRenderer?.SceneObject.RenderingEnabled}, "
+ $"rootKeys={rootTrack?.Keys.Count ?? 0}, "
+ $"workingOverride={_controller.Document.Workspace.GetWorkingPose(
clip?.Id ?? Guid.Empty,
"weapon_root" ) is not null}." );
}
private void DrawRendererSkeleton(
SkinnedModelRenderer? renderer,
Color color,
bool allowXray = false )
{
if ( !renderer.IsValid() || renderer!.Model is null )
return;
var style = SkeletonOverlayStyle.Resolve(
allowXray && _controller.Document.Workspace.XRaySkeleton,
1.0f );
using ( Gizmo.Scope( "source_skeleton" ) )
{
Gizmo.Draw.IgnoreDepth = style.DrawThroughMeshes;
DrawRendererSkeletonPass( renderer, color, 1.0f );
}
}
private void DrawRendererSkeletonPass(
SkinnedModelRenderer renderer,
Color color,
float alpha )
{
foreach ( var bone in renderer.Model.Bones.AllBones )
{
if ( !renderer.TryGetBoneTransform( bone, out var transform ) )
continue;
if ( bone.Parent is not null
&& renderer.TryGetBoneTransform( bone.Parent, out var parent ) )
{
Gizmo.Draw.Color = color.WithAlpha( 0.55f * alpha );
Gizmo.Draw.Line( parent.Position, transform.Position );
}
using var scope = Gizmo.Scope( $"source_bone:{bone.Name}", transform );
var selected = bone.Name == _controller.Document.Workspace.SelectedBone;
var radius = Math.Clamp( transform.Position.Distance( _camera.WorldPosition ) / 150.0f, 0.1f, 0.7f );
Gizmo.Draw.Color = (selected ? Color.White : color).WithAlpha( alpha );
Gizmo.Draw.SolidSphere(
Vector3.Zero,
selected ? radius * 0.7f : radius * 0.35f,
6,
4 );
Gizmo.Hitbox.DepthBias = 0.01f;
Gizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, radius ) );
if ( Gizmo.IsHovered )
{
Gizmo.Draw.ScreenText( bone.Name, transform.Position, new Vector2( 10, -10 ) );
if ( Gizmo.WasLeftMousePressed )
_controller.SelectBone( bone.Name );
}
}
}
private void DrawHostSkeleton(
EvaluatedPose pose,
float alpha,
bool useRenderedArms = false,
bool allowXray = false )
{
if ( _hostSkeleton is null )
return;
EnsureBoneDepths();
var style = SkeletonOverlayStyle.Resolve(
allowXray && _controller.Document.Workspace.XRaySkeleton,
alpha );
var occludedBones = style.DrawThroughMeshes
&& _controller.Document.Workspace.BoneOcclusionEnabled
? ResolveOccludedBones( pose, useRenderedArms )
: null;
if ( occludedBones is { Count: > 0 } )
{
DrawMixedOcclusionLines(
pose,
useRenderedArms,
occludedBones,
style );
using ( Gizmo.Scope( "host_skeleton_behind" ) )
{
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = SkeletonOverlayStyle.OccludedLineThickness;
DrawHostSkeletonPass(
pose,
style.OccludedAlpha,
useRenderedArms,
occluded: true,
onlyBones: occludedBones,
occlusionStates: occludedBones );
}
}
using ( Gizmo.Scope( "host_skeleton" ) )
{
Gizmo.Draw.IgnoreDepth = style.DrawThroughMeshes;
DrawHostSkeletonPass(
pose,
alpha,
useRenderedArms,
excludedBones: occludedBones,
occlusionStates: occludedBones );
}
}
private IReadOnlySet<string> ResolveOccludedBones(
EvaluatedPose pose,
bool useRenderedArms )
{
var samples = new List<(HostBone Bone, Transform Transform)>();
var showIk = _controller.Document.Workspace.ShowIkBones;
foreach ( var bone in _hostSkeleton!.Bones )
{
if ((SkeletonBoneStyle.Classify( bone ) == SkeletonBoneKind.Ik && !showIk)
|| !TryGetDisplayedBoneTransform(
pose,
bone,
useRenderedArms,
out var transform ) )
continue;
samples.Add( (bone, transform) );
}
var cacheMatches = OcclusionCacheMatches( samples );
if ( cacheMatches && !_occlusionFollowupPending )
return _occludedBones;
if ( !cacheMatches
&& _occlusionCacheValid
&& _sinceOcclusionTrace < (1.0f / 30.0f) )
return _occludedBones;
var completingFollowup = cacheMatches && _occlusionFollowupPending;
_occlusionPose.Clear();
_occludedBones.Clear();
_occlusionCameraTransform = _camera.WorldTransform;
_sinceOcclusionTrace = 0;
foreach ( var sample in samples )
{
_occlusionPose[sample.Bone.Name] = sample.Transform;
var occluded = IsBoneOccluded(
sample.Bone,
sample.Transform.Position,
out var hitDescription );
if ( occluded )
_occludedBones.Add( sample.Bone.Name );
if ( sample.Bone.Name.Equals(
_controller.Document.Workspace.SelectedBone,
StringComparison.OrdinalIgnoreCase ) )
ReportOcclusionDiagnostic(
sample.Bone,
hitDescription,
occluded );
}
_occlusionCacheValid = true;
_occlusionFollowupPending = !completingFollowup;
return _occludedBones;
}
private bool OcclusionCacheMatches(
IReadOnlyList<(HostBone Bone, Transform Transform)> samples )
{
if ( !_occlusionCacheValid
|| samples.Count != _occlusionPose.Count
|| !WeaponPoseProjection.TransformNear(
_occlusionCameraTransform,
_camera.WorldTransform,
0.0005f ) )
return false;
foreach ( var sample in samples )
{
if ( !_occlusionPose.TryGetValue( sample.Bone.Name, out var cached )
|| !WeaponPoseProjection.TransformNear(
cached,
sample.Transform,
0.0005f ) )
return false;
}
return true;
}
private bool IsBoneOccluded(
HostBone target,
Vector3 targetPosition,
out string description )
{
description = "none";
if ( !Scene.IsValid()
|| targetPosition.Distance( _camera.WorldPosition ) <= 0.001f )
return false;
var targetDistance = targetPosition.Distance( _camera.WorldPosition );
var depthClearance = SkeletonOverlayStyle.OcclusionDepthClearance( targetDistance );
var nearestDistance = float.MaxValue;
var sameArmHits = 0;
var oppositeArmHits = 0;
var nearArmHits = 0;
var unknownArmHits = 0;
var sameArmExample = "";
var unknownArmExample = "";
var armTraces = Scene.Trace
.Ray( _camera.WorldPosition, targetPosition )
.WithTag( ArmsOccluderTag )
.UseRenderMeshes( false )
.UseHitboxes( true )
.UsePhysicsWorld( false )
.UseHitPosition( true )
.RunAll();
foreach ( var armTrace in armTraces )
{
var hitBoneName = ResolveArmHitBoneName( armTrace );
var hitSide = !string.IsNullOrWhiteSpace( hitBoneName )
&& _hostSkeleton!.ByName.TryGetValue( hitBoneName, out var hitBone )
? hitBone.ArmSide
: 0;
if ( !SkeletonOcclusionPolicy.IsOccludedByArm(
target.IsWeaponBone,
target.ArmSide,
hitSide ) )
{
if ( hitSide == target.ArmSide && hitSide != 0 )
{
sameArmHits++;
if ( string.IsNullOrWhiteSpace( sameArmExample ) )
sameArmExample = hitBoneName;
}
else
{
unknownArmHits++;
if ( string.IsNullOrWhiteSpace( unknownArmExample ) )
unknownArmExample = hitBoneName;
}
continue;
}
var gap = targetDistance - armTrace.Distance;
if ( !SkeletonOverlayStyle.IsOccludingDepth(
targetDistance,
armTrace.Distance ) )
{
nearArmHits++;
continue;
}
oppositeArmHits++;
if ( armTrace.Distance >= nearestDistance )
continue;
nearestDistance = armTrace.Distance;
description = string.IsNullOrWhiteSpace( hitBoneName )
? $"arms hitbox (unknown bone) at {armTrace.Distance:0.###}"
: $"arms hitbox ({hitBoneName}) at {armTrace.Distance:0.###}";
}
description +=
$"; armHits=opposite:{oppositeArmHits},self:{sameArmHits}"
+ $"({sameArmExample}),near:{nearArmHits},unknown:{unknownArmHits}"
+ $"({unknownArmExample}); target={targetDistance:0.###},"
+ $"clearance={depthClearance:0.###},weaponIgnored=True";
return nearestDistance < float.MaxValue;
}
private string ResolveArmHitBoneName( SceneTraceResult trace )
{
var hitboxBoneName = trace.Hitbox?.Bone?.Name;
if ( !string.IsNullOrWhiteSpace( hitboxBoneName ) )
return hitboxBoneName;
if ( trace.Bone >= 0 && _armsRenderer.IsValid() )
return _armsRenderer!.Model.GetBoneName( trace.Bone );
return "";
}
private void ReportOcclusionDiagnostic(
HostBone target,
string hitDescription,
bool occluded )
{
var diagnostic = $"{target.Name}|{target.ArmSide}|{occluded}";
if ( diagnostic.Equals( _lastOcclusionDiagnostic, StringComparison.Ordinal ) )
return;
_lastOcclusionDiagnostic = diagnostic;
Log.Info(
$"[Weapon Animator] X-ray diagnostic: target={target.Name}, "
+ $"targetSide={target.ArmSide}, firstHit={hitDescription}, "
+ $"reduced={occluded}." );
}
/// <summary>
/// Bone depth drives the overlay gradient. <c>HostSkeleton.Bones</c> is topologically ordered,
/// so one forward pass resolves every depth. <c>BuildCached</c> hands out a shared read-only
/// instance, so the cache is keyed on that instance rather than storing depth on the bones.
/// </summary>
private void EnsureBoneDepths()
{
if ( ReferenceEquals( _boneDepthSource, _hostSkeleton ) )
return;
_boneDepthSource = _hostSkeleton;
_boneDepths.Clear();
_maxBoneDepth = 0;
if ( _hostSkeleton is null )
return;
foreach ( var bone in _hostSkeleton.Bones )
{
var depth = !string.IsNullOrWhiteSpace( bone.ParentName )
&& _boneDepths.TryGetValue( bone.ParentName, out var parentDepth )
? parentDepth + 1
: 0;
_boneDepths[bone.Name] = depth;
if ( depth > _maxBoneDepth
&& SkeletonBoneStyle.Classify( bone ) == SkeletonBoneKind.Arm )
_maxBoneDepth = depth;
}
}
private void DrawHostSkeletonPass(
EvaluatedPose pose,
float alpha,
bool useRenderedArms,
bool occluded = false,
IReadOnlySet<string>? onlyBones = null,
IReadOnlySet<string>? excludedBones = null,
IReadOnlySet<string>? occlusionStates = null )
{
var showIk = _controller.Document.Workspace.ShowIkBones;
foreach ( var bone in _hostSkeleton!.Bones )
{
if ( onlyBones is not null && !onlyBones.Contains( bone.Name ) )
continue;
if ( excludedBones is not null && excludedBones.Contains( bone.Name ) )
continue;
if ( !TryGetDisplayedBoneTransform(
pose,
bone,
useRenderedArms,
out var transform ) )
continue;
var boneStyle = SkeletonBoneStyle.Resolve(
SkeletonBoneStyle.Classify( bone ),
_boneDepths.GetValueOrDefault( bone.Name ),
_maxBoneDepth,
showIk );
// Skipping also drops the hitbox below, so hidden bones stop stealing clicks.
if ( !boneStyle.Visible )
continue;
var color = occluded
? SkeletonOverlayStyle.Occlude( boneStyle.Color )
: boneStyle.Color;
var boneAlpha = alpha * boneStyle.AlphaScale;
var dotScale = occluded ? SkeletonOverlayStyle.OccludedDotScale : 1.0f;
if ( !string.IsNullOrWhiteSpace( bone.ParentName )
&& _hostSkeleton.ByName.TryGetValue( bone.ParentName, out var parentBone )
&& TryGetDisplayedBoneTransform(
pose,
parentBone,
useRenderedArms,
out var parent ) )
{
var parentOccluded = occlusionStates?.Contains( parentBone.Name )
?? occluded;
if ( parentOccluded == occluded )
{
Gizmo.Draw.Color = color.WithAlpha( 0.45f * boneAlpha );
Gizmo.Draw.Line( parent.Position, transform.Position );
}
}
using var scope = Gizmo.Scope( $"host_bone:{bone.Name}", transform );
var selected = bone.Name == _controller.Document.Workspace.SelectedBone;
var radius = Math.Clamp( transform.Position.Distance( _camera.WorldPosition ) / 180.0f, 0.08f, 0.45f )
* boneStyle.RadiusScale;
Gizmo.Draw.Color = (selected ? Color.White : color).WithAlpha( boneAlpha );
if ( boneStyle.Hollow )
Gizmo.Draw.LineSphere( 0, (selected ? radius * 0.7f : radius * 0.45f) * dotScale, 3 );
else
Gizmo.Draw.SolidSphere( 0, (selected ? radius * 0.7f : radius * 0.3f) * dotScale, 5, 4 );
Gizmo.Hitbox.Sphere( new Sphere( 0, radius ) );
if ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed )
_controller.SelectBone( bone.Name );
}
}
private void DrawMixedOcclusionLines(
EvaluatedPose pose,
bool useRenderedArms,
IReadOnlySet<string> occludedBones,
SkeletonOverlayStyle overlay )
{
var showIk = _controller.Document.Workspace.ShowIkBones;
using var scope = Gizmo.Scope( "host_skeleton_occlusion_gradients" );
Gizmo.Draw.IgnoreDepth = true;
foreach ( var bone in _hostSkeleton!.Bones )
{
if ( string.IsNullOrWhiteSpace( bone.ParentName )
|| !_hostSkeleton.ByName.TryGetValue( bone.ParentName, out var parentBone ) )
continue;
var boneOccluded = occludedBones.Contains( bone.Name );
var parentOccluded = occludedBones.Contains( parentBone.Name );
if ( boneOccluded == parentOccluded )
continue;
var boneStyle = SkeletonBoneStyle.Resolve(
SkeletonBoneStyle.Classify( bone ),
_boneDepths.GetValueOrDefault( bone.Name ),
_maxBoneDepth,
showIk );
if ( !boneStyle.Visible )
continue;
var parentStyle = SkeletonBoneStyle.Resolve(
SkeletonBoneStyle.Classify( parentBone ),
_boneDepths.GetValueOrDefault( parentBone.Name ),
_maxBoneDepth,
showIk );
if ( !parentStyle.Visible
|| !TryGetDisplayedBoneTransform(
pose,
bone,
useRenderedArms,
out var boneTransform )
|| !TryGetDisplayedBoneTransform(
pose,
parentBone,
useRenderedArms,
out var parentTransform ) )
continue;
var startVisual = overlay.ResolveLineVisual(
parentStyle,
parentOccluded );
var endVisual = overlay.ResolveLineVisual(
boneStyle,
boneOccluded );
var delta = boneTransform.Position - parentTransform.Position;
for ( var segment = 0;
segment < SkeletonOverlayStyle.OcclusionGradientSegments;
segment++ )
{
var startFraction =
segment / (float)SkeletonOverlayStyle.OcclusionGradientSegments;
var endFraction =
(segment + 1) / (float)SkeletonOverlayStyle.OcclusionGradientSegments;
var visual = SkeletonLineVisual.Lerp(
startVisual,
endVisual,
(startFraction + endFraction) * 0.5f );
Gizmo.Draw.Color = visual.Color;
Gizmo.Draw.LineThickness = visual.Thickness;
Gizmo.Draw.Line(
parentTransform.Position + (delta * startFraction),
parentTransform.Position + (delta * endFraction) );
}
}
}
private bool TryGetDisplayedBoneTransform(
EvaluatedPose pose,
HostBone bone,
bool useRenderedArms,
out Transform transform )
{
if ( useRenderedArms && !bone.IsWeaponBone && _armsRenderer.IsValid() )
{
var rendererBone = _armsRenderer!.Model.Bones.GetBone( bone.Name );
if ( rendererBone is not null
&& _armsRenderer.TryGetBoneTransform( rendererBone, out transform ) )
return true;
}
return pose.Model.TryGetValue( bone.Name, out transform );
}
private void DrawOnionSkins( WeaponAnimationClip clip )
{
if ( _hostSkeleton is null )
return;
var step = 1.0f / MathF.Max( clip.SampleRate, 1 );
// Onion skins are context only. They share bone names with the live skeleton, so leaving
// them interactive would put duplicate hit targets on neighbouring frames.
using var scope = Gizmo.Scope( "onion_skins" );
Gizmo.Hitbox.CanInteract = false;
foreach ( var offset in new[] { -step, step } )
{
var time = Math.Clamp(
_controller.Document.Workspace.TimelineTime + offset,
0,
clip.Duration );
var onion = AnimationPoseEvaluator.Evaluate(
_controller.Document,
_hostSkeleton,
clip,
time );
DrawHostSkeleton( onion, 0.18f );
}
}
private void DrawGripTethers( EvaluatedPose pose )
{
if ( _controller.Document.Binding.PrimaryHand.IsBound )
{
DrawTether(
_controller.Document.Binding.PrimaryHand,
pose,
pose.PrimaryHandGoal,
_controller.Document.Binding.PrimaryHand.Reachable,
"hand_R" );
}
if ( _controller.Document.Binding.Configuration == GripConfiguration.TwoHanded )
{
if ( _controller.Document.Binding.SupportHand.IsBound )
{
DrawTether(
_controller.Document.Binding.SupportHand,
pose,
pose.SupportHandGoal,
_controller.Document.Binding.SupportHand.Reachable,
"hand_L" );
}
}
}
private static void DrawTether(
RigTarget target,
EvaluatedPose pose,
Transform? solvedGoal,
bool reachable,
string handBone )
{
if ( !pose.Model.TryGetValue( handBone, out var hand ) )
return;
// Draw the goal the IK actually solved toward. The raw binding transform is the bind-time
// value, so it drifts away from the hand as soon as a clip animates the control - which made
// the tether read as "far out of reach" while the hand sat correctly on the weapon.
Transform targetTransform;
if ( solvedGoal is { } goal )
{
targetTransform = goal;
}
else
{
targetTransform = target.Transform;
if ( !string.IsNullOrWhiteSpace( target.AttachedBone )
&& pose.Model.TryGetValue( target.AttachedBone, out var attached ) )
{
targetTransform = new Transform(
attached.PointToWorld( target.Transform.Position ),
attached.Rotation * target.Transform.Rotation );
}
}
// Scoped so the colour and thickness do not leak into whatever draws next.
using var scope = Gizmo.Scope( "grip_tether" );
Gizmo.Draw.Color = reachable ? WeaponAnimatorTheme.Green : WeaponAnimatorTheme.Coral;
Gizmo.Draw.LineThickness = 2.5f;
Gizmo.Draw.Line( hand.Position, targetTransform.Position );
Gizmo.Draw.SolidSphere( targetTransform.Position, 0.18f, 8, 6 );
}
private void DrawSelectedAnchorControl( WeaponAnchor anchor )
{
if ( !_sourceRenderer.IsValid() )
return;
var sourceTransform = _sourceRenderer!.WorldTransform;
var liveWorld = new Transform(
sourceTransform.PointToWorld( anchor.LocalPosition ),
sourceTransform.Rotation * anchor.LocalRotation );
var token = $"anchor:{anchor.Kind}";
var dragging = IsCalibrationDrag( token );
var startWorld = dragging ? _calibrationGizmoStartWorld : liveWorld;
var startLocal = dragging
? _calibrationGizmoStartLocal
: new Transform( anchor.LocalPosition, anchor.LocalRotation );
var basis = CalibrationGizmoBasis( startWorld );
// Scale is deliberately dropped from the scope. Feeding the gizmo a scaled transform
// resizes its handles by the calibration scale, and feeding it a rotated one makes the
// handles point along the rig's local axes while the result is applied in world space.
using var scope = Gizmo.Scope(
$"anchor_control:{anchor.Kind}",
new Transform( startWorld.Position, basis ) );
Gizmo.Draw.Color = AnchorColor( anchor.Kind );
Gizmo.Draw.LineSphere( new Sphere( Vector3.Zero, 0.24f ) );
if ( TransformMode == WeaponAnimatorTransformMode.Rotate )
{
if ( Gizmo.Control.Rotate( "anchor_rotate", Rotation.Identity, out var delta ) )
{
BeginCalibrationGizmoDrag(
token,
$"Rotate {anchor.Name} anchor",
liveWorld,
new Transform( anchor.LocalPosition, anchor.LocalRotation ) );
// Rotate reports the total rotation since the grab, so it applies to the start.
var snapped = SnapRotation( delta );
var rotation = _controller.Document.Workspace.LocalGizmos
? (startLocal.Rotation * snapped).Normal
: (sourceTransform.Rotation.Inverse
* snapped
* sourceTransform.Rotation
* startLocal.Rotation).Normal;
_controller.UpdateContinuousEdit( document =>
{
var selected = document.Calibration.FindAnchor( anchor.Id );
if ( selected is null )
return;
selected.LocalRotation = rotation;
document.Calibration.Confirmed = false;
} );
}
}
else if ( TransformMode == WeaponAnimatorTransformMode.Move
&& Gizmo.Control.Position( "anchor_move", Vector3.Zero, out var moveDelta, basis ) )
{
BeginCalibrationGizmoDrag(
token,
$"Move {anchor.Name} anchor",
liveWorld,
new Transform( anchor.LocalPosition, anchor.LocalRotation ) );
_calibrationGizmoMoveDelta += moveDelta;
var world = SnapPositionDelta(
_calibrationGizmoStartWorld.Position,
_calibrationGizmoMoveDelta,
basis );
var local = sourceTransform.PointToLocal( world );
_controller.UpdateContinuousEdit( document =>
{
var selected = document.Calibration.FindAnchor( anchor.Id );
if ( selected is null )
return;
selected.LocalPosition = local;
document.Calibration.Confirmed = false;
} );
}
}
private void DrawWholeRigControl()
{
var document = _controller.Document;
var framing = document.Workspace.FirstPersonPreview;
var live = framing
? document.Calibration.FramingTransform
: document.Calibration.PhysicalTransform;
var token = framing ? "rig:framing" : "rig:physical";
var dragging = IsCalibrationDrag( token );
var start = dragging ? _calibrationGizmoStartWorld : live;
var basis = CalibrationGizmoBasis( start );
using var scope = Gizmo.Scope(
"whole_rig",
new Transform( start.Position, basis ) );
Gizmo.Draw.Color = WeaponAnimatorTheme.Amber;
Gizmo.Draw.LineSphere( new Sphere( Vector3.Zero, 0.3f ) );
if ( TransformMode == WeaponAnimatorTransformMode.Rotate )
{
if ( Gizmo.Control.Rotate( "rig_rotate", Rotation.Identity, out var delta ) )
{
BeginCalibrationGizmoDrag( token, "Refine whole-rig rotation", live, live );
var snapped = SnapRotation( delta );
var rotation = document.Workspace.LocalGizmos
? (_calibrationGizmoStartWorld.Rotation * snapped).Normal
: (snapped * _calibrationGizmoStartWorld.Rotation).Normal;
_controller.UpdateContinuousEdit( d =>
SetRigTransform( d, framing, target => target.WithRotation( rotation ) ) );
}
}
else if ( TransformMode == WeaponAnimatorTransformMode.Move
&& Gizmo.Control.Position( "rig_move", Vector3.Zero, out var moveDelta, basis ) )
{
BeginCalibrationGizmoDrag( token, "Refine whole-rig position", live, live );
_calibrationGizmoMoveDelta += moveDelta;
var position = SnapPositionDelta(
_calibrationGizmoStartWorld.Position,
_calibrationGizmoMoveDelta,
basis );
_controller.UpdateContinuousEdit( d =>
SetRigTransform( d, framing, target => target.WithPosition( position ) ) );
}
else if ( TransformMode == WeaponAnimatorTransformMode.Scale
&& Gizmo.Control.Scale( "rig_scale", Vector3.Zero, out var scaleDelta, basis ) )
{
BeginCalibrationGizmoDrag( token, "Refine whole-rig scale", live, live );
_calibrationGizmoScaleDelta += scaleDelta / 0.01f;
// Rig scale is uniform, so respond to whichever handle is being dragged rather than
// only the X axis. The uniform centre handle reports all three equally.
var dominant = DominantAxis( _calibrationGizmoScaleDelta );
var factor = MathF.Max(
1.0f + dominant * ScaleGizmoSensitivity,
0.0001f );
var uniform = MathF.Max(
_calibrationGizmoStartWorld.Scale.x * factor,
0.0001f );
_controller.UpdateContinuousEdit( d =>
{
SetRigTransform( d, framing, target => target.WithScale( uniform ) );
if ( !framing )
d.Calibration.UniformScale = uniform;
} );
}
}
internal static float DominantAxis( Vector3 value )
{
var dominant = value.x;
if ( MathF.Abs( value.y ) > MathF.Abs( dominant ) )
dominant = value.y;
if ( MathF.Abs( value.z ) > MathF.Abs( dominant ) )
dominant = value.z;
return dominant;
}
// The whole rig and its anchors are authored in world space unless Local is toggled on.
private Rotation CalibrationGizmoBasis( Transform start ) =>
_controller.Document.Workspace.LocalGizmos
? start.Rotation
: Rotation.Identity;
private static void SetRigTransform(
WeaponAnimationDocument document,
bool framing,
Func<Transform, Transform> edit )
{
if ( framing )
{
document.Calibration.FramingTransform =
edit( document.Calibration.FramingTransform );
return;
}
document.Calibration.PhysicalTransform =
edit( document.Calibration.PhysicalTransform );
document.Calibration.Confirmed = false;
}
private bool IsCalibrationDrag( string target ) =>
_calibrationGizmoTarget.Equals( target, StringComparison.Ordinal );
// Calibration gizmos accumulate into one undo entry, matching the animation gizmos below.
private void BeginCalibrationGizmoDrag(
string target,
string description,
Transform startWorld,
Transform startLocal )
{
// Reopen if an unrelated mutation closed our continuous edit mid-drag, otherwise
// UpdateContinuousEdit silently discards the rest of the drag.
if ( IsCalibrationDrag( target ) && _controller.IsContinuousEditActive )
return;
EndCalibrationGizmoDrag();
_calibrationGizmoTarget = target;
_calibrationGizmoStartWorld = startWorld;
_calibrationGizmoStartLocal = startLocal;
_calibrationGizmoMoveDelta = Vector3.Zero;
_calibrationGizmoScaleDelta = Vector3.Zero;
_controller.BeginContinuousEdit( description );
}
private void FinishCalibrationGizmoDragIfReleased()
{
if ( string.IsNullOrEmpty( _calibrationGizmoTarget ) )
return;
// Requires both signals. Gizmo.Pressed can read false between frames while the mouse is
// still held, and ending on that alone splits one drag into an undo entry per frame.
if ( Gizmo.Pressed.Any
|| global::Editor.Application.MouseButtons.HasFlag( MouseButtons.Left ) )
return;
EndCalibrationGizmoDrag();
}
private void EndCalibrationGizmoDrag()
{
if ( string.IsNullOrEmpty( _calibrationGizmoTarget ) )
return;
_calibrationGizmoTarget = "";
_calibrationGizmoMoveDelta = Vector3.Zero;
_calibrationGizmoScaleDelta = Vector3.Zero;
_controller.EndContinuousEdit();
}
private void DrawAnimationControl()
{
var context = SelectionTransformContext.Resolve( _controller );
if ( context is null )
return;
var dragging = _animationGizmoTarget.Equals(
context.Target,
StringComparison.OrdinalIgnoreCase );
var startWorld = dragging ? _animationGizmoStartWorld : context.WorldTransform;
var basis = context.LocalSpace
? startWorld.Rotation
: Rotation.Identity;
var gizmoTransform = new Transform( startWorld.Position, basis );
using var scope = Gizmo.Scope( $"animate:{context.Target}", gizmoTransform );
Gizmo.Draw.Color = context.Kind == RigControlKind.Weapon
? WeaponAnimatorTheme.Amber
: WeaponAnimatorTheme.Cyan;
Gizmo.Draw.LineSphere( new Sphere( 0, 0.25f ) );
if ( TransformMode == WeaponAnimatorTransformMode.Rotate )
{
if ( Gizmo.Control.Rotate( "rotate", Rotation.Identity, out var delta ) )
{
BeginAnimationGizmoDrag( context );
var snapped = SnapRotation( delta );
var local = _animationGizmoStartLocal;
if ( context.LocalSpace )
{
local.Rotation = (_animationGizmoStartLocal.Rotation * snapped).Normal;
}
else
{
var editedWorld = _animationGizmoStartWorld.WithRotation(
(snapped * _animationGizmoStartWorld.Rotation).Normal );
local = WorldToLocal( editedWorld, _animationGizmoStartParent );
}
_controller.UpdateTransformEditContinuous(
context.Target,
context.Kind,
local );
}
}
else if ( TransformMode == WeaponAnimatorTransformMode.Move
&& Gizmo.Control.Position( "move", Vector3.Zero, out var delta, basis ) )
{
BeginAnimationGizmoDrag( context );
_animationGizmoMoveDelta += delta;
var position = SnapPositionDelta(
_animationGizmoStartWorld.Position,
_animationGizmoMoveDelta,
basis );
var editedWorld = _animationGizmoStartWorld.WithPosition( position );
var local = WorldToLocal( editedWorld, _animationGizmoStartParent );
_controller.UpdateTransformEditContinuous(
context.Target,
context.Kind,
local );
}
else if ( TransformMode == WeaponAnimatorTransformMode.Scale
&& Gizmo.Control.Scale( "scale", Vector3.Zero, out var scaleDelta, basis ) )
{
BeginAnimationGizmoDrag( context );
_animationGizmoScaleDelta += scaleDelta / 0.01f;
var local = ScaleFromStart(
_animationGizmoStartLocal,
_animationGizmoStartWorld,
_animationGizmoStartParent,
context.LocalSpace,
_animationGizmoScaleDelta );
_controller.UpdateTransformEditContinuous(
context.Target,
context.Kind,
local );
}
}
private void BeginAnimationGizmoDrag( SelectionTransformContext context )
{
if ( _animationGizmoTarget.Equals(
context.Target,
StringComparison.OrdinalIgnoreCase )
&& _animationGizmoKind == context.Kind )
return;
EndAnimationGizmoDrag();
_animationGizmoTarget = context.Target;
_animationGizmoKind = context.Kind;
_animationGizmoStartLocal = context.LocalTransform;
_animationGizmoStartWorld = context.WorldTransform;
_animationGizmoStartParent = context.ParentTransform;
_animationGizmoMoveDelta = Vector3.Zero;
_animationGizmoScaleDelta = Vector3.Zero;
_controller.BeginContinuousEdit(
$"{TransformModeName( TransformMode )} {context.Target}" );
}
private void FinishAnimationGizmoDragIfReleased()
{
if ( string.IsNullOrWhiteSpace( _animationGizmoTarget )
|| Gizmo.Pressed.Any )
return;
EndAnimationGizmoDrag();
}
private void EndAnimationGizmoDrag()
{
if ( string.IsNullOrWhiteSpace( _animationGizmoTarget ) )
return;
_animationGizmoTarget = "";
_animationGizmoMoveDelta = Vector3.Zero;
_animationGizmoScaleDelta = Vector3.Zero;
_animationGizmoStartParent = null;
_controller.EndContinuousEdit();
}
private Rotation SnapRotation( Rotation delta ) =>
_controller.Document.Workspace.SnapRotation ? Gizmo.Snap( delta ) : delta;
private Vector3 SnapPositionDelta( Vector3 start, Vector3 movement, Rotation localSpace )
{
if ( !_controller.Document.Workspace.SnapPosition )
return start + movement;
return Gizmo.Snap( start, movement, localSpace );
}
internal static Transform WorldToLocal( Transform world, Transform? parent ) =>
parent is null ? world : parent.Value.ToLocal( world );
internal static Transform ScaleFromStart(
Transform startLocal,
Transform startWorld,
Transform? parent,
bool localSpace,
Vector3 accumulatedDelta )
{
var factor = ClampScale(
Vector3.One + accumulatedDelta * ScaleGizmoSensitivity );
if ( localSpace )
return startLocal.WithScale(
ClampScale( startLocal.Scale * factor ) );
var editedWorld = startWorld.WithScale(
ClampScale( startWorld.Scale * factor ) );
return WorldToLocal( editedWorld, parent );
}
private static Vector3 ClampScale( Vector3 scale ) =>
new(
MathF.Max( scale.x, 0.0001f ),
MathF.Max( scale.y, 0.0001f ),
MathF.Max( scale.z, 0.0001f ) );
private void DrawMeasurement()
{
var measurement = _controller.Document.Calibration.Measurement;
if ( !measurement.HasFirstPoint )
return;
var transform = _sourceRenderer?.WorldTransform ?? Transform.Zero;
var a = transform.PointToWorld( measurement.FirstPoint );
Gizmo.Draw.Color = WeaponAnimatorTheme.Cyan;
Gizmo.Draw.SolidSphere( a, 0.15f, 8, 6 );
if ( !measurement.HasSecondPoint )
return;
var b = transform.PointToWorld( measurement.SecondPoint );
Gizmo.Draw.SolidSphere( b, 0.15f, 8, 6 );
Gizmo.Draw.LineThickness = 2;
Gizmo.Draw.Line( a, b );
Gizmo.Draw.ScreenText(
$"{measurement.FirstPoint.Distance( measurement.SecondPoint ):0.###} source units",
(a + b) * 0.5f,
new Vector2( 8, -8 ) );
}
private void DrawAnchors()
{
var transform = _sourceRenderer?.WorldTransform ?? Transform.Zero;
foreach ( var anchor in _controller.Document.Calibration.Anchors )
{
var world = transform.PointToWorld( anchor.LocalPosition );
var color = AnchorColor( anchor.Kind );
var markerScale = Math.Clamp( world.Distance( _camera.WorldPosition ) / 75.0f, 0.45f, 1.4f );
var labelOffset = AnchorLabelOffset( anchor.Kind );
var leaderEnd = world
+ _camera.WorldRotation.Right * labelOffset.x * markerScale
+ _camera.WorldRotation.Up * labelOffset.y * markerScale;
Gizmo.Draw.Color = color.WithAlpha( 0.75f );
Gizmo.Draw.LineThickness = 1.5f;
Gizmo.Draw.Line( world, leaderEnd );
Gizmo.Draw.ScreenText(
$"[{AnchorCode( anchor.Kind )}] {CalibrationSelection.DisplayName( anchor ).ToUpperInvariant()}",
leaderEnd,
new Vector2( 6, -6 ),
size: 11 );
var token = CalibrationSelection.Anchor( anchor );
using var scope = Gizmo.Scope(
$"anchor:{anchor.Id:N}",
new Transform( world, transform.Rotation * anchor.LocalRotation ) );
var selected = _controller.Document.Workspace.SelectedControl == token;
Gizmo.Draw.Color = selected ? Color.White : color;
Gizmo.Draw.SolidSphere( Vector3.Zero, selected ? 0.24f : 0.18f, 8, 6 );
Gizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, 0.32f ) );
if ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed )
_controller.SelectControl( token );
}
var rear = _controller.Document.Calibration.GetAnchor( AnchorKind.RearBore );
var front = _controller.Document.Calibration.GetAnchor( AnchorKind.FrontBore );
if ( rear is null || front is null )
return;
Gizmo.Draw.Color = WeaponAnimatorTheme.Amber;
Gizmo.Draw.LineThickness = 2;
Gizmo.Draw.Arrow(
transform.PointToWorld( rear.LocalPosition ),
transform.PointToWorld( front.LocalPosition ),
0.6f,
0.25f );
}
private void DrawScreenGuides()
{
var document = _controller.Document;
if ( !document.Workspace.ShowGuides )
return;
var viewport = new Rect( 0, 0, Size.x, Size.y );
var guideAspect = GuideAspect( document.Calibration.AspectGuide );
var viewportAspect = Size.x / MathF.Max( Size.y, 1 );
Rect guide;
if ( viewportAspect > guideAspect )
{
var width = Size.y * guideAspect;
guide = new Rect( (Size.x - width) * 0.5f, 0, width, Size.y );
}
else
{
var height = Size.x / guideAspect;
guide = new Rect( 0, (Size.y - height) * 0.5f, Size.x, height );
}
Gizmo.Draw.ScreenRect(
viewport,
Color.Transparent,
borderColor: Color.White.WithAlpha( 0.05f ),
borderSize: new Vector4( 1 ) );
Gizmo.Draw.ScreenRect(
guide,
Color.Transparent,
borderColor: Color.White.WithAlpha( 0.35f ),
borderSize: new Vector4( 1 ) );
if ( document.Calibration.ShowSafeArea )
{
Gizmo.Draw.ScreenRect(
guide.Shrink( guide.Width * 0.05f, guide.Height * 0.05f ),
Color.Transparent,
borderColor: WeaponAnimatorTheme.Cyan.WithAlpha( 0.24f ),
borderSize: new Vector4( 1 ) );
}
if ( document.Calibration.ShowCrosshair )
{
Gizmo.Draw.Color = Color.White.WithAlpha( 0.65f );
Gizmo.Draw.ScreenText( "+", guide.Center, size: 19, flags: TextFlag.Center );
}
Gizmo.Draw.Color = WeaponAnimatorTheme.Muted;
var mode = document.Workspace.FirstPersonPreview
? "VIEWMODEL CAMERA"
: document.Workspace.FreeLookCamera
? "FREE LOOK"
: "ORBIT";
Gizmo.Draw.ScreenText(
$"{mode} · {document.Calibration.AspectGuide} · {document.Calibration.HorizontalFov:0}° HFOV",
new Vector2( 12, 52 ),
size: 10 );
}
private void DrawViewportToolReadout()
{
Gizmo.Draw.ScreenRect(
new Rect( 109, 15, 1, 18 ),
Color.White.WithAlpha( 0.14f ) );
Gizmo.Draw.ScreenRect(
new Rect( 157, 15, 1, 18 ),
Color.White.WithAlpha( 0.14f ) );
Gizmo.Draw.ScreenRect(
new Rect( MathF.Max( Width - 47, 66 ), 15, 1, 18 ),
Color.White.WithAlpha( 0.14f ) );
Gizmo.Draw.ScreenRect(
TransformReadoutRect,
WeaponAnimatorTheme.Background.WithAlpha( 0.25f ) );
var text = new TextRendering.Scope
{
Text = _transformModeText,
TextColor = WeaponAnimatorTheme.Text.WithAlpha( 0.78f ),
FontSize = 10 * global::Editor.Application.DpiScale,
FontName = "Inter",
FontWeight = 500,
LineHeight = 1
};
Gizmo.Draw.ScreenText(
text,
new Vector2(
TransformReadoutRect.Left + 6,
TransformReadoutRect.Center.y ),
TextFlag.LeftCenter );
}
private void DrawCameraSpeedOverlay()
{
if ( _sinceCameraSpeedChanged >= 1.8f )
return;
var elapsed = (float)_sinceCameraSpeedChanged;
var alpha = elapsed <= 0.9f
? 1.0f
: 1.0f - Math.Clamp( (elapsed - 0.9f) / 0.9f, 0, 1 );
var rect = new Rect(
MathF.Max( (Width - 150) * 0.5f, 0 ),
Width >= 720 ? 10 : Width >= 620 ? 46 : 82,
150,
28 );
Gizmo.Draw.ScreenRect(
rect,
WeaponAnimatorTheme.Background.WithAlpha( 0.55f * alpha ) );
var text = new TextRendering.Scope
{
Text = $"CAMERA SPEED {_controller.Document.Workspace.CameraMoveSpeed:0.##}×",
TextColor = WeaponAnimatorTheme.Text.WithAlpha( 0.9f * alpha ),
FontSize = 10 * global::Editor.Application.DpiScale,
FontName = "Inter",
FontWeight = 500,
LineHeight = 1
};
Gizmo.Draw.ScreenText( text, rect.Center, TextFlag.Center );
}
private bool TryPickSourceSurface( Vector2 localPosition, out Vector3 modelPosition )
{
modelPosition = default;
if ( !_sourceRenderer.IsValid() || _sourceRenderer!.Model is null )
return false;
var ray = GetRay( localPosition );
var localRay = ray.ToLocal( _sourceRenderer.WorldTransform );
var trace = _sourceRenderer.Model.Trace.Ray( localRay, 8192 ).Run();
if ( !trace.Hit )
return false;
modelPosition = trace.HitPosition;
return true;
}
private void ApplyPickedPoint( Vector3 localPosition )
{
var mode = PickMode;
var anchorId = PickAnchorId;
PickMode = ViewportPickMode.None;
PickAnchorId = default;
var token = "";
_controller.Mutate( $"Set {PickLabel( mode )}", document =>
{
var measurement = document.Calibration.Measurement;
switch ( mode )
{
case ViewportPickMode.MeasurementFirst:
measurement.FirstPoint = localPosition;
measurement.HasFirstPoint = true;
break;
case ViewportPickMode.MeasurementSecond:
measurement.SecondPoint = localPosition;
measurement.HasSecondPoint = true;
break;
case ViewportPickMode.CustomAnchor:
// Placing an existing custom anchor must not disturb its stored attachment name.
if ( document.Calibration.FindAnchor( anchorId ) is not { } custom )
break;
custom.BoneName = document.Workspace.SelectedBone;
custom.LocalPosition = localPosition;
document.Calibration.Confirmed = false;
token = CalibrationSelection.Anchor( custom );
break;
default:
var kind = PickAnchorKind( mode );
document.Calibration.SetAnchor( new WeaponAnchor
{
Kind = kind,
Name = CalibrationSelection.DisplayName( kind ),
BoneName = document.Workspace.SelectedBone,
LocalPosition = localPosition
} );
document.Calibration.Confirmed = false;
token = CalibrationSelection.Anchor( kind );
break;
}
} );
if ( !string.IsNullOrEmpty( token ) )
_controller.SelectControl( token );
StatusChanged?.Invoke( $"{PickLabel( mode )} set at {localPosition}." );
}
private static AnchorKind PickAnchorKind( ViewportPickMode mode ) => mode switch
{
ViewportPickMode.GripAnchor => AnchorKind.Grip,
ViewportPickMode.RearBoreAnchor => AnchorKind.RearBore,
ViewportPickMode.FrontBoreAnchor => AnchorKind.FrontBore,
ViewportPickMode.MuzzleAnchor => AnchorKind.Muzzle,
ViewportPickMode.EjectAnchor => AnchorKind.Eject,
_ => AnchorKind.Custom
};
private static string PickLabel( ViewportPickMode mode ) => mode switch
{
ViewportPickMode.MeasurementFirst => "measurement point A",
ViewportPickMode.MeasurementSecond => "measurement point B",
ViewportPickMode.GripAnchor => "primary grip",
ViewportPickMode.RearBoreAnchor => "alignment marker — rear",
ViewportPickMode.FrontBoreAnchor => "alignment marker — front",
ViewportPickMode.MuzzleAnchor => "muzzle",
ViewportPickMode.EjectAnchor => "eject",
ViewportPickMode.CustomAnchor => "custom anchor",
_ => "point"
};
private static Color AnchorColor( AnchorKind kind ) => kind switch
{
AnchorKind.Grip => WeaponAnimatorTheme.Cyan,
AnchorKind.RearBore => new Color( 0.64f, 0.48f, 0.95f ),
AnchorKind.FrontBore => WeaponAnimatorTheme.Amber,
AnchorKind.Muzzle => WeaponAnimatorTheme.Coral,
AnchorKind.Eject => WeaponAnimatorTheme.Green,
_ => Color.White
};
private static Vector2 AnchorLabelOffset( AnchorKind kind ) => kind switch
{
AnchorKind.Grip => new Vector2( -1.8f, 1.1f ),
AnchorKind.RearBore => new Vector2( 1.5f, 1.7f ),
AnchorKind.FrontBore => new Vector2( 1.7f, 0.8f ),
AnchorKind.Muzzle => new Vector2( 2.1f, -0.5f ),
AnchorKind.Eject => new Vector2( -1.7f, 1.8f ),
_ => new Vector2( 1.5f, 1.0f )
};
private static string AnchorCode( AnchorKind kind ) => kind switch
{
AnchorKind.Grip => "G",
AnchorKind.RearBore => "AR",
AnchorKind.FrontBore => "AF",
AnchorKind.Muzzle => "M",
AnchorKind.Eject => "E",
_ => "A"
};
private static float GuideAspect( string guide ) => guide switch
{
"4:3" => 4.0f / 3.0f,
"21:9" => 21.0f / 9.0f,
_ => 16.0f / 9.0f
};
}