Editor/BlenderBridge/BlenderBridgeWindow.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace BlenderBridge
{
/// <summary>
/// Editor window for the Blender Bridge v2.
/// Shows connection status, client indicator, manifest status, materials, and activity log.
/// </summary>
public class BlenderBridgeWindow : Widget
{
private Label _statusLabel;
private Label _portLabel;
private Label _clientLabel;
private Label _manifestLabel;
private Button _toggleButton;
private Widget _logCanvas;
private Widget _materialsCanvas;
private ToggleSwitch _autoStartToggle;
private bool _lastRunningState = false;
private readonly List<(Button btn, BridgeLockFlags flag, string baseLabel)> _lockButtons = new();
private static readonly List<string> _logEntries = new();
private const int MaxLogEntries = 200;
static BlenderBridgeWindow()
{
if ( BlenderBridgeServer.AutoStart )
{
GameTask.RunInThreadAsync( async () =>
{
await GameTask.MainThread();
if ( !BlenderBridgeServer.IsRunning )
BlenderBridgeServer.StartServer();
} );
}
}
[Menu( "Editor", "Blender Bridge/Open Panel" )]
public static void OpenPanel()
{
var win = new BlenderBridgeWindow();
win.Show();
}
public BlenderBridgeWindow() : base( null )
{
WindowTitle = "Blender Bridge";
MinimumSize = new Vector2( 450, 400 );
BuildUI();
_pollTimer = new System.Threading.Timer( _ => _needsDrain = true, null, 500, 500 );
}
private System.Threading.Timer _pollTimer;
private volatile bool _needsDrain = false;
public override void OnDestroyed()
{
_pollTimer?.Dispose();
base.OnDestroyed();
}
protected override void OnPaint()
{
base.OnPaint();
// Cheap state-refresh tied to redraw. Reads tags from the current
// selection — both fast operations — so it's fine to do per-paint
// rather than wiring up a SelectionChanged event.
RefreshLockButtonStates();
if ( !_needsDrain ) return;
_needsDrain = false;
DrainLogQueue();
}
private void DrainLogQueue()
{
if ( !IsValid || _logCanvas == null ) return;
// Update status
if ( BlenderBridgeServer.IsRunning != _lastRunningState )
{
_lastRunningState = BlenderBridgeServer.IsRunning;
if ( _lastRunningState )
{
_statusLabel.Text = "● Running";
_statusLabel.SetStyles( "font-size: 15px; font-weight: bold; color: #4ade80;" );
_toggleButton.Text = "Stop Bridge";
}
else
{
_statusLabel.Text = "● Stopped";
_statusLabel.SetStyles( "font-size: 15px; font-weight: bold; color: #f87171;" );
_toggleButton.Text = "Start Bridge";
}
}
// Update client indicator
if ( _clientLabel != null )
{
if ( BlenderBridgeServer.HasActiveClient )
{
_clientLabel.Text = "1 client connected";
_clientLabel.SetStyles( "font-size: 11px; color: #4ade80;" );
}
else
{
_clientLabel.Text = "No clients";
_clientLabel.SetStyles( "font-size: 11px; color: #9ca3af;" );
}
}
// Update manifest status
if ( _manifestLabel != null )
{
try
{
var scene = BridgeSceneHelper.ResolveScene();
if ( scene != null && BridgePersistence.HasSavedState( scene ) )
{
_manifestLabel.Text = "Bridge state saved";
_manifestLabel.SetStyles( "font-size: 11px; color: #4ade80;" );
}
else
{
_manifestLabel.Text = "No bridge state";
_manifestLabel.SetStyles( "font-size: 11px; color: #9ca3af;" );
}
}
catch
{
_manifestLabel.Text = "No bridge state";
_manifestLabel.SetStyles( "font-size: 11px; color: #9ca3af;" );
}
}
// Drain log queue
int count = 0;
while ( BlenderBridgeServer.LogQueue.TryDequeue( out var msg ) && count < 50 )
{
var text = $"[{DateTime.Now:HH:mm:ss}] {msg}";
_logEntries.Add( text );
AddLogLabel( text );
count++;
if ( _logEntries.Count > MaxLogEntries )
{
_logEntries.RemoveAt( 0 );
var firstChild = _logCanvas?.Children?.FirstOrDefault();
firstChild?.Destroy();
}
}
}
private void BuildUI()
{
var root = Layout.Column();
root.Margin = 8;
root.Spacing = 6;
// ── Status Row ──────────────────────────────────────────────
var statusRow = Layout.Row();
statusRow.Spacing = 16;
var statusCol = Layout.Column();
var statusTitle = new Label( "STATUS" );
statusTitle.SetStyles( "font-size: 10px; color: #888;" );
statusCol.Add( statusTitle );
_statusLabel = new Label( "● Stopped" );
_statusLabel.SetStyles( "font-size: 15px; font-weight: bold; color: #f87171;" );
statusCol.Add( _statusLabel );
statusRow.Add( statusCol );
var portCol = Layout.Column();
var portTitle = new Label( "PORT" );
portTitle.SetStyles( "font-size: 10px; color: #888;" );
portCol.Add( portTitle );
_portLabel = new Label( BlenderBridgeServer.Port.ToString() );
_portLabel.SetStyles( "font-size: 15px; font-weight: bold;" );
portCol.Add( _portLabel );
statusRow.Add( portCol );
statusRow.AddStretchCell();
_toggleButton = new Button( "Start Bridge", "play_arrow" );
_toggleButton.Clicked += ToggleBridge;
statusRow.Add( _toggleButton );
root.Add( statusRow );
// ── Client + Manifest Indicators ────────────────────────────
var infoRow = Layout.Row();
infoRow.Spacing = 24;
var clientCol = Layout.Column();
var clientTitle = new Label( "CLIENT" );
clientTitle.SetStyles( "font-size: 10px; color: #888;" );
clientCol.Add( clientTitle );
_clientLabel = new Label( "No clients" );
_clientLabel.SetStyles( "font-size: 11px; color: #9ca3af;" );
clientCol.Add( _clientLabel );
infoRow.Add( clientCol );
var manifestCol = Layout.Column();
var manifestTitle = new Label( "PERSISTENCE" );
manifestTitle.SetStyles( "font-size: 10px; color: #888;" );
manifestCol.Add( manifestTitle );
_manifestLabel = new Label( "No bridge state" );
_manifestLabel.SetStyles( "font-size: 11px; color: #9ca3af;" );
manifestCol.Add( _manifestLabel );
infoRow.Add( manifestCol );
root.Add( infoRow );
// ── Auto-start ──────────────────────────────────────────────
var autoRow = Layout.Row();
autoRow.Spacing = 8;
_autoStartToggle = new ToggleSwitch( "Auto-start Bridge on editor load" );
_autoStartToggle.Value = BlenderBridgeServer.AutoStart;
_autoStartToggle.MouseClick += () =>
{
BlenderBridgeServer.AutoStart = _autoStartToggle.Value;
};
autoRow.Add( _autoStartToggle );
root.Add( autoRow );
root.AddSeparator();
// ── Blender Addon Download ──────────────────────────────────
var addonBox = Layout.Column();
addonBox.Spacing = 4;
var addonTitle = new Label( "Blender Addon" );
addonTitle.SetStyles( "font-weight: bold; font-size: 13px;" );
addonBox.Add( addonTitle );
var addonDesc = new Label( "Install the companion Blender addon (v2.0+) to connect." );
addonDesc.SetStyles( "font-size: 11px; color: #9ca3af;" );
addonDesc.WordWrap = true;
addonBox.Add( addonDesc );
var addonUrl = "https://github.com/SanicTehHedgehog/blender-sbox-bridge/releases";
var addonLink = new Button( "Download Blender Addon", "open_in_new" );
addonLink.Clicked += () =>
{
System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo( addonUrl ) { UseShellExecute = true } );
};
addonBox.Add( addonLink );
root.Add( addonBox );
root.AddSeparator();
// ── Sync Controls ───────────────────────────────────────────
var syncBox = Layout.Column();
syncBox.Spacing = 4;
var syncTitle = new Label( "Sync Controls" );
syncTitle.SetStyles( "font-weight: bold; font-size: 13px;" );
syncBox.Add( syncTitle );
var syncDesc = new Label( "Send or receive mesh data for selected scene objects." );
syncDesc.SetStyles( "font-size: 11px; color: #9ca3af;" );
syncDesc.WordWrap = true;
syncBox.Add( syncDesc );
var syncBtnRow = Layout.Row();
syncBtnRow.Spacing = 4;
var sendToBlenderBtn = new Button( "Send to Blender", "upload" );
sendToBlenderBtn.ToolTip = "Push selected objects to Blender (adopts native MeshComponents if needed)";
sendToBlenderBtn.Clicked += () =>
{
if ( !BlenderBridgeServer.IsRunning ) return;
var scene = BridgeSceneHelper.ResolveScene();
if ( scene == null ) return;
// Find selected objects in the editor
var session = SceneEditorSession.Active;
if ( session == null ) return;
int count = 0;
foreach ( var sel in session.Selection )
{
if ( sel is not GameObject go ) continue;
// Find existing bridge tag or adopt native MeshComponents
var bridgeTag = go.Tags.TryGetAll().FirstOrDefault( t => t.StartsWith( "bridge_" ) && t != "bridge_group" );
string bridgeId;
if ( bridgeTag != null )
{
bridgeId = bridgeTag.Substring( 7 );
}
else
{
// Adopt native MeshComponent or Terrain if present.
var meshComp = go.Components.Get<MeshComponent>();
var terrain = go.Components.Get<Terrain>();
bool hasMesh = meshComp?.Mesh != null;
bool hasTerrain = terrain?.Storage != null;
if ( !hasMesh && !hasTerrain ) continue;
bridgeId = "b_" + Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );
go.Tags.Add( $"bridge_{bridgeId}" );
var kind = hasTerrain ? "terrain" : "mesh";
BlenderBridgeServer.LogInfo( $"Adopted '{go.Name}' as {bridgeId} ({kind})" );
}
// Build and broadcast as an object_created message
var message = BlenderBridgeDispatcher.BuildObjectCreatedMessage( bridgeId, go );
if ( message != null )
{
BlenderBridgeServer.BroadcastWithSeq( message );
count++;
}
}
if ( count > 0 )
BlenderBridgeServer.LogInfo( $"Sent {count} object(s) to Blender" );
else
BlenderBridgeServer.LogInfo( "No mesh objects selected" );
};
syncBtnRow.Add( sendToBlenderBtn );
var requestFromBlenderBtn = new Button( "Request from Blender", "download" );
requestFromBlenderBtn.ToolTip = "Ask Blender to re-send mesh data for all bridge objects";
requestFromBlenderBtn.Clicked += () =>
{
if ( !BlenderBridgeServer.IsRunning ) return;
// Trigger a full sync — Blender will send all its objects
BlenderBridgeServer.BroadcastWithSeq( new { type = "sync_response", objects = new object[0] } );
BlenderBridgeServer.LogInfo( "Requested resync from Blender" );
};
syncBtnRow.Add( requestFromBlenderBtn );
AddLockButton( syncBtnRow, "Lock In", "lock", BridgeLockFlags.Inbound,
"Toggle: block all Blender → s&box updates for the selected object(s)." );
AddLockButton( syncBtnRow, "Lock Out", "lock_outline", BridgeLockFlags.Outbound,
"Toggle: stop pushing s&box-side changes for the selected object(s) to Blender." );
AddLockButton( syncBtnRow, "Lock Mat", "palette", BridgeLockFlags.Materials,
"Toggle: allow geometry/transform updates from Blender, but never overwrite materials. Use for objects with custom s&box vmats (water shaders, etc)." );
root.Add( syncBox );
root.Add( syncBtnRow );
root.AddSeparator();
// ── Materials Section ───────────────────────────────────────
var matHeader = Layout.Row();
var matTitle = new Label( "Generated Materials" );
matTitle.SetStyles( "font-weight: bold; font-size: 13px;" );
matHeader.Add( matTitle );
matHeader.AddStretchCell();
var openFolderBtn = new Button( "", "folder_open" );
openFolderBtn.ToolTip = "Open materials folder";
openFolderBtn.FixedWidth = 26;
openFolderBtn.FixedHeight = 26;
openFolderBtn.Clicked += OpenMaterialsFolder;
matHeader.Add( openFolderBtn );
var refreshBtn = new Button( "", "refresh" );
refreshBtn.ToolTip = "Refresh list";
refreshBtn.FixedWidth = 26;
refreshBtn.FixedHeight = 26;
refreshBtn.Clicked += RefreshMaterialsList;
matHeader.Add( refreshBtn );
root.Add( matHeader );
var matScroll = new ScrollArea( null );
matScroll.MaximumHeight = 150;
_materialsCanvas = new Widget();
_materialsCanvas.Layout = Layout.Column();
matScroll.Canvas = _materialsCanvas;
root.Add( matScroll );
RefreshMaterialsList();
root.AddSeparator();
// ── Log Header ──────────────────────────────────────────────
var logHeader = Layout.Row();
var logTitle = new Label( "Activity Log" );
logTitle.SetStyles( "font-weight: bold; font-size: 13px;" );
logHeader.Add( logTitle );
logHeader.AddStretchCell();
var clearBtn = new Button( "", "delete_sweep" );
clearBtn.ToolTip = "Clear Log";
clearBtn.FixedWidth = 26;
clearBtn.FixedHeight = 26;
clearBtn.Clicked += () => { _logEntries.Clear(); _logCanvas.DestroyChildren(); };
logHeader.Add( clearBtn );
root.Add( logHeader );
// ── Log Area ────────────────────────────────────────────────
var scroll = new ScrollArea( null );
scroll.MinimumHeight = 250;
_logCanvas = new Widget();
_logCanvas.Layout = Layout.Column();
scroll.Canvas = _logCanvas;
foreach ( var entry in _logEntries )
AddLogLabel( entry );
root.Add( scroll, 1 );
Layout = root;
}
private void AddLogLabel( string text )
{
var lbl = new Label( text );
lbl.WordWrap = true;
string color = "#e5e7eb";
string weight = "normal";
if ( text.Contains( "[ERROR]" ) )
{
color = "#f87171";
weight = "bold";
}
else if ( text.Contains( "Created" ) || text.Contains( "Sync" ) || text.Contains( "Started" ) || text.Contains( "Restored" ) )
{
color = "#4ade80";
}
else if ( text.Contains( "Deleted" ) || text.Contains( "Stopped" ) )
{
color = "#9ca3af";
}
else if ( text.Contains( "mesh" ) || text.Contains( "Model" ) || text.Contains( "Chunked" ) )
{
color = "#60a5fa";
}
else if ( text.Contains( "light" ) || text.Contains( "Light" ) )
{
color = "#fbbf24";
}
lbl.SetStyles( $"font-family: monospace; font-size: 11px; padding: 2px; color: {color}; font-weight: {weight};" );
_logCanvas.Layout.Add( lbl );
}
private string FindBridgeMaterialsDir()
{
try
{
var docsDir = System.IO.Path.Combine(
System.Environment.GetFolderPath( System.Environment.SpecialFolder.MyDocuments ),
"s&box projects" );
if ( System.IO.Directory.Exists( docsDir ) )
{
foreach ( var projDir in System.IO.Directory.GetDirectories( docsDir ) )
{
var candidate = System.IO.Path.Combine( projDir, "Assets", "materials", "blender_bridge" );
if ( System.IO.Directory.Exists( candidate ) )
return candidate;
}
}
}
catch { }
return null;
}
private void RefreshMaterialsList()
{
if ( _materialsCanvas == null ) return;
_materialsCanvas.DestroyChildren();
var dir = FindBridgeMaterialsDir();
if ( dir == null || !System.IO.Directory.Exists( dir ) )
{
var lbl = new Label( "No materials generated yet" );
lbl.SetStyles( "font-size: 11px; color: #888; padding: 4px;" );
_materialsCanvas.Layout.Add( lbl );
return;
}
var vmats = System.IO.Directory.GetFiles( dir, "*.vmat" );
if ( vmats.Length == 0 )
{
var lbl = new Label( "No materials generated yet" );
lbl.SetStyles( "font-size: 11px; color: #888; padding: 4px;" );
_materialsCanvas.Layout.Add( lbl );
return;
}
foreach ( var vmat in vmats.OrderBy( f => f ) )
{
var fileName = System.IO.Path.GetFileNameWithoutExtension( vmat );
var filePath = vmat;
var row = Layout.Row();
row.Spacing = 4;
var icon = new Label( " " );
icon.SetStyles( "color: #60a5fa; font-size: 11px;" );
row.Add( icon );
var nameLbl = new Label( fileName );
nameLbl.SetStyles( "font-family: monospace; font-size: 11px; color: #e5e7eb;" );
row.Add( nameLbl );
row.AddStretchCell();
try
{
var info = new System.IO.FileInfo( filePath );
var sizeLbl = new Label( $"{info.Length / 1024f:F1}kb" );
sizeLbl.SetStyles( "font-size: 10px; color: #666; padding-right: 4px;" );
row.Add( sizeLbl );
}
catch { }
var deleteBtn = new Button( "", "delete" );
deleteBtn.ToolTip = $"Delete {fileName}";
deleteBtn.FixedWidth = 22;
deleteBtn.FixedHeight = 22;
deleteBtn.Clicked += () =>
{
try
{
foreach ( var f in System.IO.Directory.GetFiles( dir, $"{fileName}*" ) )
System.IO.File.Delete( f );
}
catch { }
RefreshMaterialsList();
};
row.Add( deleteBtn );
var container = new Widget();
container.Layout = row;
container.SetStyles( "padding: 1px 0;" );
_materialsCanvas.Layout.Add( container );
}
// Delete All button
var deleteAllRow = Layout.Row();
deleteAllRow.AddStretchCell();
var deleteAllBtn = new Button( "Delete All", "delete_sweep" );
deleteAllBtn.ToolTip = "Delete all generated bridge materials";
deleteAllBtn.Clicked += () =>
{
try
{
foreach ( var f in System.IO.Directory.GetFiles( dir ) )
System.IO.File.Delete( f );
}
catch { }
RefreshMaterialsList();
};
deleteAllRow.Add( deleteAllBtn );
var allContainer = new Widget();
allContainer.Layout = deleteAllRow;
_materialsCanvas.Layout.Add( allContainer );
}
private void OpenMaterialsFolder()
{
var dir = FindBridgeMaterialsDir();
if ( dir != null && System.IO.Directory.Exists( dir ) )
System.Diagnostics.Process.Start( "explorer", $"\"{dir}\"" );
}
private void AddLockButton( Layout row, string label, string icon, BridgeLockFlags flag, string tooltip )
{
var btn = new Button( label, icon );
btn.ToolTip = tooltip;
btn.Clicked += () =>
{
var session = SceneEditorSession.Active;
if ( session == null ) return;
int set = 0, cleared = 0;
foreach ( var sel in session.Selection )
{
if ( sel is not GameObject go ) continue;
var next = BridgeLockPolicy.ToggleExplicit( go, flag );
if ( next.HasFlag( flag ) ) set++; else cleared++;
}
if ( set > 0 ) BlenderBridgeServer.LogInfo( $"Set {flag} on {set} object(s)" );
if ( cleared > 0 ) BlenderBridgeServer.LogInfo( $"Cleared {flag} on {cleared} object(s)" );
if ( set == 0 && cleared == 0 ) BlenderBridgeServer.LogInfo( "No objects selected" );
RefreshLockButtonStates();
};
row.Add( btn );
_lockButtons.Add( (btn, flag, label) );
}
/// <summary>
/// Update each lock button's label to reflect whether the flag is set on
/// the current selection. Three states: all-on -> "Label [ON]",
/// none-on -> "Label", mixed -> "Label [~]". Tooltip stays static.
///
/// Called on every click and from the main OnPaint above, so selection
/// changes surface the right state without explicit event subscription.
/// </summary>
private void RefreshLockButtonStates()
{
var session = SceneEditorSession.Active;
var selected = session?.Selection?.OfType<GameObject>().ToList();
foreach ( var (btn, flag, baseLabel) in _lockButtons )
{
if ( selected == null || selected.Count == 0 )
{
btn.Text = baseLabel;
continue;
}
int onCount = selected.Count( g => BridgeLockPolicy.GetFlags( g ).HasFlag( flag ) );
if ( onCount == selected.Count )
btn.Text = baseLabel + " [ON]";
else if ( onCount == 0 )
btn.Text = baseLabel;
else
btn.Text = baseLabel + " [~]";
}
}
private void ToggleBridge()
{
if ( BlenderBridgeServer.IsRunning )
BlenderBridgeServer.StopServer();
else
BlenderBridgeServer.StartServer();
}
}
}