Editor UI panels for the weapon animator calibration workflow. Defines viewport pick modes and selection token helpers, a scrub handle widget for numeric scrubbing, a rig audit list for bone selection/classification, a calibration inspector with measurement, anchor management, numeric transform editors and preview controls, and a small validation status widget.
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using Editor;
using Sandbox;
namespace SboxWeaponAnimator.Editor;
public enum ViewportPickMode
{
None,
MeasurementFirst,
MeasurementSecond,
GripAnchor,
RearBoreAnchor,
FrontBoreAnchor,
MuzzleAnchor,
EjectAnchor,
CustomAnchor
}
public static class CalibrationSelection
{
private const string AnchorPrefix = "@anchor:";
public static string Anchor( AnchorKind kind ) => $"{AnchorPrefix}{kind}";
/// <summary>
/// Custom anchors carry their id in the token, because a weapon may hold several of them.
/// </summary>
public static string Anchor( WeaponAnchor anchor ) =>
anchor.Kind == AnchorKind.Custom
? $"{AnchorPrefix}{AnchorKind.Custom}:{anchor.Id:N}"
: Anchor( anchor.Kind );
public static string DisplayName( AnchorKind kind ) => kind switch
{
AnchorKind.Grip => "Primary grip",
AnchorKind.RearBore => "Alignment marker — rear",
AnchorKind.FrontBore => "Alignment marker — front",
AnchorKind.Muzzle => "Muzzle",
AnchorKind.Eject => "Eject",
_ => "Custom anchor"
};
public static string DisplayName( WeaponAnchor anchor ) =>
anchor.Kind == AnchorKind.Custom && !string.IsNullOrWhiteSpace( anchor.Name )
? anchor.Name
: DisplayName( anchor.Kind );
public static bool TryGetAnchor( string control, out AnchorKind kind )
{
kind = default;
if ( !control.StartsWith( AnchorPrefix, StringComparison.Ordinal ) )
return false;
var body = control[AnchorPrefix.Length..];
var separator = body.IndexOf( ':' );
return Enum.TryParse( separator >= 0 ? body[..separator] : body, out kind );
}
public static bool TryGetCustomAnchorId( string control, out Guid id )
{
id = Guid.Empty;
if ( !control.StartsWith( AnchorPrefix, StringComparison.Ordinal ) )
return false;
var body = control[AnchorPrefix.Length..];
var separator = body.IndexOf( ':' );
return separator >= 0
&& Guid.TryParseExact( body[(separator + 1)..], "N", out id );
}
/// <summary>
/// Resolves a selection token to its anchor, by id for custom anchors and by kind otherwise.
/// </summary>
public static WeaponAnchor? Resolve( WeaponAnimationDocument document, string control ) =>
TryGetCustomAnchorId( control, out var id )
? document.Calibration.FindAnchor( id )
: TryGetAnchor( control, out var kind )
? document.Calibration.GetAnchor( kind )
: null;
}
internal sealed class ScrubHandle : Widget
{
private readonly string _text;
private readonly Color _accent;
private readonly float _sensitivity;
private readonly Func<float> _getValue;
private readonly Action _begin;
private readonly Action<float> _preview;
private readonly Action _end;
private bool _dragging;
private float _startX;
private float _startValue;
public ScrubHandle(
string text,
Color accent,
float sensitivity,
Func<float> getValue,
Action begin,
Action<float> preview,
Action end,
Widget parent ) : base( parent )
{
_text = text;
_accent = accent;
_sensitivity = sensitivity;
_getValue = getValue;
_begin = begin;
_preview = preview;
_end = end;
FixedWidth = 20;
FixedHeight = 26;
MouseTracking = true;
Cursor = CursorShape.SizeH;
ToolTip = $"Drag {_text} horizontally to adjust";
}
protected override void OnMousePress( MouseEvent e )
{
if ( !e.LeftMouseButton )
{
base.OnMousePress( e );
return;
}
_dragging = true;
_startX = e.ScreenPosition.x;
_startValue = _getValue();
_begin();
e.Accepted = true;
}
protected override void OnMouseMove( MouseEvent e )
{
if ( !_dragging )
{
base.OnMouseMove( e );
return;
}
_preview( _startValue + (e.ScreenPosition.x - _startX) * _sensitivity );
e.Accepted = true;
}
protected override void OnMouseReleased( MouseEvent e )
{
if ( !_dragging || e.Button != MouseButtons.Left )
{
base.OnMouseReleased( e );
return;
}
_dragging = false;
_end();
e.Accepted = true;
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( _accent.WithAlpha( Paint.HasPressed ? 0.42f : Paint.HasMouseOver ? 0.32f : 0.22f ) );
Paint.DrawRect( LocalRect, 3 );
Paint.SetPen( _accent.Lighten( 0.25f ) );
Paint.SetDefaultFont( 10, 650 );
Paint.DrawText( LocalRect, _text, TextFlag.Center );
}
}
public sealed class RigAuditPanel : Widget
{
private readonly WeaponAnimatorController _controller;
private readonly ScrollArea _boneScroll;
private readonly Widget _boneCanvas;
private readonly LineEdit _search;
private readonly Label _sourceStatus;
private readonly Label _retainedStatus;
private readonly Dictionary<string, WeaponAnimatorButton> _boneButtons =
new( StringComparer.OrdinalIgnoreCase );
private string _lastSelectedBone = "";
private string _boneStructureSignature = "";
private string _filter = "";
private bool _showMovable = true;
private bool _showStructural = true;
private bool _showIgnored;
public event Action? ImportRequested;
public event Action? RigReviewConfirmed;
public RigAuditPanel( WeaponAnimatorController controller, Widget? parent = null ) : base( parent )
{
_controller = controller;
Layout = Layout.Column();
Layout.Margin = new Sandbox.UI.Margin( 8 );
Layout.Spacing = 6;
var import = WeaponAnimatorTheme.Button(
"Import rigged model",
"file_upload",
() => ImportRequested?.Invoke(),
this,
true );
Layout.Add( import );
_sourceStatus = WeaponAnimatorTheme.Label( "No source selected", this, true );
_sourceStatus.WordWrap = true;
Layout.Add( _sourceStatus );
_retainedStatus = WeaponAnimatorTheme.Label( "No weapon subtree selected", this, true );
_retainedStatus.WordWrap = true;
Layout.Add( _retainedStatus );
_search = new LineEdit( this )
{
PlaceholderText = "Search bones…",
FixedHeight = 28
};
_search.SetStyles( WeaponAnimatorTheme.InputStyle );
_search.TextEdited += value =>
{
_filter = value?.Trim() ?? "";
RebuildBones();
};
Layout.Add( _search );
var filters = Row( this );
filters.Layout.Add( Toggle( filters, "Movable", _showMovable, x => _showMovable = x ) );
filters.Layout.Add( Toggle( filters, "Structural", _showStructural, x => _showStructural = x ) );
filters.Layout.Add( Toggle( filters, "Ignored", _showIgnored, x => _showIgnored = x ) );
Layout.Add( filters );
var rootActions = Row( this );
rootActions.Layout.Add( WeaponAnimatorTheme.Button(
"Set selected as weapon root",
"account_tree",
SetSelectedWeaponRoot,
rootActions ), 1 );
Layout.Add( rootActions );
var branchActions = Row( this );
branchActions.Layout.Add( WeaponAnimatorTheme.Button(
"Include branch",
"add",
IncludeSelectedBranch,
branchActions ) );
branchActions.Layout.Add( WeaponAnimatorTheme.Button(
"Exclude branch",
"remove",
ExcludeSelectedBranch,
branchActions ) );
Layout.Add( branchActions );
_boneScroll = new ScrollArea( this );
_boneCanvas = new Widget( _boneScroll );
_boneCanvas.Layout = Layout.Column();
_boneCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();
_boneCanvas.Layout.Spacing = 2;
_boneScroll.Canvas = _boneCanvas;
Layout.Add( _boneScroll, 1 );
Layout.Add( WeaponAnimatorTheme.Button(
"Confirm weapon bones",
"verified",
ConfirmWeaponBones,
this,
true ) );
_controller.DocumentChanged += Refresh;
_controller.SelectionChanged += RefreshBoneSelection;
Refresh();
}
public override void OnDestroyed()
{
_controller.DocumentChanged -= Refresh;
_controller.SelectionChanged -= RefreshBoneSelection;
base.OnDestroyed();
}
public void Refresh()
{
var source = _controller.Document.Source;
_sourceStatus.Text = string.IsNullOrWhiteSpace( source.SourcePath )
? "No source selected"
: $"{source.SourcePath}\n{_controller.Document.Rig.Bones.Count} bones · "
+ $"{source.Materials.Count( material => material.HasUsableTextures )}/"
+ $"{source.Materials.Count} textured materials · "
+ $"{(source.Compiled ? "compiled" : "compile failed")}";
var rig = _controller.Document.Rig;
var retained = rig.Bones.Count( WeaponRigHierarchy.IsRetained );
var structural = rig.Bones.Count( x =>
x.Inclusion == WeaponBoneInclusion.StructuralBridge
|| x.Classification == WeaponBoneClassification.Structural );
var excluded = rig.Bones.Count - retained;
_retainedStatus.Text = rig.Bones.Count == 0
? "No weapon subtree selected"
: $"{retained} retained · {structural} structural · {excluded} excluded"
+ (rig.ReviewRequired ? "\nReview and confirm the filtered weapon preview." : "\nWeapon bones confirmed.")
+ (rig.Bones.Any( x =>
x.Inclusion == WeaponBoneInclusion.Excluded && x.HasSkinInfluence )
? "\nExcluded branches may affect visible geometry; verify the model before confirming."
: "");
var signature = BoneStructureSignature(
rig,
_filter,
_showMovable,
_showStructural,
_showIgnored );
if ( signature != _boneStructureSignature )
RebuildBones();
else
RefreshBoneSelection();
}
private void RebuildBones()
{
if ( _boneCanvas is null )
return;
_boneCanvas.Layout.Clear( true );
_boneButtons.Clear();
var bonesByName = _controller.Document.Rig.Bones
.GroupBy( x => x.Name, StringComparer.OrdinalIgnoreCase )
.ToDictionary(
x => x.Key,
x => x.First(),
StringComparer.OrdinalIgnoreCase );
var depths = new Dictionary<string, int>( StringComparer.OrdinalIgnoreCase );
int ResolveDepth( WeaponBoneDefinition bone, HashSet<string> visiting )
{
if ( depths.TryGetValue( bone.Name, out var cached ) )
return cached;
if ( !visiting.Add( bone.Name )
|| string.IsNullOrWhiteSpace( bone.ParentName )
|| !bonesByName.TryGetValue( bone.ParentName, out var parent ) )
return depths[bone.Name] = 0;
var depth = Math.Min( ResolveDepth( parent, visiting ) + 1, 16 );
visiting.Remove( bone.Name );
return depths[bone.Name] = depth;
}
foreach ( var bone in _controller.Document.Rig.Bones.Where( IsVisible ) )
{
var row = Row( _boneCanvas );
row.FixedHeight = 28;
var depth = ResolveDepth( bone, [] );
var name = new WeaponAnimatorButton( $"{new string( ' ', Math.Min( depth, 8 ) * 2 )}{bone.Name}", row )
{
Clicked = () => _controller.SelectBone( bone.Name ),
Tint = WeaponAnimatorTheme.Surface
};
_boneButtons[bone.Name] = name;
row.Layout.Add( name, 1 );
var classification = new WeaponAnimatorButton( ShortClassification( bone.Classification ), row )
{
Clicked = () => CycleClassification( bone ),
FixedWidth = 34,
ToolTip = $"{bone.Classification} · {bone.Inclusion}",
Tint = bone.Classification switch
{
WeaponBoneClassification.WeaponRoot => WeaponAnimatorTheme.Coral * 0.55f,
WeaponBoneClassification.Animatable => WeaponAnimatorTheme.Amber * 0.45f,
WeaponBoneClassification.Structural => WeaponAnimatorTheme.SurfaceRaised,
_ => WeaponAnimatorTheme.Background
}
};
row.Layout.Add( classification );
_boneCanvas.Layout.Add( row );
}
_boneCanvas.Layout.AddStretchCell();
_boneStructureSignature = BoneStructureSignature(
_controller.Document.Rig,
_filter,
_showMovable,
_showStructural,
_showIgnored );
_lastSelectedBone = "";
RefreshBoneSelection();
}
internal static string BoneStructureSignature(
WeaponRigDefinition rig,
string filter,
bool showMovable,
bool showStructural,
bool showIgnored )
{
var signature = new StringBuilder()
.Append( filter )
.Append( '|' )
.Append( showMovable ? '1' : '0' )
.Append( showStructural ? '1' : '0' )
.Append( showIgnored ? '1' : '0' );
foreach ( var bone in rig.Bones )
{
signature
.Append( '\n' )
.Append( bone.Id )
.Append( '\t' )
.Append( bone.Name )
.Append( '\t' )
.Append( bone.ParentId )
.Append( '\t' )
.Append( bone.ParentName )
.Append( '\t' )
.Append( (int)bone.Classification )
.Append( '\t' )
.Append( (int)bone.Inclusion );
}
return signature.ToString();
}
private void RefreshBoneSelection()
{
var selected = _controller.Document.Workspace.SelectedBone;
if ( _lastSelectedBone.Equals( selected, StringComparison.OrdinalIgnoreCase ) )
return;
if ( _boneButtons.TryGetValue( _lastSelectedBone, out var previous ) )
previous.Tint = WeaponAnimatorTheme.Surface;
if ( _boneButtons.TryGetValue( selected, out var current ) )
{
current.Tint = WeaponAnimatorTheme.Cyan * 0.45f;
RevealIfNeeded( current );
}
_lastSelectedBone = selected;
}
/// <summary>
/// Scrolls a bone selected elsewhere - the viewport, most often - into view, so picking a bone
/// in the scene does not leave the list parked somewhere else.
/// </summary>
private void RevealIfNeeded( Widget button )
{
if ( button.Height <= 0 || _boneScroll.Height <= 0 )
return;
var viewportTop = _boneScroll.ScreenPosition.y;
var viewportBottom = viewportTop + _boneScroll.Height;
var itemTop = button.ScreenPosition.y;
var itemBottom = itemTop + button.Height;
if ( itemTop < viewportTop )
_boneScroll.VerticalScrollbar.Value -= (viewportTop - itemTop).CeilToInt();
else if ( itemBottom > viewportBottom )
_boneScroll.VerticalScrollbar.Value += (itemBottom - viewportBottom).CeilToInt();
}
private bool IsVisible( WeaponBoneDefinition bone )
{
if ( !string.IsNullOrWhiteSpace( _filter )
&& !bone.Name.Contains( _filter, StringComparison.OrdinalIgnoreCase ) )
return false;
return bone.Classification switch
{
WeaponBoneClassification.WeaponRoot => _showMovable,
WeaponBoneClassification.Animatable => _showMovable,
WeaponBoneClassification.Structural => _showStructural,
WeaponBoneClassification.Ignored => _showIgnored,
_ => true
};
}
private void CycleClassification( WeaponBoneDefinition selected )
{
if ( selected.Inclusion == WeaponBoneInclusion.Excluded
|| selected.Classification == WeaponBoneClassification.WeaponRoot )
return;
var next = selected.Classification switch
{
WeaponBoneClassification.Animatable => WeaponBoneClassification.Structural,
_ => WeaponBoneClassification.Animatable
};
_controller.Mutate( $"Classify {selected.Name}", document =>
{
selected.Classification = next;
document.Rig.ReviewRequired = true;
document.Rig.FilteredPreviewConfirmed = false;
document.Source.PreviewHostCompiled = false;
document.Calibration.Confirmed = false;
} );
}
private void SetSelectedWeaponRoot()
{
var selected = _controller.Document.Workspace.SelectedBone;
if ( string.IsNullOrWhiteSpace( selected ) )
return;
_controller.Mutate( $"Set weapon root {selected}", document =>
{
WeaponRigHierarchy.SelectWeaponSubtree( document.Rig, selected );
document.Source.PreviewHostCompiled = false;
document.Calibration.Confirmed = false;
} );
}
private void ExcludeSelectedBranch()
{
var selected = _controller.Document.Workspace.SelectedBone;
if ( string.IsNullOrWhiteSpace( selected ) )
return;
_controller.Mutate( $"Exclude branch {selected}", document =>
{
if ( !WeaponRigHierarchy.ExcludeBranch( document.Rig, selected ) )
return;
document.Source.PreviewHostCompiled = false;
document.Calibration.Confirmed = false;
} );
}
private void IncludeSelectedBranch()
{
var selected = _controller.Document.Workspace.SelectedBone;
if ( string.IsNullOrWhiteSpace( selected ) )
return;
_controller.Mutate( $"Include branch {selected}", document =>
{
if ( !WeaponRigHierarchy.IncludeBranch( document.Rig, selected ) )
return;
document.Source.PreviewHostCompiled = false;
document.Calibration.Confirmed = false;
} );
}
private void ConfirmWeaponBones()
{
if ( _controller.Document.Rig.Bones.Count == 0 )
return;
_controller.Mutate( "Confirm filtered weapon bones", document =>
{
WeaponRigHierarchy.ConfirmFilteredPreview( document.Rig );
document.Rig.ProfileHash = WeaponSourceImporter.HashText(
WeaponRigHierarchy.ProfileText( document.Rig ) );
document.Source.PreviewHostCompiled = false;
document.Calibration.Confirmed = false;
} );
RigReviewConfirmed?.Invoke();
}
private static string ShortClassification( WeaponBoneClassification value ) => value switch
{
WeaponBoneClassification.WeaponRoot => "R",
WeaponBoneClassification.Animatable => "A",
WeaponBoneClassification.Structural => "S",
_ => "×"
};
private Button Toggle( Widget parent, string text, bool value, Action<bool> changed )
{
var button = new WeaponAnimatorButton( text, parent )
{
IsToggle = true,
IsChecked = value,
Tint = WeaponAnimatorTheme.SurfaceRaised
};
button.Toggled = () =>
{
changed( button.IsChecked );
RebuildBones();
};
return button;
}
internal static Widget Row( Widget parent )
{
var row = new Widget( parent );
row.SetStyles( "background-color: transparent; border: none;" );
row.Layout = Layout.Row();
row.Layout.Margin = 0;
row.Layout.Spacing = 4;
return row;
}
}
public sealed class CalibrationInspectorPanel : Widget
{
private readonly WeaponAnimatorController _controller;
private readonly Label _selectedBone;
private readonly Label _measurementResult;
private readonly Label _alignmentResult;
private readonly Label _confirmationState;
private readonly LineEdit _knownDistance;
private readonly List<Action> _transformRefreshers = [];
private readonly List<Action> _documentRefreshers = [];
private readonly Dictionary<Guid, Button> _customAnchorButtons = [];
private Widget? _customAnchorCanvas;
private string _customAnchorSignature = "";
private Vector3 _modelDimensions;
public event Action<ViewportPickMode, Guid>? PickRequested;
public event Action? AutoAlignRequested;
public event Action? ConfirmRequested;
public event Action? RebuildPreviewRequested;
public CalibrationInspectorPanel(
WeaponAnimatorController controller,
Widget? parent = null ) : base( parent )
{
_controller = controller;
var scroll = new ScrollArea( this );
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Add( scroll, 1 );
var canvas = new Widget( scroll );
canvas.Layout = Layout.Column();
canvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin( 10 );
canvas.Layout.Spacing = 8;
scroll.Canvas = canvas;
canvas.Layout.Add( Section( canvas, "SELECTION" ) );
_selectedBone = WeaponAnimatorTheme.Label( "No bone selected", canvas, true );
canvas.Layout.Add( _selectedBone );
canvas.Layout.Add( Section( canvas, "PHYSICAL MEASUREMENT" ) );
var pickRow = RigAuditPanel.Row( canvas );
pickRow.Layout.Add( WeaponAnimatorTheme.Button(
"Point A",
"looks_one",
() => PickRequested?.Invoke( ViewportPickMode.MeasurementFirst, Guid.Empty ),
pickRow ), 1 );
pickRow.Layout.Add( WeaponAnimatorTheme.Button(
"Point B",
"looks_two",
() => PickRequested?.Invoke( ViewportPickMode.MeasurementSecond, Guid.Empty ),
pickRow ), 1 );
canvas.Layout.Add( pickRow );
var knownRow = RigAuditPanel.Row( canvas );
_knownDistance = new LineEdit( knownRow )
{
PlaceholderText = "Known distance",
FixedHeight = 28
};
_knownDistance.SetStyles( WeaponAnimatorTheme.InputStyle );
// EditingFinished fires on focus loss; ReturnPressed covers Enter explicitly so a typed
// distance is committed even where the blur signal does not reach us.
_knownDistance.EditingFinished += CommitScalePreview;
_knownDistance.ReturnPressed += CommitScalePreview;
knownRow.Layout.Add( _knownDistance, 1 );
var unitButton = new WeaponAnimatorButton( "in", knownRow )
{
FixedWidth = 48,
Clicked = ToggleUnit,
Tint = WeaponAnimatorTheme.SurfaceRaised
};
knownRow.Layout.Add( unitButton );
_documentRefreshers.Add( () =>
{
unitButton.Text = _controller.Document.Calibration.Measurement.Unit
== MeasurementUnit.Inches
? "in"
: "cm";
} );
canvas.Layout.Add( knownRow );
_measurementResult = WeaponAnimatorTheme.Label( "Pick two points to establish scale.", canvas, true );
_measurementResult.WordWrap = true;
canvas.Layout.Add( _measurementResult );
canvas.Layout.Add( WeaponAnimatorTheme.Button(
"Apply scale",
"done",
ApplyScale,
canvas,
true ) );
canvas.Layout.Add( Section( canvas, "AUTO-ALIGN MARKERS · OPTIONAL" ) );
var alignmentNote = WeaponAnimatorTheme.Label(
"Only needed when Auto-align should rotate an incorrectly oriented source model.",
canvas,
true );
alignmentNote.WordWrap = true;
canvas.Layout.Add( alignmentNote );
AddPickButton( canvas, "Alignment marker — rear", "radio_button_unchecked", ViewportPickMode.RearBoreAnchor );
AddPickButton( canvas, "Alignment marker — front", "adjust", ViewportPickMode.FrontBoreAnchor );
var autoAlign = WeaponAnimatorTheme.Button(
"Auto-align from markers",
"center_focus_strong",
() => AutoAlignRequested?.Invoke(),
canvas,
true );
autoAlign.ToolTip = "Uses the rear-to-front marker direction to rotate and place the weapon";
canvas.Layout.Add( autoAlign );
_alignmentResult = WeaponAnimatorTheme.Label(
"Skip these markers when the source orientation is already correct.",
canvas,
true );
_alignmentResult.WordWrap = true;
canvas.Layout.Add( _alignmentResult );
canvas.Layout.Add( Section( canvas, "GRIP ANCHOR · REQUIRED" ) );
var gripNote = WeaponAnimatorTheme.Label(
"Seeds the default primary-hand target used on the animation page.",
canvas,
true );
gripNote.WordWrap = true;
canvas.Layout.Add( gripNote );
AddPickButton( canvas, "Primary grip", "pan_tool", ViewportPickMode.GripAnchor );
canvas.Layout.Add( Section( canvas, "OUTPUT ANCHORS · OPTIONAL" ) );
AddPickButton( canvas, "Muzzle", "flare", ViewportPickMode.MuzzleAnchor );
AddPickButton( canvas, "Eject", "outbound", ViewportPickMode.EjectAnchor );
canvas.Layout.Add( Section( canvas, "CUSTOM ANCHORS · OPTIONAL" ) );
var customNote = WeaponAnimatorTheme.Label(
"Exported alongside muzzle and eject. Name each one to match the attachment your game "
+ "code expects. Grip and alignment markers stay in calibration and are never exported.",
canvas,
true );
customNote.WordWrap = true;
canvas.Layout.Add( customNote );
_customAnchorCanvas = new Widget( canvas );
_customAnchorCanvas.Layout = Layout.Column();
_customAnchorCanvas.Layout.Margin = 0;
_customAnchorCanvas.Layout.Spacing = 3;
canvas.Layout.Add( _customAnchorCanvas );
canvas.Layout.Add( WeaponAnimatorTheme.Button(
"Add custom anchor",
"add_location_alt",
AddCustomAnchor,
canvas ) );
_documentRefreshers.Add( RefreshCustomAnchors );
canvas.Layout.Add( WeaponAnimatorTheme.Button(
"Clear all anchors",
"delete_sweep",
ClearAllAnchors,
canvas ) );
_documentRefreshers.Add( () =>
{
var calibration = _controller.Document.Calibration;
autoAlign.Enabled = calibration.GetAnchor( AnchorKind.Grip ) is not null
&& calibration.GetAnchor( AnchorKind.RearBore ) is not null
&& calibration.GetAnchor( AnchorKind.FrontBore ) is not null;
} );
canvas.Layout.Add( Section( canvas, "NUMERIC TRANSFORMS" ) );
AddTransformFields( canvas, "Physical", false );
AddTransformFields( canvas, "Viewmodel framing", true );
canvas.Layout.Add( Section( canvas, "PREVIEW" ) );
var previewNote = WeaponAnimatorTheme.Label(
"Viewmodel camera is optional. It previews a fixed origin and does not author the player's camera.",
canvas,
true );
previewNote.WordWrap = true;
canvas.Layout.Add( previewNote );
var modeRow = RigAuditPanel.Row( canvas );
modeRow.Layout.Add( Toggle(
modeRow,
"Viewmodel camera",
() => _controller.Document.Workspace.FirstPersonPreview,
value => _controller.Mutate( "Preview mode", d => d.Workspace.FirstPersonPreview = value ) ), 1 );
modeRow.Layout.Add( Toggle(
modeRow,
"Safe area",
() => _controller.Document.Calibration.ShowSafeArea,
value => _controller.Mutate( "Safe area", d => d.Calibration.ShowSafeArea = value ) ), 1 );
canvas.Layout.Add( modeRow );
canvas.Layout.Add( ChoiceButton(
canvas,
"Aspect",
() => _controller.Document.Calibration.AspectGuide,
["4:3", "16:9", "21:9"],
value => _controller.Mutate( "Aspect guide", d => d.Calibration.AspectGuide = value ) ) );
canvas.Layout.Add( ChoiceButton(
canvas,
"Up axis",
() => _controller.Document.Calibration.UpAxis.ToString(),
Enum.GetNames<WeaponUpAxis>(),
value => _controller.Mutate(
"Alignment up axis",
d => d.Calibration.UpAxis = Enum.Parse<WeaponUpAxis>( value ) ) ) );
canvas.Layout.Add( WeaponAnimatorTheme.Button(
"Rebuild preview host",
"refresh",
() => RebuildPreviewRequested?.Invoke(),
canvas ) );
canvas.Layout.Add( Section( canvas, "CALIBRATION GATE" ) );
_confirmationState = WeaponAnimatorTheme.Label( "", canvas, true );
_confirmationState.WordWrap = true;
canvas.Layout.Add( _confirmationState );
canvas.Layout.Add( WeaponAnimatorTheme.Button(
"Confirm rig and continue",
"arrow_forward",
() => ConfirmRequested?.Invoke(),
canvas,
true ) );
canvas.Layout.AddStretchCell();
_controller.DocumentChanged += Refresh;
_controller.SelectionChanged += Refresh;
_controller.PoseChanged += RefreshTransformValues;
Refresh();
}
public override void OnDestroyed()
{
_controller.DocumentChanged -= Refresh;
_controller.SelectionChanged -= Refresh;
_controller.PoseChanged -= RefreshTransformValues;
base.OnDestroyed();
}
public void SetModelDimensions( Vector3 dimensions )
{
_modelDimensions = dimensions;
Refresh();
}
public void SetAlignmentMessage( string message )
{
_alignmentResult.Text = message;
}
private void Refresh()
{
var document = _controller.Document;
if ( CalibrationSelection.TryGetAnchor( document.Workspace.SelectedControl, out var anchorKind ) )
{
var anchor = document.Calibration.GetAnchor( anchorKind );
_selectedBone.Text = anchor is null
? "Anchor not set"
: $"{CalibrationSelection.DisplayName( anchorKind )} · use Move or Rotate in the viewport";
}
else
{
_selectedBone.Text = string.IsNullOrWhiteSpace( document.Workspace.SelectedBone )
? "No control selected"
: $"{document.Workspace.SelectedBone} bone";
}
var measurement = document.Calibration.Measurement;
if ( !_knownDistance.IsFocused )
{
_knownDistance.Value = measurement.KnownDistance > 0
? measurement.KnownDistance.ToString( "0.###", CultureInfo.InvariantCulture )
: "";
}
PreviewScale();
var report = WeaponAnimationValidator.ValidateCalibration( document );
_confirmationState.Text = report.IsValid
? "All calibration checks pass. Confirmation will snapshot the rig and seed Idle."
: string.Join( "\n", report.Issues.Where( x => x.Blocking ).Take( 5 ).Select( x => $"• {x.Message}" ) );
RefreshDocumentValues();
}
private void PreviewScale()
{
if ( !TryCalculateScalePreview( out _, out var preview ) )
{
_measurementResult.Text =
"Pick two distinct points and enter a positive known distance.";
return;
}
_measurementResult.Text =
$"Measured {preview.MeasuredUnits:0.###} units. Scale ×{preview.UniformScale:0.####}\n" +
$"Original XYZ: {FormatDimensions( preview.OriginalDimensions )}\n" +
$"Result XYZ: {FormatDimensions( preview.ResultingDimensions )}";
}
private void CommitScalePreview()
{
var hasPreview = TryCalculateScalePreview( out var known, out var preview );
if ( !float.TryParse(
_knownDistance.Text,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out known )
|| !WeaponAnimationMath.IsFinite( known )
|| known <= 0 )
{
_measurementResult.Text = "Pick two distinct points and enter a positive known distance.";
return;
}
_controller.Mutate( "Update scale measurement", document =>
{
var measurement = document.Calibration.Measurement;
measurement.KnownDistance = known;
measurement.HasPendingScale = hasPreview;
if ( !hasPreview )
return;
measurement.PreviewScale = preview.UniformScale;
measurement.OriginalDimensions = preview.OriginalDimensions;
measurement.ResultingDimensions = preview.ResultingDimensions;
} );
}
private void ApplyScale()
{
if ( !TryCalculateScalePreview( out var known, out var preview ) )
return;
_controller.Mutate( "Apply uniform scale", document =>
{
var measurement = document.Calibration.Measurement;
measurement.KnownDistance = known;
measurement.PreviewScale = preview.UniformScale;
measurement.OriginalDimensions = preview.OriginalDimensions;
measurement.ResultingDimensions = preview.ResultingDimensions;
document.Calibration.UniformScale = preview.UniformScale;
document.Calibration.PhysicalTransform =
document.Calibration.PhysicalTransform.WithScale( preview.UniformScale );
document.Calibration.Confirmed = false;
measurement.HasPendingScale = false;
} );
}
private bool TryCalculateScalePreview(
out float known,
out ScalePreview preview )
{
preview = default;
if ( !float.TryParse(
_knownDistance.Text,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out known )
|| !WeaponAnimationMath.IsFinite( known )
|| known <= 0 )
return false;
var measurement = _controller.Document.Calibration.Measurement;
return measurement.HasFirstPoint
&& measurement.HasSecondPoint
&& WeaponAnimationMath.TryCalculateUniformScale(
measurement.FirstPoint,
measurement.SecondPoint,
known,
measurement.Unit,
_modelDimensions,
out preview );
}
private void RefreshTransformValues()
{
foreach ( var refresh in _transformRefreshers )
refresh();
}
private void RefreshDocumentValues()
{
RefreshTransformValues();
foreach ( var refresh in _documentRefreshers )
refresh();
}
private void AddTransformFields( Widget parent, string label, bool framing )
{
parent.Layout.Add( WeaponAnimatorTheme.Label( label, parent, true ) );
AddVectorField(
parent,
"Position",
() => GetCalibrationTransform( framing ).Position,
0.05f,
(document, value) =>
SetCalibrationTransform(
document,
framing,
GetCalibrationTransform( document, framing ).WithPosition( value ) ) );
AddVectorField(
parent,
"Rotation",
() =>
{
var angles = GetCalibrationTransform( framing ).Rotation.Angles();
return new Vector3( angles.pitch, angles.yaw, angles.roll );
},
0.5f,
(document, value) =>
SetCalibrationTransform(
document,
framing,
GetCalibrationTransform( document, framing )
.WithRotation( Rotation.From( value.x, value.y, value.z ) ) ) );
AddScalarField(
parent,
"Scale",
() => GetCalibrationTransform( framing ).Scale.x,
0.005f,
(document, value) =>
{
var clamped = MathF.Max( value, 0.0001f );
SetCalibrationTransform(
document,
framing,
GetCalibrationTransform( document, framing ).WithScale( clamped ) );
if ( !framing )
document.Calibration.UniformScale = clamped;
} );
}
private void AddVectorField(
Widget parent,
string label,
Func<Vector3> getter,
float sensitivity,
Action<WeaponAnimationDocument, Vector3> apply )
{
var row = RigAuditPanel.Row( parent );
row.Layout.Add( WeaponAnimatorTheme.Label( label, row, true ), 1 );
var edits = new LineEdit[3];
var axisNames = new[] { "X", "Y", "Z" };
var axisColors = new[]
{
WeaponAnimatorTheme.Coral,
WeaponAnimatorTheme.Green,
new Color( 0.30f, 0.56f, 0.96f )
};
for ( var index = 0; index < edits.Length; index++ )
{
var capturedIndex = index;
var field = new Widget( row )
{
FixedWidth = 68,
FixedHeight = 26,
Layout = Layout.Row()
};
field.Layout.Margin = 0;
field.Layout.Spacing = 0;
var edit = new LineEdit( field ) { FixedWidth = 48, FixedHeight = 26 };
edit.SetStyles( WeaponAnimatorTheme.InputStyle );
edit.EditingFinished += () =>
{
var current = getter();
if ( !float.TryParse(
edit.Text,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var parsed ) )
return;
current[capturedIndex] = parsed;
_controller.Mutate(
$"{label} {axisNames[capturedIndex]}",
document => apply( document, current ) );
};
field.Layout.Add( new ScrubHandle(
axisNames[capturedIndex],
axisColors[capturedIndex],
sensitivity,
() => getter()[capturedIndex],
() => _controller.BeginContinuousEdit( $"{label} {axisNames[capturedIndex]}" ),
value =>
{
var current = getter();
current[capturedIndex] = value;
_controller.UpdateContinuousEdit( document => apply( document, current ) );
},
_controller.EndContinuousEdit,
field ) );
field.Layout.Add( edit );
edits[index] = edit;
row.Layout.Add( field );
}
_transformRefreshers.Add( () =>
{
var value = getter();
for ( var index = 0; index < edits.Length; index++ )
edits[index].Value = value[index].ToString( "0.###", CultureInfo.InvariantCulture );
} );
parent.Layout.Add( row );
}
private void AddScalarField(
Widget parent,
string label,
Func<float> getter,
float sensitivity,
Action<WeaponAnimationDocument, float> apply )
{
var row = RigAuditPanel.Row( parent );
row.Layout.Add( WeaponAnimatorTheme.Label( label, row, true ), 1 );
var field = new Widget( row )
{
FixedWidth = 104,
FixedHeight = 26,
Layout = Layout.Row()
};
field.Layout.Margin = 0;
field.Layout.Spacing = 0;
var edit = new LineEdit( field ) { FixedWidth = 72, FixedHeight = 26 };
edit.SetStyles( WeaponAnimatorTheme.InputStyle );
edit.EditingFinished += () =>
{
if ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) )
_controller.Mutate( label, document => apply( document, parsed ) );
};
field.Layout.Add( new ScrubHandle(
"XYZ",
WeaponAnimatorTheme.Amber,
sensitivity,
getter,
() => _controller.BeginContinuousEdit( label ),
value => _controller.UpdateContinuousEdit( document => apply( document, value ) ),
_controller.EndContinuousEdit,
field )
{
FixedWidth = 32
} );
field.Layout.Add( edit );
row.Layout.Add( field );
_transformRefreshers.Add( () =>
edit.Value = getter().ToString( "0.###", CultureInfo.InvariantCulture ) );
parent.Layout.Add( row );
}
private Transform GetCalibrationTransform( bool framing ) =>
GetCalibrationTransform( _controller.Document, framing );
private static Transform GetCalibrationTransform( WeaponAnimationDocument document, bool framing ) =>
framing ? document.Calibration.FramingTransform : document.Calibration.PhysicalTransform;
private static void SetCalibrationTransform(
WeaponAnimationDocument document,
bool framing,
Transform value )
{
if ( framing )
document.Calibration.FramingTransform = value;
else
{
document.Calibration.PhysicalTransform = value;
document.Calibration.Confirmed = false;
}
}
private static string FormatDimensions( Vector3 dimensions )
{
var centimetres = dimensions * WeaponAnimationMath.CentimetresPerInch;
return $"{dimensions.x:0.##}×{dimensions.y:0.##}×{dimensions.z:0.##} in · " +
$"{centimetres.x:0.##}×{centimetres.y:0.##}×{centimetres.z:0.##} cm";
}
private void ToggleUnit()
{
_controller.Mutate( "Measurement unit", document =>
{
var measurement = document.Calibration.Measurement;
if ( measurement.Unit == MeasurementUnit.Inches )
{
measurement.Unit = MeasurementUnit.Centimetres;
measurement.KnownDistance *= WeaponAnimationMath.CentimetresPerInch;
}
else
{
measurement.Unit = MeasurementUnit.Inches;
measurement.KnownDistance /= WeaponAnimationMath.CentimetresPerInch;
}
} );
}
private void AddPickButton( Widget parent, string name, string icon, ViewportPickMode mode )
{
var kind = mode switch
{
ViewportPickMode.GripAnchor => AnchorKind.Grip,
ViewportPickMode.RearBoreAnchor => AnchorKind.RearBore,
ViewportPickMode.FrontBoreAnchor => AnchorKind.FrontBore,
ViewportPickMode.MuzzleAnchor => AnchorKind.Muzzle,
ViewportPickMode.EjectAnchor => AnchorKind.Eject,
_ => AnchorKind.Custom
};
var row = RigAuditPanel.Row( parent );
var select = WeaponAnimatorTheme.Button(
name,
icon,
() =>
{
if ( _controller.Document.Calibration.GetAnchor( kind ) is null )
PickRequested?.Invoke( mode, Guid.Empty );
else
_controller.SelectControl( CalibrationSelection.Anchor( kind ) );
},
row );
row.Layout.Add( select, 1 );
var repick = WeaponAnimatorTheme.Button(
"",
"my_location",
() => PickRequested?.Invoke( mode, Guid.Empty ),
row );
repick.FixedWidth = 34;
repick.ToolTip = $"Pick {name.ToLowerInvariant()} again";
row.Layout.Add( repick );
var delete = WeaponAnimatorTheme.Button(
"",
"delete",
() => DeleteAnchor( kind ),
row );
delete.FixedWidth = 34;
delete.ToolTip = $"Delete {name.ToLowerInvariant()}";
row.Layout.Add( delete );
_documentRefreshers.Add( () =>
{
var exists = _controller.Document.Calibration.GetAnchor( kind ) is not null;
var selected = _controller.Document.Workspace.SelectedControl == CalibrationSelection.Anchor( kind );
select.Text = exists ? $"Move {name}" : $"Set {name}";
select.Tint = selected
? WeaponAnimatorTheme.Cyan * 0.48f
: WeaponAnimatorTheme.SurfaceRaised;
repick.Enabled = exists;
delete.Enabled = exists;
} );
parent.Layout.Add( row );
}
private void DeleteAnchor( AnchorKind kind )
{
if ( _controller.Document.Calibration.GetAnchor( kind ) is null )
return;
_controller.Mutate( $"Delete {CalibrationSelection.DisplayName( kind )} anchor", document =>
{
document.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == kind );
if ( document.Workspace.SelectedControl == CalibrationSelection.Anchor( kind ) )
document.Workspace.SelectedControl = "";
document.Calibration.Confirmed = false;
} );
}
/// <summary>
/// Rebuilds the custom anchor rows only when the set actually changes, so selecting an anchor
/// re-tints in place instead of tearing down live text fields the user may be editing.
/// </summary>
private void RefreshCustomAnchors()
{
if ( _customAnchorCanvas is null )
return;
var anchors = _controller.Document.Calibration.CustomAnchors().ToList();
var signature = string.Join(
'\n',
anchors.Select( anchor => $"{anchor.Id:N}\t{anchor.GeneratedAttachmentName}" ) );
if ( signature != _customAnchorSignature )
{
_customAnchorSignature = signature;
_customAnchorCanvas.Layout.Clear( true );
_customAnchorButtons.Clear();
foreach ( var anchor in anchors )
AddCustomAnchorRow( anchor );
}
var selected = _controller.Document.Workspace.SelectedControl;
foreach ( var pair in _customAnchorButtons )
{
var anchor = _controller.Document.Calibration.FindAnchor( pair.Key );
pair.Value.Tint = anchor is not null
&& selected == CalibrationSelection.Anchor( anchor )
? WeaponAnimatorTheme.Cyan * 0.48f
: WeaponAnimatorTheme.SurfaceRaised;
}
}
private void AddCustomAnchorRow( WeaponAnchor anchor )
{
var id = anchor.Id;
var row = RigAuditPanel.Row( _customAnchorCanvas! );
var edit = new LineEdit( row )
{
Text = WeaponAnimationNames.AttachmentName( anchor ),
PlaceholderText = "attachment_name",
FixedHeight = 27,
ToolTip = "Attachment name written into the generated prefab and model"
};
edit.SetStyles( WeaponAnimatorTheme.InputStyle );
// EditingFinished fires on focus loss; ReturnPressed covers Enter explicitly.
edit.EditingFinished += () => RenameCustomAnchor( id, edit.Text );
edit.ReturnPressed += () => RenameCustomAnchor( id, edit.Text );
row.Layout.Add( edit, 1 );
var select = WeaponAnimatorTheme.Button(
"",
"ads_click",
() =>
{
if ( _controller.Document.Calibration.FindAnchor( id ) is { } target )
_controller.SelectControl( CalibrationSelection.Anchor( target ) );
},
row );
select.FixedWidth = 34;
select.ToolTip = "Select this anchor and show its gizmo";
_customAnchorButtons[id] = select;
row.Layout.Add( select );
var place = WeaponAnimatorTheme.Button(
"",
"my_location",
() => PickRequested?.Invoke( ViewportPickMode.CustomAnchor, id ),
row );
place.FixedWidth = 34;
place.ToolTip = "Click the weapon surface to place this anchor";
row.Layout.Add( place );
var remove = WeaponAnimatorTheme.Button(
"",
"delete",
() => DeleteCustomAnchor( id ),
row );
remove.FixedWidth = 34;
remove.ToolTip = "Delete this anchor";
row.Layout.Add( remove );
_customAnchorCanvas!.Layout.Add( row );
}
private void AddCustomAnchor()
{
var id = Guid.NewGuid();
_controller.Mutate( "Add custom anchor", document =>
{
document.Calibration.Anchors.Add( new WeaponAnchor
{
Id = id,
Kind = AnchorKind.Custom,
Name = "attachment",
BoneName = document.Workspace.SelectedBone
} );
WeaponAnimationNames.RepairCustomAnchorNames( document );
document.Calibration.Confirmed = false;
} );
if ( _controller.Document.Calibration.FindAnchor( id ) is { } added )
_controller.SelectControl( CalibrationSelection.Anchor( added ) );
}
/// <summary>
/// The field edits the attachment name directly, so what the user types is what generation
/// emits. Repair runs here rather than at generation time so any collision suffix is visible
/// immediately instead of appearing silently in the output.
/// </summary>
private void RenameCustomAnchor( Guid id, string value )
{
_controller.Mutate( "Rename custom anchor", document =>
{
if ( document.Calibration.FindAnchor( id ) is not { } anchor )
return;
var slug = WeaponAnimationDocument.Slugify( value );
if ( string.IsNullOrWhiteSpace( slug ) )
slug = "anchor";
if ( anchor.GeneratedAttachmentName == slug && anchor.Name == slug )
return;
anchor.GeneratedAttachmentName = slug;
anchor.Name = slug;
WeaponAnimationNames.RepairCustomAnchorNames( document );
document.Calibration.Confirmed = false;
} );
}
private void DeleteCustomAnchor( Guid id )
{
_controller.Mutate( "Delete custom anchor", document =>
{
if ( document.Calibration.FindAnchor( id ) is not { } anchor )
return;
var token = CalibrationSelection.Anchor( anchor );
document.Calibration.Anchors.Remove( anchor );
if ( document.Workspace.SelectedControl == token )
document.Workspace.SelectedControl = "";
document.Calibration.Confirmed = false;
} );
}
private void ClearAllAnchors()
{
if ( _controller.Document.Calibration.Anchors.Count == 0 )
return;
_controller.Mutate( "Clear all anchors", document =>
{
document.Calibration.Anchors.Clear();
if ( CalibrationSelection.TryGetAnchor( document.Workspace.SelectedControl, out _ ) )
document.Workspace.SelectedControl = "";
document.Calibration.Confirmed = false;
} );
}
private static Label Section( Widget parent, string text )
{
return WeaponAnimatorTheme.SectionLabel( text, parent, topMargin: true );
}
private Button Toggle(
Widget parent,
string text,
Func<bool> current,
Action<bool> changed )
{
var button = new WeaponAnimatorButton( text, parent )
{
IsToggle = true,
IsChecked = current(),
Tint = WeaponAnimatorTheme.SurfaceRaised
};
button.Toggled = () => changed( button.IsChecked );
_documentRefreshers.Add( () => button.IsChecked = current() );
return button;
}
private Button ChoiceButton(
Widget parent,
string label,
Func<string> current,
System.Collections.Generic.IEnumerable<string> values,
Action<string> changed )
{
var button = new WeaponAnimatorButton( $"{label}: {current()}", "expand_more", parent )
{
Tint = WeaponAnimatorTheme.SurfaceRaised
};
button.Clicked = () =>
{
var menu = new Menu( button );
foreach ( var value in values )
{
var captured = value;
menu.AddOption( captured, null, () =>
{
changed( captured );
button.Text = $"{label}: {current()}";
button.FitToContent();
} );
}
menu.OpenAt( button.ScreenRect.BottomLeft );
};
_documentRefreshers.Add( () =>
{
button.Text = $"{label}: {current()}";
button.FitToContent();
} );
return button;
}
}
public sealed class ValidationStatusPanel : Widget
{
private readonly Label _label;
public ValidationStatusPanel( Widget? parent = null ) : base( parent )
{
Layout = Layout.Row();
Layout.Margin = new Sandbox.UI.Margin( 12, 8, 12, 8 );
_label = WeaponAnimatorTheme.Label( "Ready", this, true );
_label.WordWrap = true;
Layout.Add( _label, 1 );
}
public void SetReport( ValidationReport report, string prefix = "" )
{
var status = report.IsValid
? report.WarningCount == 0 ? "READY" : $"{report.WarningCount} WARNING(S)"
: $"{report.ErrorCount} ERROR(S)";
_label.Color = report.IsValid
? report.WarningCount == 0 ? WeaponAnimatorTheme.Green : WeaponAnimatorTheme.Amber
: WeaponAnimatorTheme.Coral;
_label.Text = string.IsNullOrWhiteSpace( prefix )
? $"{status} · {string.Join( " · ", report.Issues.Take( 4 ).Select( x => x.Message ) )}"
: $"{prefix} · {status} · {string.Join( " · ", report.Issues.Take( 4 ).Select( x => x.Message ) )}";
}
public void SetMessage( string message, ValidationSeverity severity = ValidationSeverity.Info )
{
_label.Text = message;
_label.Color = severity switch
{
ValidationSeverity.Error => WeaponAnimatorTheme.Coral,
ValidationSeverity.Warning => WeaponAnimatorTheme.Amber,
_ => WeaponAnimatorTheme.Muted
};
}
}