Editor/WeaponImporter/Tool/UI/WeaponImporterWindow.cs
using Editor;
using Sandbox;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Setup;
using WeaponImporter.Core.Weapon;
namespace WeaponImporter.Tool.UI;
/// <summary>
/// The Weapon Importer tool window. First the drop page (drop a weapon or pick one); once the
/// weapon is loaded, the editor: pages on the left, the live preview in the middle with the
/// timeline under it, and the selected page's properties on the right.
/// </summary>
public sealed partial class WeaponImporterWindow : Widget
{
public const string DockTitle = "Weapon Importer";
public const string DockIcon = "back_hand";
private static WeaponImporterWindow _instance;
public static WeaponImporterWindow Instance => _instance.IsValid() ? _instance : null;
public ImporterController Controller { get; } = new();
private DropArea _drop;
private ProcessingIndicator _firstLoad;
private Widget _main;
private Label _weaponName;
private Pill _typePill;
private SegmentedControl _mode;
private IconButton _autoSetup;
private Button _bake;
private NavList _nav;
private ScrollArea _pageScroll;
/// <summary>The scrolling page column (layout checks).</summary>
internal ScrollArea PageScroll => _pageScroll;
private readonly List<StepPanel> _steps = new();
private Card _viewportCard;
private Widget _viewportHost;
private WeaponViewport _viewport;
private ProcessingIndicator _viewportLoader;
private TimelineCard _timeline;
private StatusDot _statusDot;
/// <summary>Whether the bottom status line is showing (hidden on the start screen).</summary>
public bool StatusLineVisible => _status.IsValid() && _status.Visible;
private Label _status;
private UiButton _cancel;
/// <summary>The editor pages, in order (also keys 1–5).</summary>
private static readonly (string Icon, string Title, string Tip)[] Pages =
{
("category", "Weapon", "Type, orientation, parts, muzzle and shell eject"),
("back_hand", "Grip", "Where the hands hold the weapon"),
("movie", "Animations", "Which clip plays for each action"),
("texture", "Materials", "Textures of the weapon's materials"),
("inventory_2", "Export", "Checks and bake"),
};
public WeaponImporterWindow( Widget parent ) : base( parent )
{
if ( !_instance.IsValid() )
_instance = this;
Name = "WeaponImporter";
WindowTitle = DockTitle;
SetWindowIcon( DockIcon );
MinimumSize = new Vector2( 900, 560 );
AcceptDrops = true;
// The first import otherwise pays for loading the analysis code (a two second stall).
_ = Task.Run( WeaponImporter.Core.Warmup.Run );
FocusMode = FocusMode.Click;
Layout = Layout.Column();
Layout.Margin = 12;
Layout.Spacing = 10;
Build();
Controller.Changed += OnChanged;
Controller.SessionReplaced += OnSessionReplaced;
Controller.BusyChanged += OnBusyChanged;
Controller.StatusChanged += OnStatusChanged;
Controller.RoleChanged += OnRoleChanged;
OnSessionReplaced();
OnStatusChanged();
}
// ------------------------------------------------------------------ registration
[Event( "tools.editorwindow.createview" )]
private static void RegisterViewMenu( Menu menu ) => EditorWindow.DockManager.RegisterDockType( new DockManager.DockInfo
{
Title = DockTitle,
Icon = DockIcon,
CreateAction = () =>
{
Open();
return null;
},
} );
[Event( "tools.editorwindow.postcreateview" )]
private static void ConfigureViewMenu( Menu menu )
{
var option = menu.GetOption( DockTitle );
if ( option is null )
return;
option.Toggled = null;
option.Checkable = false;
option.Triggered = () => Open();
}
[Event( "asset.contextmenu", Priority = 60 )]
private static void OnAssetContextMenu( AssetContextMenu e )
{
if ( e.SelectedList is not { Count: 1 } )
return;
var path = e.SelectedList[0].AbsolutePath;
if ( DropPaths.IsSetupFile( path ) )
e.Menu.AddOption( "Open Weapon Setup", DockIcon, () => Open().ImportFile( path ) );
else if ( DropPaths.IsWeaponFile( path ) )
e.Menu.AddOption( "Import as Weapon…", DockIcon, () => Open().ImportFile( path ) );
}
public static WeaponImporterWindow Open()
{
if ( Instance is { } existing )
{
existing.GetWindow().Show();
existing.Show();
existing.GetWindow().Raise();
return existing;
}
var dialog = new Dialog( null );
dialog.Window.Title = DockTitle;
dialog.Window.SetWindowIcon( DockIcon );
dialog.Layout = Layout.Column();
var window = dialog.Layout.Add( new WeaponImporterWindow( dialog ), 1 );
dialog.Window.MinimumSize = new Vector2( 900, 560 );
dialog.Window.Size = new Vector2( 1340, 800 );
dialog.Show();
return window;
}
// ------------------------------------------------------------------ layout
private void Build()
{
// The first page: unchanged from the previous importer (drop area, three buttons, loader).
_drop = Layout.Add( new DropArea( this, ImportFile, () => ChooseFile(), ChooseFromDisk, UseTemplate ), 1 );
_firstLoad = Layout.Add( new ProcessingIndicator( this ) { Visible = false }, 1 );
_main = Layout.Add( new Widget( this ) { Visible = false }, 1 );
_main.Layout = Layout.Column();
_main.Layout.Spacing = 10;
BuildHeader( _main.Layout );
var body = _main.Layout.AddRow( 1 );
body.Spacing = 10;
// Pages: clickable rows.
var navColumn = body.AddColumn();
_nav = navColumn.Add( new NavList( _main ) );
foreach ( var (icon, title, tip) in Pages )
_nav.Add( icon, title, tip );
_nav.OnSelected = SelectStep;
navColumn.AddStretchCell();
// Preview + timeline | page properties.
var split = body.Add( new Splitter( _main ) { IsHorizontal = true }, 1 );
var center = new Widget( split );
center.Layout = Layout.Column();
center.Layout.Spacing = 10;
BuildViewportCard( center );
_timeline = center.Layout.Add( new TimelineCard( center, Controller ) );
split.AddWidget( center );
var right = new PagePane( split );
right.Layout = Layout.Column();
right.Layout.Margin = new Sandbox.UI.Margin( 6, 0, 0, 0 );
_pageScroll = right.Layout.Add( new ScrollArea( right ), 1 );
_pageScroll.HorizontalScrollbarMode = ScrollbarMode.Off;
var canvas = new Widget( _pageScroll );
canvas.Layout = Layout.Column();
canvas.Layout.Margin = new Sandbox.UI.Margin( 0, 0, 12, 0 ); // clear of the overlay scrollbar
canvas.Layout.Spacing = 10;
_steps.Add( canvas.Layout.Add( new WeaponStep( canvas, Controller ) ) );
_steps.Add( canvas.Layout.Add( new HandsStep( canvas, Controller ) ) );
_steps.Add( canvas.Layout.Add( new AnimationsStep( canvas, Controller ) ) );
_steps.Add( canvas.Layout.Add( new MaterialsStep( canvas, Controller ) ) );
_steps.Add( canvas.Layout.Add( new CheckStep( canvas, Controller ) ) );
canvas.Layout.AddStretchCell();
_pageScroll.Canvas = canvas;
split.AddWidget( right );
split.SetStretch( 0, 1 );
split.SetStretch( 1, 0 );
split.SetCollapsible( 0, false );
split.SetCollapsible( 1, false );
// Status line: a light (working, ready, error) and what is happening, with Cancel beside it.
var status = Layout.AddRow();
status.Spacing = 8;
_statusDot = status.Add( new StatusDot( this ) );
// Fixed height and one line: if this row ever changed height (a wrapped message, Cancel
// appearing) the preview above would resize and re-create its render surface, stalling
// the editor for seconds. Long messages are elided; the full text is the tooltip.
_status = status.Add( new Label( "", this ) { WordWrap = false, FixedHeight = UiStyle.ControlHeight, MinimumWidth = 40 }, 1 );
_cancel = status.Add( new UiButton( this, "Cancel", "cancel", () => Controller.Cancel(), "Stop the running operation" ) { Visible = false } );
SelectStep( 0 );
}
private void BuildHeader( Layout parent )
{
var header = parent.AddRow();
header.Spacing = 8;
_weaponName = header.Add( new Label( "", this ) );
_weaponName.SetStyles( "font-weight: 600; font-size: 15px;" );
_typePill = header.Add( new Pill( this, "", Theme.TextLight ) );
header.AddStretchCell();
// The two ways of working with a weapon.
_mode = header.Add( new SegmentedControl( this ) { FixedHeight = 30, FixedWidth = 280, ToolTip = "First person: the weapon's own animation (and arms). Third person: a character holds it." } );
_mode.AddOption( "First Person", "visibility" );
_mode.AddOption( "Third Person", "accessibility_new" );
_mode.SelectedIndex = 1;
_mode.OnSelectedChanged = _ => SetFirstPerson( _mode.SelectedIndex == 0 );
header.AddStretchCell();
header.Add( UiStyle.Icon( this, "file_open", () => ChooseFile(), "Import another weapon (or drop one anywhere on this window)", 30 ) );
_autoSetup = header.Add( UiStyle.Icon( this, "auto_fix_high", AutoSetup, "Auto setup: set the weapon up again from scratch (clears manual edits)", 30 ) );
_bake = header.Add( UiStyle.Primary( "Bake", "inventory_2", () => _ = Controller.BakeAsync(), "Write and compile the weapon model and a ready prefab" ) );
_bake.MinimumWidth = 96;
}
private static readonly (ViewportCamera Mode, string Label, string Icon, string Tip)[] Cameras =
{
(ViewportCamera.Orbit, "Orbit", "3d_rotation", "Orbit freely: left drag rotates, right drag pans, wheel zooms"),
(ViewportCamera.Gameplay, "Gameplay", "videogame_asset", "Over the shoulder, like the game camera"),
(ViewportCamera.Front, "Front", "person", "Straight at the weapon from the front"),
(ViewportCamera.Side, "Side", "switch_left", "The weapon side on, from the right"),
};
private ComboBox _cameraCombo;
private ComboBox _characterCombo;
private SegmentedControl _compare;
private ViewportCamera _thirdPersonCamera = ViewportCamera.Orbit;
private bool _syncingHeader;
private void BuildViewportCard( Widget parent )
{
_viewportCard = parent.Layout.Add( new Card( parent ), 1 );
_viewportCard.Layout.Spacing = 8;
var bar = _viewportCard.Layout.AddRow();
bar.Spacing = 6;
_cameraCombo = bar.Add( UiStyle.Framed( new ComboBox( _viewportCard ) { FixedWidth = 132, ToolTip = "Camera" } ) );
foreach ( var (mode, label, icon, tip) in Cameras )
{
var m = mode;
_cameraCombo.AddItem( label, icon, () =>
{
if ( _syncingHeader || !_viewport.IsValid() )
return;
_thirdPersonCamera = m;
_viewport.SetCamera( m );
}, description: tip, selected: mode == ViewportCamera.Orbit );
}
_characterCombo = bar.Add( UiStyle.Framed( new ComboBox( _viewportCard ) { FixedWidth = 150, ToolTip = "Character the hands are fitted to; the prefab works with any character using the same skeleton" } ) );
FillCharacters( "human" );
_compare = bar.Add( new SegmentedControl( _viewportCard ) { FixedHeight = UiStyle.ControlHeight, FixedWidth = 190, ToolTip = "Original: the character's animation as authored. Corrected: hands on this weapon." } );
_compare.AddOption( "Original", "history" );
_compare.AddOption( "Corrected", "back_hand" );
_compare.SelectedIndex = 1;
_compare.OnSelectedChanged = _ => Controller.ShowOriginal = _compare.SelectedIndex == 0;
bar.AddStretchCell();
bar.Add( UiStyle.Icon( _viewportCard, "layers", ShowLayers, "Show in the preview: arms, contacts, IK targets, attachments, normals" ) );
bar.Add( UiStyle.Icon( _viewportCard, "center_focus_strong", () => _viewport?.FrameWeapon(), "Frame the weapon (F)" ) );
_viewportHost = _viewportCard.Layout.Add( new Widget( _viewportCard ), 1 );
_viewportHost.Layout = Layout.Column();
_viewport = _viewportHost.Layout.Add( new WeaponViewport( _viewportHost, Controller ), 1 );
_viewport.CameraModeChanged += SyncHeader;
_viewportLoader = _viewportHost.Layout.Add( new ProcessingIndicator( _viewportHost ) { Visible = false }, 1 );
}
/// <summary>Preview layers in one dropdown instead of a row of toggle buttons.</summary>
private void ShowLayers()
{
var menu = new Menu( this );
void Layer( string title, string icon, Func<bool> get, Action<bool> set )
{
var option = menu.AddOption( title, icon, () => set( !get() ) );
option.Checkable = true;
option.Checked = get();
}
Layer( "Arms", "accessibility", () => Controller.ShowSkeleton, v => Controller.ShowSkeleton = v );
Layer( "Contacts", "touch_app", () => Controller.ShowContacts, v => Controller.ShowContacts = v );
Layer( "IK targets", "my_location", () => Controller.ShowIkTargets, v => Controller.ShowIkTargets = v );
Layer( "Muzzle and eject", "push_pin", () => Controller.ShowAttachments, v => Controller.ShowAttachments = v );
Layer( "Surface normals", "north_east", () => Controller.ShowNormals, v => Controller.ShowNormals = v );
Layer( "Regions", "select_all", () => Controller.ShowRegions, v => Controller.ShowRegions = v );
menu.OpenAtCursor();
}
/// <summary>First person: the weapon's own animation (and arms); third person: the character.</summary>
public void SetFirstPerson( bool on )
{
if ( !_viewport.IsValid() )
return;
if ( on == _viewport.FirstPerson )
return;
_viewport.SetCamera( on ? ViewportCamera.FirstPerson : _thirdPersonCamera );
SyncHeader();
}
/// <summary>Keeps the header controls on the current mode and camera without re-triggering them.</summary>
private void SyncHeader()
{
if ( !_cameraCombo.IsValid() || !_viewport.IsValid() )
return;
_syncingHeader = true;
try
{
var firstPerson = _viewport.FirstPerson;
if ( _mode.SelectedIndex != (firstPerson ? 0 : 1) )
_mode.SelectedIndex = firstPerson ? 0 : 1;
// Third-person controls only make sense with the character.
Show( _cameraCombo, !firstPerson );
Show( _characterCombo, !firstPerson );
Show( _compare, !firstPerson );
var camera = Array.FindIndex( Cameras, c => c.Mode == _viewport.CameraMode );
if ( camera >= 0 )
{
_thirdPersonCamera = Cameras[camera].Mode;
if ( _cameraCombo.CurrentIndex != camera )
_cameraCombo.CurrentIndex = camera;
}
var current = string.IsNullOrEmpty( Controller.Setup?.Character ) ? "human" : Controller.Setup.Character;
if ( _characterIds.IndexOf( current ) is var character && character < 0 )
{
FillCharacters( current );
character = _characterIds.IndexOf( current );
}
if ( character >= 0 && _characterCombo.CurrentIndex != character )
_characterCombo.CurrentIndex = character;
}
finally
{
_syncingHeader = false;
}
}
// Character ids in the order of the character list (built-ins, then a custom model).
private readonly List<string> _characterIds = new();
/// <summary>The built-in characters, <paramref name="current"/> when it is a custom model, and "Custom model…".</summary>
private void FillCharacters( string current )
{
var wasSyncing = _syncingHeader;
_syncingHeader = true;
try
{
_characterCombo.Clear();
_characterIds.Clear();
var entries = ImporterController.Characters.Select( c => (c.Id, c.Label) ).ToList();
if ( entries.All( e => e.Id != current ) )
entries.Add( (current, CharacterLibrary.Label( current )) );
foreach ( var (id, label) in entries )
{
var value = id;
_characterIds.Add( id );
_characterCombo.AddItem( label, "person", () => { if ( !_syncingHeader ) Controller.SetCharacter( value ); },
description: ImporterController.Characters.Any( c => c.Id == id ) ? null : id, selected: id == current );
}
_characterIds.Add( "" );
_characterCombo.AddItem( "Custom model…", "folder_open", () => { if ( !_syncingHeader ) PickCharacter(); },
description: "Any humanoid using the citizen or human animgraph" );
}
finally
{
_syncingHeader = wasSyncing;
}
}
private void PickCharacter()
{
var picker = AssetPicker.Create( this, AssetType.Model );
picker.Title = "Character model (citizen or human animgraph)";
picker.OnAssetPicked = assets =>
{
if ( assets?.FirstOrDefault() is { } asset )
Controller.SetCharacter( asset.Path );
SyncHeader();
};
picker.Show();
// Until something is picked the list shows the current character.
SyncHeader();
}
public int CurrentStep { get; private set; }
public void SelectStep( int index )
{
index = Math.Clamp( index, 0, _steps.Count - 1 );
CurrentStep = index;
if ( _nav.Selected != index )
_nav.Select( index, notify: false );
for ( var i = 0; i < _steps.Count; i++ )
_steps[i].Visible = i == index;
_steps[index].MarkDirty();
_steps[index].Flush();
if ( index == 2 && Controller.Role == AnimationRole.Idle && Controller.Setup is not null )
Controller.RestartRequested = true;
}
/// <summary>Scrolls the page column so its last card is visible.</summary>
public void ScrollStepToEnd()
{
var last = CurrentPanel.Children.LastOrDefault();
if ( last.IsValid() )
_pageScroll.MakeVisible( last );
}
/// <summary>The page currently shown (for the gate).</summary>
public StepPanel CurrentPanel => _steps[CurrentStep];
public WeaponViewport Viewport => _viewport;
/// <summary>Page dots, for the gate.</summary>
public string PageStatus => string.Join( ", ", Pages.Select( ( p, i ) => $"{p.Title}={_nav.StatusOf( i )?.Hex ?? "-"}" ) );
// ------------------------------------------------------------------ actions
/// <summary>The one path every import takes: drop, file dialog, asset context menu.</summary>
public void ImportFile( string path )
{
if ( !DropPaths.IsWeaponFile( path ) )
{
Controller.SetStatus( $"{System.IO.Path.GetFileName( path )} is not a weapon file (FBX, GLB, glTF, VMDL or .weapon.json).", Theme.Red );
return;
}
_ = Controller.LoadAsync( path );
}
/// <summary>Picks a weapon from the project's asset browser (models and their FBX/glTF sources).</summary>
public AssetPicker ChooseFile()
{
var picker = new Editor.AssetPickers.GenericPicker( this, WeaponAssetTypes(), new AssetPicker.PickerOptions { EnableCloud = false, EnableMounts = false } );
picker.Title = "Import weapon";
picker.OnAssetPicked = assets =>
{
var asset = assets?.FirstOrDefault();
if ( asset is not null )
ImportFile( asset.AbsolutePath );
};
picker.Show();
return picker;
}
/// <summary>Asset types a weapon can be imported from: compiled models and FBX/GLB/glTF sources.</summary>
public static List<AssetType> WeaponAssetTypes()
{
var types = new List<AssetType> { AssetType.Model };
foreach ( var ext in new[] { "fbx", "glb", "gltf" } )
if ( AssetType.Find( ext, false ) is { } t )
types.Add( t );
return types.Distinct().ToList();
}
/// <summary>Picks a weapon file anywhere on disk (outside the project).</summary>
private void ChooseFromDisk()
{
var path = EditorUtility.OpenFileDialog( "Import weapon", "Weapon models (*.fbx *.glb *.gltf *.vmdl);;Weapon setup (*.weapon.json)", null );
if ( !string.IsNullOrEmpty( path ) )
ImportFile( path );
}
private void UseTemplate()
{
var folder = AssetCompiler.AssetsRoot.Length > 0 ? System.IO.Path.Combine( AssetCompiler.AssetsRoot, "weapons" ) : null;
var template = EditorUtility.OpenFileDialog( "Template: a weapon you already set up", "Weapon setup (*.weapon.json)", folder );
if ( string.IsNullOrEmpty( template ) )
return;
var path = EditorUtility.OpenFileDialog( "Weapon to set up like it", "Weapon models (*.fbx *.glb *.gltf *.vmdl)", null );
if ( string.IsNullOrEmpty( path ) )
return;
new TemplateDialog( this, template, parts => _ = Controller.LoadAsync( path, template, parts ) ).Show();
}
private void AutoSetup()
{
var session = Controller.Session;
if ( session is null )
return;
_ = Controller.RunAsync( "Setting up the weapon", ( p, c ) => session.AutoSetupAsync( p, c ) );
}
// ------------------------------------------------------------------ reactions
private void OnSessionReplaced()
{
var hasSession = Controller.Session is not null;
_main.Visible = hasSession;
_drop.Visible = !hasSession && !Controller.Busy;
_firstLoad.Visible = !hasSession && Controller.Busy;
OnStatusChanged();
foreach ( var step in _steps )
step.ForceRebuild();
if ( hasSession )
{
SelectStep( 0 );
SetFirstPerson( false );
}
OnChanged();
}
private void OnChanged()
{
var session = Controller.Session;
if ( session?.Setup is not { } s )
return;
_weaponName.Text = s.Name;
_weaponName.ToolTip = session.SourcePath;
var a = session.Analysis;
var confidence = a?.Type.Confidence ?? 0f;
_typePill.Set( WeaponTypes.Label( s.Type ).ToUpperInvariant(), UiStyle.ConfidenceColor( confidence, s.TypeManual ) );
_typePill.ToolTip = s.TypeManual ? "Type set by you" : $"Detected type, {confidence * 100:0}% sure{(string.IsNullOrEmpty( a?.Type.Reason ) ? "" : ": " + a.Type.Reason)}";
UpdatePageStatus( session );
SyncHeader();
}
/// <summary>A dot beside each page that has something to look at.</summary>
private void UpdatePageStatus( ImportSession session )
{
Color? Worst( Func<string, bool> belongs )
{
var checks = session.Checks.Where( c => belongs( c.Name ) ).ToList();
if ( checks.Any( c => c.Severity == CheckSeverity.Error ) )
return Theme.Red;
if ( checks.Any( c => c.NeedsAttention ) )
return Theme.Yellow;
return null;
}
_nav.SetStatus( 0, Worst( n => n is "Skeleton" or "Root" or "Scale" or "Muzzle" or "Eject" or "Type" ) );
_nav.SetStatus( 1, Worst( n => n.EndsWith( "grip" ) ) );
_nav.SetStatus( 2, Worst( n => n == "Animations" || n.StartsWith( "Hands in" ) ) );
var proposals = session.TextureMatches.Any( m => !m.Linked && !session.Setup.TextureChoices.ContainsKey( m.Key ) && !ImportSession.Accepted( session.Setup, m ) && m.Confidence >= 0.35f );
_nav.SetStatus( 3, proposals ? Theme.Yellow : (Color?)null );
_nav.SetStatus( 4, Worst( n => n == "Compiles" ) );
}
private void OnRoleChanged() => SyncHeader();
private void OnBusyChanged()
{
var busy = Controller.Busy;
var hasSession = Controller.Session is not null;
Show( _firstLoad, busy && !hasSession );
Show( _drop, !busy && !hasSession );
_firstLoad.SetTitle( Controller.BusyTitle ?? "Processing…" );
_firstLoad.SetMessage( Controller.BusyMessage );
_firstLoad.Busy = busy;
// Toggling the preview's native render surface is expensive (it is re-created), so only
// touch visibility when it actually changes; light work (live edits, checks) never does.
Show( _viewportLoader, busy && hasSession );
Show( _viewport, !(busy && hasSession) );
_viewportLoader.SetTitle( Controller.BusyTitle ?? "Processing…" );
_viewportLoader.SetMessage( Controller.BusyMessage );
_viewportLoader.Busy = busy;
if ( _autoSetup.Enabled != (!busy && hasSession) )
_autoSetup.Enabled = !busy && hasSession;
if ( _bake.Enabled != (!busy && hasSession) )
_bake.Enabled = !busy && hasSession;
// No Cancel on the first load after a drop; the loader covers the window.
Show( _cancel, Controller.CanCancel && Controller.Session is not null );
OnStatusChanged();
}
private static void Show( Widget widget, bool visible )
{
if ( widget.Visible != visible )
widget.Visible = visible;
}
private void OnStatusChanged()
{
_status.Text = Controller.StatusText;
_status.ToolTip = Controller.StatusText;
_status.SetStyles( $"color: {(Controller.StatusColor == Theme.Green || Controller.StatusColor == Theme.TextLight ? Theme.Text.Hex : Controller.StatusColor.Hex)};" );
_statusDot.Color = Controller.Busy || Controller.Solving ? Theme.Yellow : Controller.StatusColor;
// No duplicates: while a loader is up it already shows the progress message, and the start
// screen explains itself. The line appears for results (ready, warnings, errors).
var hidden = Controller.Busy || (Controller.Session is null && Controller.StatusColor != Theme.Red);
Show( _statusDot, !hidden );
Show( _status, !hidden );
}
[EditorEvent.Frame]
public void Frame()
{
if ( !this.IsValid() )
return;
try
{
Controller.Tick();
_firstLoad.Tick();
_viewportLoader.Tick();
if ( Controller.Session is not null )
{
_steps[CurrentStep].Flush();
_timeline.Tick();
}
}
catch ( Exception e )
{
Log.Warning( $"[weapon importer] {e}" );
}
}
// ------------------------------------------------------------------ input
protected override void OnKeyPress( KeyEvent e )
{
if ( Controller.Session is null )
{
base.OnKeyPress( e );
return;
}
switch ( e.Key )
{
case KeyCode.Space:
Controller.TogglePlay();
break;
case KeyCode.Left:
Controller.StepFrame( -1 );
break;
case KeyCode.Right:
Controller.StepFrame( 1 );
break;
case KeyCode.F:
_viewport?.FrameWeapon();
break;
case KeyCode.Escape when Controller.Pick != PickTarget.None:
Controller.BeginPick( PickTarget.None );
break;
case KeyCode.Num1:
case KeyCode.Num2:
case KeyCode.Num3:
case KeyCode.Num4:
case KeyCode.Num5:
SelectStep( (int)e.Key - (int)KeyCode.Num1 );
break;
default:
base.OnKeyPress( e );
return;
}
e.Accepted = true;
}
public override void OnDragHover( DragEvent e )
{
if ( DropPaths.From( e.Data ) is not null )
e.Action = DropAction.Link;
}
public override void OnDragDrop( DragEvent e )
{
var path = DropPaths.From( e.Data );
if ( path is null )
{
Controller.SetStatus( "Drop an FBX, GLB, glTF, VMDL or .weapon.json file.", Theme.Red );
return;
}
e.Action = DropAction.Link;
ImportFile( path );
}
protected override void OnPaint()
{
// Paint only this surface; an inherited CSS background hides the segmented controls' selection.
Paint.ClearPen();
Paint.SetBrush( Theme.SurfaceBackground );
Paint.DrawRect( LocalRect );
}
public override void OnDestroyed()
{
Controller.Changed -= OnChanged;
Controller.SessionReplaced -= OnSessionReplaced;
Controller.BusyChanged -= OnBusyChanged;
Controller.StatusChanged -= OnStatusChanged;
Controller.RoleChanged -= OnRoleChanged;
Controller.Dispose();
if ( _instance == this )
_instance = null;
base.OnDestroyed();
}
/// <summary>The page column: about 360 px wide by default, resizable with the splitter.</summary>
private sealed class PagePane : Widget
{
public PagePane( Widget parent ) : base( parent )
{
MinimumWidth = 320;
}
protected override Vector2 SizeHint() => new( 380, 600 );
}
}