Editor dock UI for the Blender Bridge v2. It constructs a docked window in the editor showing bridge status, port, client indicator, manifest persistence, material previews and an activity log, and exposes controls to start/stop the bridge, send/request objects, manage generated materials, and toggle per-object lock flags.
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace BlenderBridge
{
/// <summary>
/// Editor dock for the Blender Bridge v2.
/// Shows connection status, client indicator, manifest status, materials, and activity log.
/// Summon from the editor's Window/Layout menu — the dock manager creates and parents the instance.
/// </summary>
[Dock( "Editor", "Blender Bridge", "sync_alt" )]
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 ScrollArea _logScroll;
private bool _logCollapsed = false;
private static readonly List<string> _logEntries = new();
private const int MaxLogEntries = 200;
// Auto-start lives in BlenderBridgeServer.AutoStartOnEditorLoad — a static
// constructor here only runs if the dock is in the saved layout.
public BlenderBridgeWindow( Widget parent ) : base( parent )
{
MinimumSize = new Vector2( 450, 400 );
BuildUI();
_pollTimer = new System.Threading.Timer( _ => RequestDrain(), null, 500, 500 );
}
private System.Threading.Timer _pollTimer;
private volatile bool _needsDrain = false;
/// <summary>
/// Timer tick, on a threadpool thread. Setting the flag alone is not
/// enough — _needsDrain is only consumed in OnPaint, and an unattended
/// dock never repaints on its own, so status would sit stale (server
/// stopped but "● Running", Blender gone but "1 client connected")
/// until a mouse-over forced a paint. Hop to the main thread and ask
/// for one explicitly.
/// </summary>
private void RequestDrain()
{
_needsDrain = true;
GameTask.RunInThreadAsync( async () =>
{
await GameTask.MainThread();
if ( IsValid )
Update();
} );
}
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. Cap check must come BEFORE TryDequeue: the other
// order dequeues a 51st message and then discards it, silently losing
// one line per over-full drain.
int count = 0;
while ( count < 50 && BlenderBridgeServer.LogQueue.TryDequeue( out var msg ) )
{
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 );
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 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.
// Identity tags only — lock tags (bridge_lock_*) share the
// prefix, and picking one up would broadcast the object
// under a phantom id shared by every locked object.
var bridgeTag = go.Tags.TryGetAll().FirstOrDefault( BlenderBridgeDispatcher.IsBridgeIdentityTag );
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 collapseBtn = new Button( "", "unfold_less" );
collapseBtn.ToolTip = "Hide / show log";
collapseBtn.FixedWidth = 26;
collapseBtn.FixedHeight = 26;
collapseBtn.Clicked += () =>
{
_logCollapsed = !_logCollapsed;
if ( _logScroll == null ) return;
if ( _logCollapsed )
{
_logScroll.MinimumHeight = 0;
_logScroll.MaximumHeight = 0;
}
else
{
_logScroll.MinimumHeight = 250;
_logScroll.MaximumHeight = int.MaxValue;
}
};
logHeader.Add( collapseBtn );
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 ────────────────────────────────────────────────
_logScroll = new ScrollArea( null );
_logScroll.MinimumHeight = 250;
_logCanvas = new Widget();
_logCanvas.Layout = Layout.Column();
_logScroll.Canvas = _logCanvas;
foreach ( var entry in _logEntries )
AddLogLabel( entry );
root.Add( _logScroll, 1 );
root.AddSeparator();
// ── Footer: settings + setup links ──────────────────────────
var footerRow = Layout.Row();
footerRow.Spacing = 8;
_autoStartToggle = new ToggleSwitch( "Auto-start on editor load" );
_autoStartToggle.Value = BlenderBridgeServer.AutoStart;
_autoStartToggle.MouseClick += () =>
{
BlenderBridgeServer.AutoStart = _autoStartToggle.Value;
};
footerRow.Add( _autoStartToggle );
footerRow.AddStretchCell();
var addonUrl = "https://github.com/SanicTehHedgehog/blender-sbox-bridge/releases";
var addonLink = new Button( "Get Blender Addon", "open_in_new" );
addonLink.ToolTip = "Open the Blender addon download page (companion v2.0+ required to connect).";
addonLink.Clicked += () =>
{
System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo( addonUrl ) { UseShellExecute = true } );
};
footerRow.Add( addonLink );
root.Add( footerRow );
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
{
// Must resolve to the SAME project the dispatcher writes materials
// into (Project.Current). Scanning Documents\s&box projects\* here
// binds the panel — and its delete buttons — to whichever other
// project happens to sort first.
var assetsDir = BlenderBridgeDispatcher.GetProjectAssetsDir();
if ( assetsDir == null ) return null;
var candidate = System.IO.Path.Combine( assetsDir, "materials", "blender_bridge" );
if ( System.IO.Directory.Exists( candidate ) )
return candidate;
}
catch { }
return null;
}
private void RefreshMaterialsList()
{
if ( _materialsCanvas == null ) return;
_materialsCanvas.DestroyChildren();
MaterialThumb.PruneCache();
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;
row.Add( new MaterialThumb( dir, fileName ) );
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
{
DeleteMaterialFiles( dir, fileName );
}
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 );
}
/// <summary>
/// Delete exactly one generated material's files. A bare "{name}*" glob
/// takes prefix-collided neighbours with it — deleting "metal" would also
/// remove metal_rough.vmat and metal_001.vmat, and those prefixes are
/// normal Blender naming ("Metal" / "Metal Rough" / "Metal.001"). So:
/// the vmat by exact name, textures only via the generator's four known
/// suffixes, and never a .vmat/.vmat_c that merely shares a texture's
/// name ("metal"'s rough map metal_rough.png vs material metal_rough).
/// </summary>
private static void DeleteMaterialFiles( string dir, string name )
{
foreach ( var ext in new[] { ".vmat", ".vmat_c" } )
{
var path = System.IO.Path.Combine( dir, name + ext );
if ( System.IO.File.Exists( path ) )
System.IO.File.Delete( path );
}
foreach ( var suffix in new[] { "color", "rough", "metal", "normal" } )
{
foreach ( var f in System.IO.Directory.GetFiles( dir, $"{name}_{suffix}.*" ) )
{
if ( f.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase ) ||
f.EndsWith( ".vmat_c", StringComparison.OrdinalIgnoreCase ) )
continue;
System.IO.File.Delete( f );
}
}
}
/// <summary>
/// Square preview for a generated material row: the copied color
/// texture if one exists next to the .vmat, else a flat chip in the
/// vmat's tint color.
/// </summary>
private class MaterialThumb : Widget
{
// Pixmap has no Dispose, so loading the full-resolution source per
// row on every list refresh abandons the previous set to
// finalization. Cache one Pixmap per texture path instead; write
// time invalidates when Blender regenerates the texture. Only ever
// touched from the UI thread.
private static readonly Dictionary<string, (DateTime WriteTime, Pixmap Pix)> _thumbCache = new();
/// <summary>Drop cache entries whose texture file was deleted.</summary>
internal static void PruneCache()
{
foreach ( var key in _thumbCache.Keys.Where( k => !System.IO.File.Exists( k ) ).ToList() )
_thumbCache.Remove( key );
}
private readonly Pixmap _pix;
private readonly Color _tint = Color.Transparent;
public MaterialThumb( string dir, string matName )
{
FixedWidth = 26;
FixedHeight = 26;
ToolTip = matName;
try
{
var tex = System.IO.Directory
.GetFiles( dir, matName + "_color.*" )
.FirstOrDefault( f => !f.EndsWith( ".meta" ) );
if ( tex != null )
{
var writeTime = System.IO.File.GetLastWriteTimeUtc( tex );
if ( _thumbCache.TryGetValue( tex, out var cached ) && cached.WriteTime == writeTime )
{
_pix = cached.Pix;
return;
}
_pix = Pixmap.FromFile( tex );
_thumbCache[tex] = (writeTime, _pix);
return;
}
// No color texture — parse the tint so untextured
// materials still get a meaningful color chip.
var text = System.IO.File.ReadAllText(
System.IO.Path.Combine( dir, matName + ".vmat" ) );
var m = System.Text.RegularExpressions.Regex.Match(
text, "g_vColorTint\\s+\"\\[([0-9.]+) ([0-9.]+) ([0-9.]+)" );
if ( m.Success )
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
_tint = new Color(
float.Parse( m.Groups[1].Value, inv ),
float.Parse( m.Groups[2].Value, inv ),
float.Parse( m.Groups[3].Value, inv ) );
}
}
catch { }
}
protected override void OnPaint()
{
var r = LocalRect.Shrink( 1 );
if ( _pix != null )
{
Paint.Draw( r, _pix );
return;
}
Paint.ClearPen();
Paint.SetBrush( _tint );
Paint.DrawRect( r, 3 );
}
}
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();
}
}
}