Editor/UI/FabBridgeWidget.cs
using System;
namespace FabBridge.UI;
/// <summary>
/// Main editor widget for FabBridge - provides UI for configuration and status
/// </summary>
[Dock( "Editor", "Fab Bridge", "download" )]
public class FabBridgeWidget : Widget
{
private static FabBridgeWidget _instance;
public static FabBridgeWidget Instance => _instance;
private FabBridgeServer _server;
private FabImportHandler _importHandler;
// UI Elements
private Label _statusLabel;
private Button _startButton;
private Button _stopButton;
private LineEdit _portEdit;
private ListView _importHistoryList;
private LineEdit _importFolderEdit;
private Checkbox _createMaterialsCheck;
private Checkbox _convertModelsCheck;
private Checkbox _generateCollisionsCheck;
private Checkbox _detectLodsCheck;
// Current port value
private int _currentPort = FabBridgeServer.DefaultPort;
// Import history
private List<FabImportHandler.ImportResult> _importHistory = new();
public FabBridgeWidget( Widget parent ) : base( parent )
{
Log.Info( "FabBridge: Widget constructor called" );
_instance = this;
// Initialize server and handler
Log.Info( "FabBridge: Creating server and import handler" );
_server = new FabBridgeServer();
_importHandler = new FabImportHandler();
// Wire up events
_server.OnAssetReceived += OnAssetReceived;
_server.OnStatusChanged += OnServerStatusChanged;
_server.OnClientConnected += OnClientConnected;
_server.OnError += OnServerError;
_importHandler.OnImportStarted += OnImportStarted;
_importHandler.OnImportCompleted += OnImportCompleted;
_importHandler.OnProgressUpdate += OnProgressUpdate;
// Build the UI
Log.Info( "FabBridge: Building UI layout" );
BuildLayout();
// Auto-start server
Log.Info( $"FabBridge: Auto-starting server on port {_currentPort}" );
_server.Start( _currentPort );
Log.Info( "FabBridge: Widget initialization complete" );
}
private void BuildLayout()
{
WindowTitle = "Fab Bridge";
MinimumSize = new Vector2( 300, 400 );
Layout = Layout.Column();
Layout.Spacing = 8;
Layout.Margin = 16;
// Header
var header = Layout.Add( new Label( "Fab Bridge for s&box" ) );
header.SetStyles( "font-size: 16px; font-weight: bold;" );
// Licensing notice
var notice = Layout.Add( new Label( "Note: Fab assets require a subscription for non-Unreal use." ) );
notice.SetStyles( "font-size: 11px; color: #888;" );
notice.WordWrap = true;
Layout.AddSpacingCell( 8 );
// Server section
var serverGroup = new Widget( this );
serverGroup.Layout = Layout.Column();
serverGroup.Layout.Spacing = 4;
var serverLabel = serverGroup.Layout.Add( new Label( "Server Settings" ) );
serverLabel.SetStyles( "font-weight: bold;" );
// Port row
var portRow = new Widget( serverGroup );
portRow.Layout = Layout.Row();
portRow.Layout.Spacing = 8;
portRow.Layout.Add( new Label( "Port:" ) );
_portEdit = portRow.Layout.Add( new LineEdit( portRow ) );
_portEdit.Text = FabBridgeServer.DefaultPort.ToString();
_portEdit.MaximumWidth = 80;
_portEdit.TextEdited += OnPortChanged;
portRow.Layout.AddStretchCell();
serverGroup.Layout.Add( portRow );
// Button row
var buttonRow = new Widget( serverGroup );
buttonRow.Layout = Layout.Row();
buttonRow.Layout.Spacing = 8;
_startButton = buttonRow.Layout.Add( new Button( "Start Server", buttonRow ) );
_startButton.Clicked = OnStartClicked;
_stopButton = buttonRow.Layout.Add( new Button( "Stop Server", buttonRow ) );
_stopButton.Clicked = OnStopClicked;
_stopButton.Enabled = false;
buttonRow.Layout.AddStretchCell();
serverGroup.Layout.Add( buttonRow );
// Status
var statusRow = new Widget( serverGroup );
statusRow.Layout = Layout.Row();
statusRow.Layout.Spacing = 8;
statusRow.Layout.Add( new Label( "Status:" ) );
_statusLabel = statusRow.Layout.Add( new Label( "Stopped" ) );
_statusLabel.SetStyles( "color: #f80;" );
statusRow.Layout.AddStretchCell();
serverGroup.Layout.Add( statusRow );
Layout.Add( serverGroup );
Layout.AddSpacingCell( 16 );
// Import settings section
var importGroup = new Widget( this );
importGroup.Layout = Layout.Column();
importGroup.Layout.Spacing = 4;
var importLabel = importGroup.Layout.Add( new Label( "Import Settings" ) );
importLabel.SetStyles( "font-weight: bold;" );
// Import folder row
var folderRow = new Widget( importGroup );
folderRow.Layout = Layout.Row();
folderRow.Layout.Spacing = 8;
folderRow.Layout.Add( new Label( "Folder:" ) );
_importFolderEdit = folderRow.Layout.Add( new LineEdit( folderRow ) );
_importFolderEdit.Text = _importHandler.ImportFolder;
_importFolderEdit.TextEdited += ( text ) => _importHandler.ImportFolder = text;
_importFolderEdit.PlaceholderText = "fab_imports";
importGroup.Layout.Add( folderRow );
// Checkboxes
_createMaterialsCheck = importGroup.Layout.Add( new Checkbox( "Create materials automatically" ) );
_createMaterialsCheck.Value = _importHandler.CreateMaterials;
_createMaterialsCheck.StateChanged += ( state ) => _importHandler.CreateMaterials = state == CheckState.On;
_convertModelsCheck = importGroup.Layout.Add( new Checkbox( "Convert models (FBX to VMDL)" ) );
_convertModelsCheck.Value = _importHandler.ConvertModels;
_convertModelsCheck.StateChanged += ( state ) => _importHandler.ConvertModels = state == CheckState.On;
_generateCollisionsCheck = importGroup.Layout.Add( new Checkbox( "Generate collisions (PhysicsHullFromRender)" ) );
_generateCollisionsCheck.Value = _importHandler.GenerateCollisions;
_generateCollisionsCheck.StateChanged += ( state ) => _importHandler.GenerateCollisions = state == CheckState.On;
_detectLodsCheck = importGroup.Layout.Add( new Checkbox( "Detect LODs (scan sibling quality-tier folders)" ) );
_detectLodsCheck.Value = _importHandler.DetectLods;
_detectLodsCheck.StateChanged += ( state ) => _importHandler.DetectLods = state == CheckState.On;
Layout.Add( importGroup );
Layout.AddSpacingCell( 16 );
// LOD switch thresholds section. LOD0 is fixed at 0; the rest are configurable.
var lodGroup = new Widget( this );
lodGroup.Layout = Layout.Column();
lodGroup.Layout.Spacing = 4;
var lodLabel = lodGroup.Layout.Add( new Label( "LOD Switch Thresholds" ) );
lodLabel.SetStyles( "font-weight: bold;" );
var lodHint = lodGroup.Layout.Add( new Label( "Distance at which each lower LOD takes over. LOD0 is always 0." ) );
lodHint.SetStyles( "font-size: 11px; color: #888;" );
lodHint.WordWrap = true;
AddLodThresholdRow( lodGroup, 0, readOnly: true );
AddLodThresholdRow( lodGroup, 1 );
AddLodThresholdRow( lodGroup, 2 );
AddLodThresholdRow( lodGroup, 3 );
Layout.Add( lodGroup );
Layout.AddSpacingCell( 16 );
// Import history section
var historyLabel = Layout.Add( new Label( "Import History" ) );
historyLabel.SetStyles( "font-weight: bold;" );
_importHistoryList = Layout.Add( new ListView( this ) );
_importHistoryList.MinimumHeight = 150;
_importHistoryList.ItemPaint = PaintHistoryItem;
_importHistoryList.ItemSize = new Vector2( 0, 32 );
// Clear history button
var clearButton = Layout.Add( new Button( "Clear History", this ) );
clearButton.Clicked = () =>
{
_importHistory.Clear();
UpdateHistoryList();
};
Layout.AddStretchCell();
// Instructions
var instructions = Layout.Add( new Label( "Configure Fab to use Custom (socket port) export with port " + FabBridgeServer.DefaultPort ) );
instructions.SetStyles( "font-size: 11px; color: #666;" );
instructions.WordWrap = true;
}
private void AddLodThresholdRow( Widget parent, int lod, bool readOnly = false )
{
var row = new Widget( parent );
row.Layout = Layout.Row();
row.Layout.Spacing = 8;
row.Layout.Add( new Label( $"LOD{lod}:" ) );
var edit = row.Layout.Add( new LineEdit( row ) );
edit.Text = FabLodSettings.GetThreshold( lod ).ToString( "0.##", System.Globalization.CultureInfo.InvariantCulture );
edit.MaximumWidth = 80;
edit.ReadOnly = readOnly;
if ( !readOnly )
{
edit.TextEdited += text =>
{
if ( float.TryParse( text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v ) && v >= 0f )
FabLodSettings.SetThreshold( lod, v );
};
}
row.Layout.AddStretchCell();
parent.Layout.Add( row );
}
private void OnPortChanged( string text )
{
if ( int.TryParse( text, out var port ) && port >= 1024 && port <= 65535 )
{
_currentPort = port;
}
}
private void OnStartClicked()
{
if ( _server.Start( _currentPort ) )
{
_startButton.Enabled = false;
_stopButton.Enabled = true;
_portEdit.ReadOnly = true;
}
}
private void OnStopClicked()
{
_server.Stop();
_startButton.Enabled = true;
_stopButton.Enabled = false;
_portEdit.ReadOnly = false;
}
private void OnServerStatusChanged( string status )
{
_statusLabel.Text = status;
if ( _server.IsRunning )
{
_statusLabel.SetStyles( "color: #0f0;" );
_startButton.Enabled = false;
_stopButton.Enabled = true;
}
else
{
_statusLabel.SetStyles( "color: #f80;" );
_startButton.Enabled = true;
_stopButton.Enabled = false;
}
}
private void OnClientConnected()
{
_statusLabel.Text = "Client connected";
_statusLabel.SetStyles( "color: #0ff;" );
}
private void OnServerError( string error )
{
_statusLabel.Text = $"Error: {error}";
_statusLabel.SetStyles( "color: #f00;" );
}
private async void OnAssetReceived( FabExportData exportData )
{
Log.Info( $"FabBridge: Received {exportData.Assets.Count} asset(s) for import" );
// Defer past the current frame update. MainThread.Queue (used by the server
// to marshal here) fires inside MainThread.RunQueues during the editor's
// per-frame update; calling AssetSystem.RegisterFile from there triggers a
// reentrant CResourceSystem::BlockUntilManifestLoaded() and errors out.
await Task.Delay( 50 );
await MainThread.Wait();
// Import all assets
var results = await _importHandler.ImportAllAsync( exportData );
foreach ( var result in results )
{
_importHistory.Insert( 0, result );
}
// Keep history limited
while ( _importHistory.Count > 50 )
{
_importHistory.RemoveAt( _importHistory.Count - 1 );
}
UpdateHistoryList();
// Refresh asset browser
MainAssetBrowser.Instance?.Local.UpdateAssetList();
}
private void OnImportStarted( FabAsset asset )
{
_statusLabel.Text = $"Importing: {asset.Name ?? asset.Id}";
_statusLabel.SetStyles( "color: #ff0;" );
}
private void OnImportCompleted( FabImportHandler.ImportResult result )
{
if ( result.Success )
{
_statusLabel.Text = $"Imported: {result.AssetName}";
_statusLabel.SetStyles( "color: #0f0;" );
}
else
{
_statusLabel.Text = $"Failed: {result.AssetName}";
_statusLabel.SetStyles( "color: #f00;" );
}
}
private void OnProgressUpdate( string message )
{
_statusLabel.Text = message;
}
private void UpdateHistoryList()
{
_importHistoryList.SetItems( _importHistory );
}
private void PaintHistoryItem( VirtualWidget item )
{
if ( item.Object is not FabImportHandler.ImportResult result )
return;
var rect = item.Rect;
// Background for selected
if ( item.Selected )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Blue );
Paint.DrawRect( rect );
}
// Status icon
var iconRect = rect.Shrink( 4 );
iconRect.Width = 16;
Paint.SetPen( result.Success ? Theme.Green : Theme.Red );
Paint.DrawIcon( iconRect, result.Success ? "check_circle" : "error", 14 );
// Asset name
var textRect = rect.Shrink( 4 );
textRect.Left += 24;
Paint.SetPen( Theme.Text );
Paint.SetDefaultFont();
Paint.DrawText( textRect, result.AssetName, TextFlag.LeftCenter );
// Time
var timeRect = rect.Shrink( 4 );
timeRect.Left = timeRect.Right - 80;
Paint.SetPen( Theme.Text.WithAlpha( 0.5f ) );
Paint.SetDefaultFont( 9 );
Paint.DrawText( timeRect, result.ImportTime.ToString( "HH:mm:ss" ), TextFlag.RightCenter );
}
public override void OnDestroyed()
{
_server?.Dispose();
base.OnDestroyed();
}
}