Editor UI window and supporting launcher/preferences/picker for the Weapon Animator editor. It hosts the controller, handles opening/saving .wepanim assets, building UI layout (calibration/animation), import/reimport of source models, material refresh, asset generation, recovery/autosave, and various editor commands and shortcuts.
#nullable enable annotations
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
namespace SboxWeaponAnimator.Editor;
[EditorForAssetType( "wepanim" )]
public sealed class WeaponAnimatorWindow : DockWindow, IAssetEditor
{
private readonly WeaponAnimatorController _controller = new();
private readonly WeaponSourceImporter _importer = new();
private readonly AssetGenerationService _generator = new();
private bool _generating;
private CancellationTokenSource? _generationCancellation;
private bool _closeAfterGenerationStops;
private bool _refreshingMaterials;
private Asset? _asset;
private WeaponAnimationAsset? _resource;
private Widget? _root;
private WeaponAnimatorToolbar? _toolbar;
private WeaponAnimatorViewport? _viewport;
private ValidationStatusPanel? _statusPanel;
private Splitter? _horizontalSplitter;
private Splitter? _verticalSplitter;
private Splitter? _animationRightSplitter;
private Splitter? _animationOuterSplitter;
private Button? _validationButton;
private Button? _generateButton;
private Button? _playButton;
private bool _allowClose;
private bool _rebaseOnConfirm;
private bool _rebuilding;
private WeaponAnimationMigrationResult? _migration;
private bool _migrationBackupRequired;
private bool _recoveryWritePending;
private bool _closing;
private int _recoveryRequestVersion;
public bool CanOpenMultipleAssets => false;
public void SelectMember( string memberName ) { }
public WeaponAnimatorWindow()
{
DeleteOnClose = true;
WindowTitle = "S&box Weapon Animator";
Title = WindowTitle;
Size = new Vector2( 1600, 940 );
MinimumSize = new Vector2( 1200, 720 );
StateCookie = "SboxWeaponAnimator.Window";
SetWindowIcon( "animation" );
_controller.DocumentChanged += OnDocumentChanged;
_controller.DirtyChanged += OnDirtyChanged;
_controller.PlaybackChanged += RefreshToolbarState;
BuildMenuBar();
BuildWorkspace();
Show();
}
public void AssetOpen( Asset asset )
{
_asset = asset;
_resource = asset?.LoadResource<WeaponAnimationAsset>() ?? new WeaponAnimationAsset();
var document = _resource.Document ?? WeaponAnimationDocument.CreateDefault();
var adoptedName = AdoptAssetFileName( document, asset );
_migration = MigrateAndRepair( document );
var sourceRecovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(
document,
out var sourceRecoveryMessage );
_migrationBackupRequired = _migration.Changed;
_controller.SetDocument( document );
if ( _migration.Changed || sourceRecovered || adoptedName )
_controller.ReplaceWithoutHistory( document, true );
if ( document.ActiveStage == WeaponAnimatorStage.Animate
&& document.Source.Compiled )
{
PreviewHostBuilder.Build( document );
}
BuildWorkspace();
if ( !OfferRecovery() )
OfferCachedImportRecovery();
if ( sourceRecovered )
_statusPanel?.SetMessage( sourceRecoveryMessage, ValidationSeverity.Warning );
else if ( _migration.Changed )
_statusPanel?.SetMessage( _migration.Summary, ValidationSeverity.Warning );
RefreshTitle();
}
protected override bool OnClose()
{
SaveWorkspaceState();
if ( _generating )
{
_closeAfterGenerationStops = true;
_generationCancellation?.Cancel();
_statusPanel?.SetMessage(
"Cancelling asset generation before closing…",
ValidationSeverity.Warning );
return false;
}
if ( _allowClose || !_controller.IsDirty )
{
DestroyWorkspace();
return true;
}
Dialog.AskConfirm(
() =>
{
if ( Save() )
CloseAfterPrompt();
},
() =>
{
Dialog.AskConfirm(
// Discarding closes without saving, but the autosave snapshot is kept so the
// work is still recoverable on the next open. Only Save clears it.
() => CloseAfterPrompt( clearRecovery: false ),
"Discard all unsaved changes to this Weapon Animation Project?",
"Discard Changes",
"Discard",
"Cancel" );
},
"Save changes before closing this Weapon Animation Project?",
"Unsaved Weapon Animation Project",
"Save",
"More Options" );
return false;
}
[Shortcut( "editor.save", "Ctrl+S", ShortcutType.Window )]
private void ShortcutSave() => Save();
[Shortcut( "editor.undo", "Ctrl+Z", ShortcutType.Window )]
private void ShortcutUndo() => _controller.Undo();
[Shortcut( "editor.redo", "Ctrl+Y", ShortcutType.Window )]
private void ShortcutRedo() => _controller.Redo();
[Shortcut( "weaponanim.copykeys", "Ctrl+C", ShortcutType.Window )]
private void ShortcutCopy() => _controller.CopySelectedKeys();
[Shortcut( "weaponanim.pastekeys", "Ctrl+V", ShortcutType.Window )]
private void ShortcutPaste() => _controller.PasteKeys();
[Shortcut( "weaponanim.cutkeys", "Ctrl+X", ShortcutType.Window )]
private void ShortcutCut() => _controller.CutSelectedKeys();
[Shortcut( "weaponanim.key", "K", ShortcutType.Window )]
private void ShortcutKey() => _controller.KeySelectedTransform();
[Shortcut( "weaponanim.move", "W", ShortcutType.Window )]
private void ShortcutMove()
{
if ( _viewport?.ConsumesFreeLookMovementShortcut == true )
return;
_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Move );
}
[Shortcut( "weaponanim.rotate", "E", ShortcutType.Window )]
private void ShortcutRotate() =>
_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Rotate );
[Shortcut( "weaponanim.scale", "R", ShortcutType.Window )]
private void ShortcutScale() =>
_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Scale );
[EditorEvent.Hotload]
public void OnHotload()
{
HostSkeletonBuilder.ClearCache();
SaveWorkspaceState();
MenuBar.Clear();
BuildMenuBar();
var sourceRecovered = false;
var sourceRecoveryMessage = "";
_controller.Mutate(
"Recover missing source preview",
document => sourceRecovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(
document,
out sourceRecoveryMessage ) );
if ( _controller.Document.Source.Compiled )
PreviewHostBuilder.Build( _controller.Document );
BuildWorkspace();
if ( sourceRecovered )
_statusPanel?.SetMessage( sourceRecoveryMessage, ValidationSeverity.Warning );
}
private void BuildMenuBar()
{
var file = MenuBar.AddMenu( "File" );
file.AddOption( "New", "note_add", WeaponAnimatorLauncher.CreateNew );
file.AddOption( "Open…", "folder_open", WeaponAnimatorLauncher.OpenExisting );
file.AddSeparator();
file.AddOption( "Save", "save", () => Save(), "editor.save" );
file.AddOption( "Save As…", "save_as", SaveAs );
file.AddOption( "Generate Assets", "build", GenerateAssets );
file.AddSeparator();
file.AddOption( "Close", "close", Close );
var edit = MenuBar.AddMenu( "Edit" );
edit.AddOption( "Undo", "undo", _controller.Undo, "editor.undo" );
edit.AddOption( "Redo", "redo", _controller.Redo, "editor.redo" );
edit.AddSeparator();
edit.AddOption( "Cut Keys", "content_cut", _controller.CutSelectedKeys );
edit.AddOption( "Copy Keys", "content_copy", _controller.CopySelectedKeys );
edit.AddOption( "Paste Keys", "content_paste", _controller.PasteKeys );
edit.AddOption( "Delete Keys", "delete", _controller.DeleteSelectedKeys );
edit.AddSeparator();
edit.AddOption( "Preferences…", "tune", OpenPreferences );
var view = MenuBar.AddMenu( "View" );
view.AddOption( "Calibrate", "straighten", RequestCalibrationStage );
view.AddOption( "Animate", "animation", () => SwitchStage( WeaponAnimatorStage.Animate ) );
view.AddSeparator();
var guides = view.AddOption( "Toggle Guides", "aspect_ratio", () =>
_controller.Mutate( "Viewport guides", d => d.Workspace.ShowGuides = !d.Workspace.ShowGuides ) );
BindCheckedState( guides, () => _controller.Document.Workspace.ShowGuides );
var skeleton = view.AddOption( "Toggle Skeleton", "accessibility_new", () =>
_controller.Mutate( "Skeleton overlay", d => d.Workspace.ShowSkeleton = !d.Workspace.ShowSkeleton ) );
BindCheckedState( skeleton, () => _controller.Document.Workspace.ShowSkeleton );
var xray = view.AddOption( "X-Ray Skeleton", "visibility", () =>
_controller.UpdateWorkspacePreference(
"X-ray skeleton",
workspace => workspace.XRaySkeleton = !workspace.XRaySkeleton ) );
BindCheckedState( xray, () => _controller.Document.Workspace.XRaySkeleton );
var boneOcclusion = view.AddOption( "Bone Occlusion", "gradient", () =>
_controller.UpdateWorkspacePreference(
"Bone occlusion",
workspace => workspace.BoneOcclusionEnabled =
!workspace.BoneOcclusionEnabled ) );
BindCheckedState(
boneOcclusion,
() => _controller.Document.Workspace.BoneOcclusionEnabled );
var ikBones = view.AddOption( "Show IK Bones", "polyline", () =>
_controller.UpdateWorkspacePreference(
"Show IK bones",
workspace => workspace.ShowIkBones = !workspace.ShowIkBones ) );
BindCheckedState( ikBones, () => _controller.Document.Workspace.ShowIkBones );
var onionSkins = view.AddOption( "Toggle Onion Skins", "filter_none", () =>
_controller.Mutate( "Onion skins", d => d.Workspace.ShowOnionSkins = !d.Workspace.ShowOnionSkins ) );
BindCheckedState(
onionSkins,
() => _controller.Document.Workspace.ShowOnionSkins );
var cameraPreview = view.AddOption( "Viewmodel Camera Preview", "videocam", () =>
_controller.Mutate(
"Preview camera",
d => d.Workspace.FirstPersonPreview = !d.Workspace.FirstPersonPreview ) );
BindCheckedState(
cameraPreview,
() => _controller.Document.Workspace.FirstPersonPreview );
view.AddSeparator();
view.AddOption( "Reset Workspace", "restart_alt", ResetWorkspace );
var tools = MenuBar.AddMenu( "Tools" );
tools.AddOption( "Validate", "rule", Validate );
tools.AddOption( "Rebuild Preview Rig", "refresh", RebuildPreviewHost );
tools.AddOption( "Reimport Source", "published_with_changes", ReimportSource );
tools.AddOption( "Refresh Materials", "texture", RefreshMaterials );
tools.AddOption( "Open Generated Folder", "folder", OpenGeneratedFolder );
}
private static void BindCheckedState( Option option, Func<bool> fetch )
{
option.Checkable = true;
option.Checked = fetch();
option.FetchCheckedState = fetch;
}
private void BuildWorkspace()
{
if ( _rebuilding )
return;
_rebuilding = true;
SaveWorkspaceState();
DestroyWorkspace();
_root = new Widget( this );
_root.SetStyles( "background-color: rgb(13,15,17); border: none;" );
_root.Layout = Layout.Column();
_root.Layout.Margin = 0;
_root.Layout.Spacing = 4;
_toolbar = new WeaponAnimatorToolbar( _root );
BuildToolbar();
_root.Layout.Add( _toolbar );
_viewport = new WeaponAnimatorViewport( _controller );
_viewport.StatusChanged += ( message ) => _statusPanel?.SetMessage( message );
_viewport.LegacyIdleRepaired += () => _migrationBackupRequired = true;
if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
BuildCalibrationLayout();
else
BuildAnimationLayout();
Canvas = _root;
_rebuilding = false;
RefreshToolbarState();
}
private void BuildToolbar()
{
if ( _toolbar is null )
return;
_toolbar.Clear();
_toolbar.AddLeft( "Save", "save", () => Save() );
_generateButton = _toolbar.AddLeft( "Generate", "build", GenerateAssets, true );
if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Animate )
{
_playButton = _toolbar.AddLeft( "Play", "play_arrow", TogglePlayback );
}
_toolbar.AddLeft( "Undo", "undo", _controller.Undo, overflowAtNarrowWidth: true );
_toolbar.AddLeft( "Redo", "redo", _controller.Redo, overflowAtNarrowWidth: true );
var calibrate = _toolbar.AddCenter(
"1 Calibrate",
"straighten",
RequestCalibrationStage,
_controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate );
calibrate.IsToggle = true;
calibrate.IsChecked = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate;
var animate = _toolbar.AddCenter(
"2 Animate",
"animation",
() => SwitchStage( WeaponAnimatorStage.Animate ),
_controller.Document.ActiveStage == WeaponAnimatorStage.Animate );
animate.IsToggle = true;
animate.IsChecked = _controller.Document.ActiveStage == WeaponAnimatorStage.Animate;
_validationButton = _toolbar.AddRight( "Validate", "rule", Validate );
RefreshGenerationButton();
_toolbar.BalanceCenter();
}
private void BuildCalibrationLayout()
{
if ( _root is null || _viewport is null )
return;
var rigPanel = new RigAuditPanel( _controller );
rigPanel.ImportRequested += ImportSource;
rigPanel.RigReviewConfirmed += RebuildPreviewHost;
var inspector = new CalibrationInspectorPanel( _controller );
inspector.PickRequested += _viewport.SetPickMode;
inspector.AutoAlignRequested += AutoAlign;
inspector.ConfirmRequested += ConfirmCalibration;
inspector.RebuildPreviewRequested += RebuildPreviewHost;
inspector.SetModelDimensions( _viewport.ModelDimensions );
_viewport.ModelDimensionsChanged += inspector.SetModelDimensions;
_statusPanel = new ValidationStatusPanel();
var report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );
_statusPanel.SetReport( report );
var left = new PanelChrome( "RIG AUDIT", "account_tree", rigPanel );
var center = new PanelChrome( "3D CALIBRATION", "view_in_ar", _viewport );
var right = new PanelChrome( "CALIBRATION", "tune", inspector );
var bottom = new PanelChrome( "VALIDATION + IMPORT", "fact_check", _statusPanel );
left.MinimumSize = new Vector2( 260, 200 );
left.MaximumSize = new Vector2( 520, 10000 );
right.MinimumSize = new Vector2( 310, 200 );
right.MaximumSize = new Vector2( 560, 10000 );
center.MinimumSize = new Vector2( 420, 240 );
bottom.MinimumSize = new Vector2( 200, 55 );
bottom.MaximumSize = new Vector2( 10000, 190 );
BuildSplitLayout( left, center, right, bottom, true );
}
private void BuildAnimationLayout()
{
if ( _root is null || _viewport is null )
return;
var rigBrowser = new RigBrowserPanel( _controller );
var inspector = new SelectedControlInspectorPanel( _controller );
var clips = new ClipRackPanel(
_controller,
showClipHeader: false );
var timeline = new AnimationTimelinePanel( _controller );
clips.StatusChanged += ( message, severity ) => _statusPanel?.SetMessage( message, severity );
inspector.StatusChanged += ( message, severity ) => _statusPanel?.SetMessage( message, severity );
_statusPanel = new ValidationStatusPanel();
_statusPanel.SetReport( WeaponAnimationValidator.ValidateForGeneration( _controller.Document ) );
var left = new PanelChrome( "RIG BROWSER", "account_tree", rigBrowser );
var center = new PanelChrome( "3D ANIMATION", "view_in_ar", _viewport );
var right = new PanelChrome( "SELECTED CONTROL", "tune", inspector );
var clipRack = new PanelChrome( "CLIP RACK", "video_library", clips );
var bottom = new PanelChrome( "DOPE SHEET · CURVES · TAGS", "timeline", timeline );
left.MinimumSize = new Vector2( 330, 240 );
left.MaximumSize = new Vector2( 540, 10000 );
right.MinimumSize = new Vector2( 370, 240 );
right.MaximumSize = new Vector2( 600, 10000 );
clipRack.MinimumSize = new Vector2( 370, 260 );
clipRack.MaximumSize = new Vector2( 600, 10000 );
center.MinimumSize = new Vector2( 420, 260 );
bottom.MinimumSize = new Vector2( 300, 220 );
bottom.MaximumSize = new Vector2( 10000, 520 );
BuildAnimationSplitLayout( left, center, right, clipRack, bottom );
}
private void BuildAnimationSplitLayout(
Widget left,
Widget center,
Widget inspector,
Widget clips,
Widget timeline )
{
if ( _root is null )
return;
_verticalSplitter = new Splitter( _root )
{
IsVertical = true,
OpaqueResize = true,
HandleWidth = 4
};
_horizontalSplitter = new Splitter( _root )
{
IsHorizontal = true,
OpaqueResize = true,
HandleWidth = 4
};
_horizontalSplitter.AddWidget( left );
_horizontalSplitter.AddWidget( center );
_horizontalSplitter.SetStretch( 0, 0 );
_horizontalSplitter.SetStretch( 1, 1 );
_horizontalSplitter.SetCollapsible( 0, false );
_horizontalSplitter.SetCollapsible( 1, false );
_verticalSplitter.AddWidget( _horizontalSplitter );
_verticalSplitter.AddWidget( timeline );
_verticalSplitter.SetStretch( 0, 1 );
_verticalSplitter.SetStretch( 1, 0 );
_verticalSplitter.SetCollapsible( 0, false );
_verticalSplitter.SetCollapsible( 1, false );
_animationRightSplitter = new Splitter( _root )
{
IsVertical = true,
OpaqueResize = true,
HandleWidth = 4
};
_animationRightSplitter.AddWidget( inspector );
_animationRightSplitter.AddWidget( clips );
_animationRightSplitter.SetStretch( 0, 1 );
_animationRightSplitter.SetStretch( 1, 1 );
_animationRightSplitter.SetCollapsible( 0, false );
_animationRightSplitter.SetCollapsible( 1, false );
_animationOuterSplitter = new Splitter( _root )
{
IsHorizontal = true,
OpaqueResize = true,
HandleWidth = 4
};
_animationOuterSplitter.AddWidget( _verticalSplitter );
_animationOuterSplitter.AddWidget( _animationRightSplitter );
_animationOuterSplitter.SetStretch( 0, 1 );
_animationOuterSplitter.SetStretch( 1, 0 );
_animationOuterSplitter.SetCollapsible( 0, false );
_animationOuterSplitter.SetCollapsible( 1, false );
_root.Layout.Add( _animationOuterSplitter, 1 );
var workspace = _controller.Document.Workspace;
if ( !string.IsNullOrWhiteSpace( workspace.AnimationMainSplitterState ) )
_horizontalSplitter.RestoreState( workspace.AnimationMainSplitterState );
if ( !string.IsNullOrWhiteSpace( workspace.AnimationVerticalSplitterState ) )
_verticalSplitter.RestoreState( workspace.AnimationVerticalSplitterState );
if ( !string.IsNullOrWhiteSpace( workspace.AnimationRightSplitterState ) )
_animationRightSplitter.RestoreState( workspace.AnimationRightSplitterState );
if ( !string.IsNullOrWhiteSpace( workspace.AnimationOuterSplitterState ) )
_animationOuterSplitter.RestoreState( workspace.AnimationOuterSplitterState );
}
private void BuildSplitLayout(
Widget left,
Widget center,
Widget right,
Widget bottom,
bool calibration )
{
if ( _root is null )
return;
_horizontalSplitter = new Splitter( _root )
{
IsHorizontal = true,
OpaqueResize = true,
HandleWidth = 4
};
_horizontalSplitter.AddWidget( left );
_horizontalSplitter.AddWidget( center );
_horizontalSplitter.AddWidget( right );
_horizontalSplitter.SetStretch( 0, 0 );
_horizontalSplitter.SetStretch( 1, 1 );
_horizontalSplitter.SetStretch( 2, 0 );
_horizontalSplitter.SetCollapsible( 0, false );
_horizontalSplitter.SetCollapsible( 1, false );
_horizontalSplitter.SetCollapsible( 2, false );
_verticalSplitter = new Splitter( _root )
{
IsVertical = true,
OpaqueResize = true,
HandleWidth = 4
};
_verticalSplitter.AddWidget( _horizontalSplitter );
_verticalSplitter.AddWidget( bottom );
_verticalSplitter.SetStretch( 0, 1 );
_verticalSplitter.SetStretch( 1, 0 );
_verticalSplitter.SetCollapsible( 0, false );
_verticalSplitter.SetCollapsible( 1, false );
_root.Layout.Add( _verticalSplitter, 1 );
var workspace = _controller.Document.Workspace;
var horizontalState = calibration
? workspace.CalibrationSplitterState
: workspace.AnimationSplitterState;
var verticalState = calibration
? workspace.CalibrationVerticalSplitterState
: workspace.AnimationVerticalSplitterState;
if ( !string.IsNullOrWhiteSpace( horizontalState ) )
_horizontalSplitter.RestoreState( horizontalState );
if ( !string.IsNullOrWhiteSpace( verticalState ) )
_verticalSplitter.RestoreState( verticalState );
}
private void SaveWorkspaceState()
{
if ( _horizontalSplitter is null || _verticalSplitter is null )
return;
var workspace = _controller.Document.Workspace;
if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
{
workspace.CalibrationSplitterState = _horizontalSplitter.SaveState();
workspace.CalibrationVerticalSplitterState = _verticalSplitter.SaveState();
}
else
{
workspace.AnimationMainSplitterState = _horizontalSplitter.SaveState();
workspace.AnimationVerticalSplitterState = _verticalSplitter.SaveState();
if ( _animationRightSplitter is not null )
workspace.AnimationRightSplitterState = _animationRightSplitter.SaveState();
if ( _animationOuterSplitter is not null )
workspace.AnimationOuterSplitterState = _animationOuterSplitter.SaveState();
}
}
private void DestroyWorkspace()
{
// Destroy the private scene synchronously before replacing its widget tree.
_viewport?.ReleasePreviewScene();
if ( _root.IsValid() )
_root!.Destroy();
_root = null;
_toolbar = null;
_viewport = null;
_statusPanel = null;
_horizontalSplitter = null;
_verticalSplitter = null;
_animationRightSplitter = null;
_animationOuterSplitter = null;
_validationButton = null;
_playButton = null;
}
private void ImportSource()
{
var dialog = new FileDialog( this )
{
Title = "Import Rigged Weapon",
Directory = global::Editor.FileSystem.Content.GetFullPath( "/" )
};
dialog.SetModeOpen();
dialog.SetFindExistingFile();
dialog.SetNameFilter( "Rigged Models (*.fbx *.smd *.dmx *.vmdl)" );
if ( !dialog.Execute() )
return;
SourceImportResult? import = null;
PreviewHostResult? host = null;
_controller.Mutate( "Import source weapon", document =>
{
import = _importer.Import( document, dialog.SelectedFile );
if ( import.Success )
host = PreviewHostBuilder.Build( document );
} );
_statusPanel?.SetMessage(
$"{import?.Message} {host?.Message}",
import?.Success == true && host?.Success == true
? ValidationSeverity.Info
: ValidationSeverity.Error );
_viewport?.RebuildPreview();
}
private void ReimportSource()
{
var source = string.IsNullOrWhiteSpace( _controller.Document.Source.OriginalSourcePath )
? _controller.Document.Source.SourcePath
: _controller.Document.Source.OriginalSourcePath;
if ( string.IsNullOrWhiteSpace( source ) )
{
ImportSource();
return;
}
SourceImportResult? result = null;
_controller.Mutate( "Reimport source weapon", document =>
{
result = _importer.Import( document, source );
if ( result.Success )
PreviewHostBuilder.Build( document );
} );
_statusPanel?.SetMessage(
result?.Message ?? "Reimport failed.",
result?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );
_viewport?.RebuildPreview();
}
private async void RefreshMaterials()
{
if ( _refreshingMaterials || _generating )
{
_statusPanel?.SetMessage(
"Material refresh or generation is already in progress.",
ValidationSeverity.Warning );
return;
}
_refreshingMaterials = true;
Log.Info( "[Weapon Animator] manual material refresh requested." );
try
{
_statusPanel?.SetMessage(
"Discovering and compiling source materials…",
ValidationSeverity.Info );
var result = await RefreshMaterialsCoreAsync( "Refresh source materials" );
_statusPanel?.SetMessage(
result.Message,
result.Success
? ValidationSeverity.Info
: ValidationSeverity.Error );
}
catch ( Exception ex )
{
Log.Error( $"[Weapon Animator] material refresh threw: {ex}" );
_statusPanel?.SetMessage(
$"Material refresh failed: {ex.Message}",
ValidationSeverity.Error );
}
finally
{
_refreshingMaterials = false;
RefreshToolbarState();
}
}
private async System.Threading.Tasks.Task<SourceImportResult> RefreshMaterialsCoreAsync(
string historyDescription )
{
var recovered = false;
var recoveryMessage = "";
_controller.Mutate(
"Recover missing source preview",
document => recovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(
document,
out recoveryMessage ) );
if ( recovered )
{
_viewport?.RebuildPreview();
_statusPanel?.SetMessage( recoveryMessage, ValidationSeverity.Warning );
}
var documentId = _controller.Document.DocumentId;
var sourceHash = _controller.Document.Source.SourceHash;
var result = await _importer.RefreshMaterialsAsync( _controller.Document );
if ( !result.Success )
return result;
if ( _controller.Document.DocumentId != documentId
|| !string.Equals(
_controller.Document.Source.SourceHash,
sourceHash,
StringComparison.OrdinalIgnoreCase ) )
{
return new SourceImportResult
{
Success = false,
Message = "The open document or source changed while materials were compiling; "
+ "the candidate preview was not applied."
};
}
_controller.Mutate(
historyDescription,
document => WeaponSourceImporter.ApplyMaterialRefresh( document, result ) );
_viewport?.RebuildPreview();
WeaponSourceImporter.CleanupLegacyMaterialPreview( _controller.Document );
return result;
}
private void AutoAlign()
{
var document = _controller.Document;
var grip = document.Calibration.GetAnchor( AnchorKind.Grip );
var rear = document.Calibration.GetAnchor( AnchorKind.RearBore );
var front = document.Calibration.GetAnchor( AnchorKind.FrontBore );
if ( grip is null || rear is null || front is null )
{
_statusPanel?.SetMessage(
"Set the primary grip and both optional alignment markers before running Auto-align.",
ValidationSeverity.Error );
return;
}
if ( !WeaponAnimationMath.TryCalculateAlignment(
grip.LocalPosition,
rear.LocalPosition,
front.LocalPosition,
document.Calibration.UpAxis,
document.Calibration.UniformScale,
new Vector3( 12, -3, -2 ),
out var alignment ) )
{
_statusPanel?.SetMessage( "The selected anchors cannot produce a finite alignment.", ValidationSeverity.Error );
return;
}
_controller.Mutate( "Auto-align weapon", d =>
{
d.Calibration.PhysicalTransform = alignment.PhysicalTransform;
var rearWorld = alignment.PhysicalTransform.PointToWorld( rear.LocalPosition );
var correctionWorld = new Vector3( 0, -rearWorld.y, -rearWorld.z );
var correctionLocal = alignment.PhysicalTransform.PointToLocal(
alignment.PhysicalTransform.Position + correctionWorld );
d.Calibration.FramingTransform = d.Calibration.FramingTransform.WithPosition( correctionLocal );
d.Calibration.Confirmed = false;
} );
_statusPanel?.SetMessage(
alignment.BoreMayBeReversed
? "Aligned, but the bore points appear reversed. Swap rear and front if the muzzle faces away from +X."
: "Grip placed at the canonical hand origin; bore aligned to +X and projected through the crosshair.",
alignment.BoreMayBeReversed ? ValidationSeverity.Warning : ValidationSeverity.Info );
}
private void ConfirmCalibration()
{
PreviewHostResult? hostResult = null;
_controller.Mutate( "Build calibrated preview host", document =>
hostResult = PreviewHostBuilder.Build( document ) );
var report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );
if ( !report.IsValid || hostResult?.Success != true )
{
_statusPanel?.SetReport( report, hostResult?.Message ?? "" );
return;
}
var previous = _controller.Document.Calibration.Snapshot;
_controller.Mutate( "Confirm calibration", document =>
{
if ( _rebaseOnConfirm && previous is not null )
CalibrationRebaser.RebaseAnimationData( document, previous );
var calibration = document.Calibration;
calibration.Revision++;
calibration.Confirmed = true;
calibration.Snapshot = new CalibrationSnapshot
{
Revision = calibration.Revision,
SourceHash = document.Source.SourceHash,
RigHash = document.Rig.ProfileHash,
UniformScale = calibration.UniformScale,
PhysicalTransform = calibration.PhysicalTransform,
FramingTransform = calibration.FramingTransform,
Anchors = Json.Deserialize<System.Collections.Generic.List<WeaponAnchor>>(
Json.Serialize( calibration.Anchors ) ) ?? [],
ConfirmedUtc = DateTime.UtcNow
};
if ( previous is null )
CalibrationBindingSeeder.SeedDefaultPrimaryHand( document );
var idle = document.EnsureClip( WeaponClipRole.Idle );
if ( idle.Tracks.Count == 0 || idle.IsBindPoseSeed )
{
var skeleton = HostSkeletonBuilder.BuildCached( document );
IdleBindPoseService.SeedFromCurrentBind( document, skeleton );
}
idle.Readiness = ClipReadiness.Ready;
document.Workspace.SelectedClipId = idle.Id;
document.ActiveStage = WeaponAnimatorStage.Animate;
} );
_rebaseOnConfirm = false;
BuildWorkspace();
}
private void RequestCalibrationStage()
{
if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
return;
var hasAnimation = _controller.Document.Clips.Any( x =>
x.Tracks.Count > 0 && x.Role != WeaponClipRole.Idle );
if ( !hasAnimation )
{
SwitchStage( WeaponAnimatorStage.Calibrate );
return;
}
Dialog.AskConfirm(
() =>
{
_rebaseOnConfirm = true;
SwitchStage( WeaponAnimatorStage.Calibrate );
},
() =>
{
Dialog.AskConfirm(
() =>
{
_controller.Mutate(
"Discard animation for recalibration",
CalibrationRebaser.DiscardAnimationData );
_rebaseOnConfirm = false;
SwitchStage( WeaponAnimatorStage.Calibrate );
},
"Discard all authored animation and binding data before recalibrating?",
"Discard Animation Data",
"Discard",
"Cancel" );
},
"Rebase bindings, controls, and animation roots onto the new calibration when it is confirmed?",
"Return to Calibration",
"Rebase",
"Other Options" );
}
private void SwitchStage( WeaponAnimatorStage stage )
{
if ( stage == _controller.Document.ActiveStage )
return;
if ( stage == WeaponAnimatorStage.Animate )
{
var report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );
if ( !_controller.Document.Calibration.Confirmed || !report.IsValid )
{
_statusPanel?.SetMessage(
"Confirm a valid calibration before entering Animate.",
ValidationSeverity.Error );
return;
}
}
SaveWorkspaceState();
_controller.Mutate( $"Switch to {stage}", d => d.ActiveStage = stage );
BuildWorkspace();
}
private bool Save()
{
if ( _asset is null || _resource is null )
{
SaveAs();
return _asset is not null;
}
SaveWorkspaceState();
if ( _migrationBackupRequired )
{
try
{
WeaponAnimationMigration.CreateBackup(
_asset.AbsolutePath,
_migration?.SourceSchemaVersion ?? 2 );
}
catch ( Exception ex )
{
_statusPanel?.SetMessage(
$"Migration backup failed; the project was not saved: {ex.Message}",
ValidationSeverity.Error );
return false;
}
}
_resource.Document = _controller.Document;
if ( !_asset.SaveToDisk( _resource ) )
{
_statusPanel?.SetMessage( "The .wepanim asset could not be saved.", ValidationSeverity.Error );
return false;
}
_controller.MarkSaved();
_migrationBackupRequired = false;
RecoveryService.Clear( _controller.Document.DocumentId );
_statusPanel?.SetMessage( $"Saved {_asset.Path}." );
RefreshTitle();
return true;
}
private void SaveAs()
{
var dialog = new FileDialog( this )
{
Title = "Save Weapon Animation Project As",
Directory = global::Editor.FileSystem.Content.GetFullPath( "/" ),
DefaultSuffix = "wepanim"
};
dialog.SetModeSave();
dialog.SetFindFile();
dialog.SetNameFilter( "Weapon Animation Project (*.wepanim)" );
if ( !dialog.Execute() )
return;
var path = Path.ChangeExtension( dialog.SelectedFile, ".wepanim" );
var asset = AssetSystem.CreateResource( "wepanim", path );
if ( asset is null )
{
_statusPanel?.SetMessage( "Could not create the new .wepanim asset.", ValidationSeverity.Error );
return;
}
_asset = asset;
// Saving under a new filename renames the project, so the generated output follows it.
AdoptAssetFileName( _controller.Document, _asset );
_resource = new WeaponAnimationAsset { Document = _controller.Document };
if ( !_asset.SaveToDisk( _resource ) )
{
_statusPanel?.SetMessage( "Save As failed.", ValidationSeverity.Error );
return;
}
_controller.MarkSaved();
_migrationBackupRequired = false;
RecoveryService.Clear( _controller.Document.DocumentId );
RefreshTitle();
}
private async void GenerateAssets()
{
// Compiling waits on the asset system across frames, so keep a second press from
// starting a competing run over the same output files.
if ( _generating )
{
_generationCancellation?.Cancel();
_statusPanel?.SetMessage(
"Cancelling asset generation safely…",
ValidationSeverity.Warning );
return;
}
_generating = true;
_generationCancellation = new CancellationTokenSource();
RefreshGenerationButton();
Log.Info( "[Weapon Animator] asset generation requested." );
try
{
if ( WeaponMaterialPipeline.RequiresPreviewRefresh( _controller.Document ) )
{
var materialImport = await RefreshMaterialsCoreAsync(
"Discover source materials" );
if ( !materialImport.Success )
{
_statusPanel?.SetMessage(
materialImport.Message,
ValidationSeverity.Error );
return;
}
}
_statusPanel?.SetMessage( "Generating and compiling assets…", ValidationSeverity.Info );
var result = await _generator.GenerateAsync(
_controller.Document,
progress =>
{
var count = progress.Total > 0
? $" {progress.Completed}/{progress.Total}"
: "";
_statusPanel?.SetMessage(
$"{progress.Stage}{count} — {progress.Detail}",
ValidationSeverity.Info );
},
_generationCancellation.Token );
if ( result.Success )
{
_statusPanel?.SetMessage(
$"Generated and reloaded {result.GeneratedFiles.Count} files in {result.OutputFolder}.",
ValidationSeverity.Info );
Save();
}
else if ( result.Cancelled )
{
_statusPanel?.SetMessage(
"Asset generation cancelled; previous owned outputs were restored.",
ValidationSeverity.Warning );
}
else
{
var message = string.Join(
" · ",
result.Diagnostics.Where( x => x.Severity == ValidationSeverity.Error )
.Select( x => x.Message )
.Take( 4 ) );
_statusPanel?.SetMessage(
string.IsNullOrWhiteSpace( message ) ? "Generation failed validation." : message,
ValidationSeverity.Error );
}
}
catch ( OperationCanceledException )
{
_statusPanel?.SetMessage(
"Asset generation cancelled before outputs were changed.",
ValidationSeverity.Warning );
}
catch ( Exception ex )
{
Log.Error( $"[Weapon Animator] generation threw: {ex}" );
_statusPanel?.SetMessage( $"Generation failed: {ex.Message}", ValidationSeverity.Error );
}
finally
{
_generating = false;
_generationCancellation?.Dispose();
_generationCancellation = null;
RefreshGenerationButton();
RefreshToolbarState();
if ( _closeAfterGenerationStops )
{
_closeAfterGenerationStops = false;
Close();
}
}
}
private void RefreshGenerationButton()
{
if ( _generateButton is null )
return;
_generateButton.Text = _generating ? "Cancel" : "Generate";
_generateButton.Icon = _generating ? "stop" : "build";
_generateButton.Tint = _generating
? WeaponAnimatorTheme.Coral * 0.58f
: WeaponAnimatorTheme.Cyan * 0.72f;
if ( _generateButton is WeaponAnimatorButton button )
button.FitToContent( true );
_toolbar?.BalanceCenter();
}
private void Validate()
{
var report = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate
? WeaponAnimationValidator.ValidateCalibration( _controller.Document )
: WeaponAnimationValidator.ValidateForGeneration( _controller.Document );
_statusPanel?.SetReport( report );
RefreshToolbarState();
}
private void RebuildPreviewHost()
{
PreviewHostResult? result = null;
_controller.Mutate( "Rebuild preview host", document =>
result = PreviewHostBuilder.Build( document ) );
_statusPanel?.SetMessage(
result?.Message ?? "Preview host rebuild failed.",
result?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );
_viewport?.RebuildPreview();
}
private void OpenGeneratedFolder()
{
try
{
var path = AssetGenerationService.GetOutputFolder( _controller.Document );
if ( Directory.Exists( path ) )
EditorUtility.OpenFolder( path );
else
_statusPanel?.SetMessage( "Generate assets before opening the output folder.", ValidationSeverity.Warning );
}
catch ( Exception ex )
{
_statusPanel?.SetMessage(
$"Could not resolve the output folder: {ex.Message}",
ValidationSeverity.Error );
}
}
private void TogglePlayback()
{
_controller.TogglePlayback();
}
private void ResetWorkspace()
{
_controller.Mutate( "Reset workspace", document =>
{
var state = document.Workspace;
state.CameraFocus = Vector3.Zero;
state.CameraAngles = new Angles( 12, 180, 0 );
state.CameraDistance = 48;
state.FreeLookCamera = false;
state.CameraPosition = Vector3.Zero;
state.CameraMoveSpeed = 1;
state.FullBrightViewport = false;
state.CalibrationSplitterState = "";
state.CalibrationVerticalSplitterState = "";
state.AnimationSplitterState = "";
state.AnimationVerticalSplitterState = "";
state.AnimationTimelineSplitterState = "";
state.AnimationRightSplitterState = "";
state.AnimationMainSplitterState = "";
state.AnimationOuterSplitterState = "";
state.TimelineViews.Clear();
state.CurveViews.Clear();
} );
BuildWorkspace();
_viewport?.FitCamera();
}
private void OpenPreferences()
{
new WeaponAnimatorPreferencesWindow( _controller ).Show();
}
private void OnDocumentChanged()
{
if ( _controller.IsDirty )
QueueRecoveryWrite();
RefreshTitle();
RefreshToolbarState();
}
private void OnDirtyChanged()
{
if ( _controller.IsDirty )
QueueRecoveryWrite();
RefreshTitle();
}
private void QueueRecoveryWrite()
{
if ( _closing || !_controller.IsDirty )
return;
_recoveryRequestVersion++;
if ( _recoveryWritePending )
return;
_recoveryWritePending = true;
_ = WriteRecoveryAfterQuietPeriodAsync();
}
private async Task WriteRecoveryAfterQuietPeriodAsync()
{
try
{
while ( !_closing && _controller.IsDirty )
{
var requestedVersion = _recoveryRequestVersion;
await Task.Delay( 750 );
if ( requestedVersion != _recoveryRequestVersion )
continue;
// Re-check after the wait. Saving inside the quiet period clears the recovery
// file, and writing it back would make the next open offer to restore a snapshot
// of an already-saved project.
if ( _closing || !_controller.IsDirty )
return;
// Serialize on the main thread: the continuation above can resume on a worker,
// and the document may be mutated while it is being written.
await GameTask.MainThread();
if ( !_closing && _controller.IsDirty )
RecoveryService.Write( _controller.Document );
return;
}
}
finally
{
_recoveryWritePending = false;
}
}
/// <summary>
/// The .wepanim filename is the project's identity: generated folders and asset names follow it.
/// This has to run on every open rather than only for new documents, because
/// <c>WeaponAnimationAsset.Document</c> is initialised with <c>CreateDefault()</c> — it is never
/// null, so an asset created outside the New Project flow always arrives carrying the
/// "New Weapon" default and would otherwise generate into <c>weapons/new_weapon</c>.
/// </summary>
internal static bool AdoptAssetFileName( WeaponAnimationDocument document, Asset? asset ) =>
AdoptAssetFileName( document, asset?.Path );
internal static bool AdoptAssetFileName( WeaponAnimationDocument document, string? assetPath )
{
if ( string.IsNullOrWhiteSpace( assetPath ) )
return false;
var fileName = Path.GetFileNameWithoutExtension( assetPath.Replace( '\\', '/' ) );
var slug = WeaponAnimationDocument.Slugify( fileName );
if ( string.IsNullOrWhiteSpace( slug ) )
return false;
var changed = false;
if ( document.Name != fileName )
{
document.Name = fileName;
changed = true;
}
document.Output ??= new OutputSettings();
if ( document.Output.AssetName != slug )
{
document.Output.AssetName = slug;
changed = true;
}
return changed;
}
private void RefreshTitle()
{
WindowTitle = ComposeWindowTitle(
_asset?.Path ?? "",
_controller.Document.Name,
_controller.IsDirty );
Title = WindowTitle;
}
internal static string ComposeWindowTitle(
string assetPath,
string documentName,
bool dirty )
{
var fileName = string.IsNullOrWhiteSpace( assetPath )
? documentName
: Path.GetFileName( assetPath.Replace( '\\', '/' ) );
if ( string.IsNullOrWhiteSpace( fileName ) )
fileName = "New Weapon";
return $"S&box Weapon Animator — {fileName}{(dirty ? " *" : "")}";
}
private void RefreshToolbarState()
{
if ( _validationButton is null )
return;
var report = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate
? WeaponAnimationValidator.ValidateCalibration( _controller.Document )
: WeaponAnimationValidator.ValidateForGeneration( _controller.Document );
_validationButton.Text = report.IsValid
? report.WarningCount > 0 ? $"{report.WarningCount} warnings" : "Valid"
: $"{report.ErrorCount} errors";
if ( _validationButton is WeaponAnimatorButton validationButton )
validationButton.FitToContent( true );
_validationButton.Icon = report.IsValid
? report.WarningCount > 0 ? "warning" : "check_circle"
: "error";
_validationButton.Tint = report.IsValid
? report.WarningCount > 0 ? WeaponAnimatorTheme.Amber * 0.45f : WeaponAnimatorTheme.Green * 0.45f
: WeaponAnimatorTheme.Coral * 0.5f;
if ( _playButton is not null )
{
_playButton.Text = _controller.IsPlaying ? "Pause" : "Play";
_playButton.Icon = _controller.IsPlaying ? "pause" : "play_arrow";
if ( _playButton is WeaponAnimatorButton playButton )
playButton.FitToContent( true );
}
_toolbar?.BalanceCenter();
}
private bool OfferRecovery()
{
if ( _asset is null )
return false;
var writeUtc = File.Exists( _asset.AbsolutePath )
? File.GetLastWriteTimeUtc( _asset.AbsolutePath )
: DateTime.MinValue;
var recovery = RecoveryService.ReadNewerThan( _controller.Document.DocumentId, writeUtc );
if ( recovery is null )
return false;
Dialog.AskConfirm(
() =>
{
var migration = MigrateAndRepair( recovery );
if ( migration.Migrated )
{
_migration = migration;
_migrationBackupRequired = true;
}
NormalizeRecoveredSource( recovery );
if ( recovery.Source.Compiled )
PreviewHostBuilder.Build( recovery );
_controller.ReplaceWithoutHistory( recovery, true );
BuildWorkspace();
var message = migration.Migrated
? $"Recovered the newer autosave snapshot. {migration.Summary}"
: "Recovered the newer autosave snapshot.";
_statusPanel?.SetMessage( message, ValidationSeverity.Warning );
},
() => RecoveryService.Clear( _controller.Document.DocumentId ),
"A newer recovery snapshot exists for this project. Restore it?",
"Recover Weapon Animation Project",
"Restore",
"Discard Recovery" );
return true;
}
private void OfferCachedImportRecovery()
{
var document = _controller.Document;
if ( !string.IsNullOrWhiteSpace( document.Source.SourcePath ) )
return;
var cachedSource = FindCachedSource( document.DocumentId );
if ( string.IsNullOrWhiteSpace( cachedSource ) )
return;
Dialog.AskConfirm(
() =>
{
var result = _importer.Import( document, cachedSource );
var host = result.Success ? PreviewHostBuilder.Build( document ) : null;
_controller.ReplaceWithoutHistory( document, true );
BuildWorkspace();
_statusPanel?.SetMessage(
$"{result.Message} {host?.Message}",
result.Success && host?.Success == true
? ValidationSeverity.Info
: ValidationSeverity.Error );
},
() => { },
"The saved document is empty, but a previous weapon import remains in its private cache. Recover that import?",
"Recover Cached Weapon Import",
"Recover Import",
"Ignore Cache" );
}
private static string FindCachedSource( Guid documentId )
{
var cache = WeaponSourceImporter.GetPreviewCacheRoot( documentId );
if ( !Directory.Exists( cache ) )
return "";
var wrapper = Directory.EnumerateFiles( cache, "source_*.vmdl" )
.OrderByDescending( File.GetLastWriteTimeUtc )
.FirstOrDefault();
if ( string.IsNullOrWhiteSpace( wrapper ) )
return "";
var match = Regex.Match(
File.ReadAllText( wrapper ),
"filename\\s*=\\s*\"(?<path>[^\"]+\\.(?:fbx|smd|dmx|vmdl))\"",
RegexOptions.IgnoreCase );
if ( !match.Success )
return "";
var relative = match.Groups["path"].Value;
var absolute = global::Editor.FileSystem.Content.GetFullPath( relative );
if ( File.Exists( absolute ) )
return absolute;
var directory = Path.GetDirectoryName( absolute );
var filename = Path.GetFileName( absolute );
if ( string.IsNullOrWhiteSpace( directory ) || !Directory.Exists( directory ) )
return "";
return Directory.EnumerateFiles( directory )
.FirstOrDefault( path =>
Path.GetFileName( path ).Equals( filename, StringComparison.OrdinalIgnoreCase ) )
?? "";
}
private void NormalizeRecoveredSource( WeaponAnimationDocument document )
{
if ( !document.Source.Compiled
|| !document.Source.NeedsModelDocWrapper
|| string.IsNullOrWhiteSpace( document.Rig.RootBone )
|| document.Rig.RootBone.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase )
|| !string.IsNullOrWhiteSpace( document.Source.SourceRootBoneName ) )
return;
var source = string.IsNullOrWhiteSpace( document.Source.OriginalSourcePath )
? document.Source.SourcePath
: document.Source.OriginalSourcePath;
_importer.Import( document, source );
}
private void CloseAfterPrompt( bool clearRecovery = true )
{
_closing = true;
_recoveryRequestVersion++;
if ( clearRecovery )
RecoveryService.Clear( _controller.Document.DocumentId );
_allowClose = true;
Close();
}
private static WeaponAnimationMigrationResult MigrateAndRepair(
WeaponAnimationDocument document ) =>
WeaponAnimationMigration.MigrateAndRepair( document );
}
public static class WeaponAnimatorLauncher
{
[Menu( "Editor", "Tools/Weapon Animator/Open Weapon Animator", "animation", Priority = 0 )]
public static void OpenPicker()
{
new WeaponAnimatorPickerWindow().Show();
}
public static void CreateNew()
{
var dialog = new FileDialog( null )
{
Title = "Create Weapon Animation Project",
Directory = global::Editor.FileSystem.Content.GetFullPath( "/" ),
DefaultSuffix = "wepanim"
};
dialog.SetModeSave();
dialog.SetFindFile();
dialog.SetNameFilter( "Weapon Animation Project (*.wepanim)" );
if ( !dialog.Execute() )
return;
var path = Path.ChangeExtension( dialog.SelectedFile, ".wepanim" );
var asset = AssetSystem.CreateResource( "wepanim", path );
if ( asset is null )
return;
var resource = new WeaponAnimationAsset
{
Document = WeaponAnimationDocument.CreateDefault( Path.GetFileNameWithoutExtension( path ) )
};
asset.SaveToDisk( resource );
IAssetEditor.OpenInEditor( asset, out _ );
}
public static void OpenExisting()
{
var dialog = new FileDialog( null )
{
Title = "Open Weapon Animation Project",
Directory = global::Editor.FileSystem.Content.GetFullPath( "/" )
};
dialog.SetModeOpen();
dialog.SetFindExistingFile();
dialog.SetNameFilter( "Weapon Animation Project (*.wepanim)" );
if ( !dialog.Execute() )
return;
var asset = AssetSystem.FindByPath( dialog.SelectedFile )
?? AssetSystem.RegisterFile( dialog.SelectedFile );
if ( asset is not null )
IAssetEditor.OpenInEditor( asset, out _ );
}
}
internal sealed class WeaponAnimatorPickerWindow : Window
{
public WeaponAnimatorPickerWindow()
{
DeleteOnClose = true;
WindowTitle = "Weapon Animator";
Title = WindowTitle;
Size = new Vector2( 520, 260 );
SetWindowIcon( "animation" );
var root = new Widget( this );
root.SetStyles( "background-color: rgb(13,15,17);" );
root.Layout = Layout.Column();
root.Layout.Margin = new Sandbox.UI.Margin( 28 );
root.Layout.Spacing = 14;
var title = WeaponAnimatorTheme.Label( "WEAPON ANIMATOR", root );
title.SetStyles(
"background-color: transparent; border: none; padding: 0px;" +
$"font-size: 18px; font-weight: 600; letter-spacing: 1.2px; color: {WeaponAnimatorTheme.Text.Hex};" );
root.Layout.Add( title );
var description = WeaponAnimatorTheme.Label(
"Open a document-driven import, calibration, binding, and animation workspace. No active scene or selected GameObject is required.",
root,
true );
description.WordWrap = true;
root.Layout.Add( description );
var row = RigAuditPanel.Row( root );
row.Layout.Add( WeaponAnimatorTheme.Button(
"New project",
"note_add",
() =>
{
Close();
WeaponAnimatorLauncher.CreateNew();
},
row,
true ), 1 );
row.Layout.Add( WeaponAnimatorTheme.Button(
"Open existing",
"folder_open",
() =>
{
Close();
WeaponAnimatorLauncher.OpenExisting();
},
row ), 1 );
root.Layout.Add( row );
root.Layout.AddStretchCell();
Canvas = root;
}
}
internal sealed class WeaponAnimatorPreferencesWindow : Window
{
public WeaponAnimatorPreferencesWindow( WeaponAnimatorController controller )
{
DeleteOnClose = true;
WindowTitle = "Weapon Animator Preferences";
Title = WindowTitle;
Size = new Vector2( 420, 520 );
var root = new Widget( this );
root.SetStyles( "background-color: rgb(13,15,17);" );
root.Layout = Layout.Column();
root.Layout.Margin = new Sandbox.UI.Margin( 18 );
root.Layout.Spacing = 8;
root.Layout.Add( Toggle(
"Auto-key transformed controls",
controller.Document.Workspace.AutoKey,
value => controller.Mutate( "Auto-key preference", d => d.Workspace.AutoKey = value ) ) );
root.Layout.Add( Toggle(
"Use local gizmo space",
controller.Document.Workspace.LocalGizmos,
value => controller.Mutate( "Gizmo preference", d => d.Workspace.LocalGizmos = value ) ) );
root.Layout.Add( Toggle(
"Snap position",
controller.Document.Workspace.SnapPosition,
value => controller.Mutate( "Position snapping", d => d.Workspace.SnapPosition = value ) ) );
root.Layout.Add( Toggle(
"Snap rotation",
controller.Document.Workspace.SnapRotation,
value => controller.Mutate( "Rotation snapping", d => d.Workspace.SnapRotation = value ) ) );
root.Layout.Add( Number(
"Rotation snap angle",
controller.Document.Workspace.RotationSnapDegrees,
0.25f,
180,
value => controller.UpdateWorkspacePreference(
"Rotation snap angle",
workspace => workspace.RotationSnapDegrees = value ) ) );
root.Layout.Add( WeaponAnimatorTheme.SectionLabel(
"VIEWPORT GRID",
root,
topMargin: true ) );
root.Layout.Add( Number(
"Grid opacity",
controller.Document.Workspace.GridOpacity,
0,
0.5f,
value => controller.UpdateWorkspacePreference(
"Grid opacity",
workspace => workspace.GridOpacity = value ) ) );
root.Layout.Add( Number(
"Grid line weight",
controller.Document.Workspace.GridLineThickness,
0.1f,
2,
value => controller.UpdateWorkspacePreference(
"Grid line weight",
workspace => workspace.GridLineThickness = value ) ) );
root.Layout.Add( WeaponAnimatorTheme.SectionLabel(
"VIEWPORT LIGHTING",
root,
topMargin: true ) );
root.Layout.Add( Toggle(
"Cyan edge light",
controller.Document.Workspace.RimLightEnabled,
value => controller.UpdateWorkspacePreference(
"Cyan edge light",
workspace => workspace.RimLightEnabled = value ) ) );
root.Layout.Add( Number(
"Cyan edge brightness",
controller.Document.Workspace.RimLightIntensity,
0,
12,
value => controller.UpdateWorkspacePreference(
"Cyan edge brightness",
workspace => workspace.RimLightIntensity = value ) ) );
var lightingNote = WeaponAnimatorTheme.Label(
"The edge light is disabled automatically in Full Bright.",
root,
true );
lightingNote.WordWrap = true;
root.Layout.Add( lightingNote );
root.Layout.AddStretchCell();
root.Layout.Add( WeaponAnimatorTheme.Button( "Close", "close", Close, root, true ) );
Canvas = root;
Button Toggle( string text, bool value, Action<bool> changed )
{
var button = new WeaponAnimatorButton( text, root )
{
IsToggle = true,
IsChecked = value,
Tint = WeaponAnimatorTheme.SurfaceRaised
};
button.Toggled = () => changed( button.IsChecked );
return button;
}
Widget Number(
string text,
float value,
float minimum,
float maximum,
Action<float> changed )
{
var container = new Widget( root );
container.Layout = Layout.Column();
container.Layout.Margin = 0;
container.Layout.Spacing = 3;
var row = RigAuditPanel.Row( container );
row.Layout.Add( WeaponAnimatorTheme.Label( text, row, true ), 1 );
var edit = new LineEdit( row )
{
Text = value.ToString( "0.##", CultureInfo.InvariantCulture ),
FixedWidth = 82,
FixedHeight = 27
};
edit.SetStyles( WeaponAnimatorTheme.InputStyle );
var slider = new FloatSlider( container )
{
Minimum = minimum,
Maximum = maximum,
Value = value,
FixedHeight = 18
};
void Apply( float candidate, bool updateEdit )
{
var clamped = Math.Clamp( candidate, minimum, maximum );
if ( updateEdit )
edit.Text = clamped.ToString( "0.##", CultureInfo.InvariantCulture );
slider.Value = clamped;
changed( clamped );
}
edit.TextEdited += textValue =>
{
if ( float.TryParse(
textValue,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var parsed )
&& WeaponAnimationMath.IsFinite( parsed ) )
Apply( parsed, false );
};
edit.EditingFinished += () => Apply( slider.Value, true );
slider.OnValueEdited = () => Apply( slider.Value, true );
row.Layout.Add( edit );
container.Layout.Add( row );
container.Layout.Add( slider );
return container;
}
}
}