UI/SignalsOverlay.razor
@using Sandbox
@using Sandbox.UI
@inherits PanelComponent
@namespace Sandbox
<root>
@if ( HasContent )
{
@foreach ( var connector in Connectors )
{
if ( !connector.Layout.Visible ) continue;
<div @key=@($"{connector.Source.Id}:{connector.Target.Id}") class="connector">
@{ var dotCount = ConnectorDotCount( connector ); }
@for ( var i = 0; i < dotCount; i++ )
{
var position = ConnectorDotPosition( connector, dotCount, i );
<div class="dot" style="left: @(position.x)px; top: @(position.y)px; animation-delay: @(ConnectorDotDelay( connector, dotCount, i ))s;"></div>
}
</div>
}
@foreach ( var entry in Entries )
{
if ( !entry.Layout.Visible ) continue;
<div class="@EntryClass( entry )" style="left: @(entry.Layout.ScreenPosition.x)px; top: @(entry.Layout.ScreenPosition.y)px;">
@if ( entry.Card is { } card )
{
<div class="card-anchor" style="left: @(entry.Layout.CardOffset.x)px; top: @(entry.Layout.CardOffset.y)px;">
<div class="card-stack">
<div class="heading">
<i>@entry.Icon</i>
<label class="title">@card.Title</label>
</div>
<div class="card">
@if ( card.Options.Count > 0 )
{
<div class="option-list">
@foreach ( var option in VisibleOptions( card ) )
{
<div class="option @(option == SelectedOption( card ) ? "active" : "")">
<i>@option.Icon</i>
<div class="option-labels">
<label class="option-title">@option.Title</label>
@if ( option == SelectedOption( card ) && !string.IsNullOrWhiteSpace( option.Description ) )
{
<label class="description option-description">@option.Description</label>
}
</div>
</div>
}
</div>
}
</div>
</div>
</div>
}
</div>
}
}
</root>
@code
{
public sealed class Entry( GameObject target, string icon = "link" )
{
public GameObject Target { get; } = target;
public string Icon { get; } = icon;
public Card Card { get; init; }
internal Projection Layout { get; } = new();
}
public sealed class Card( string title )
{
public string Title { get; } = title;
public IReadOnlyList<Option> Options { get; init; } = [];
public int SelectedOption { get; init; }
}
public sealed class Option( string title )
{
public string Icon { get; init; } = "input";
public string Title { get; } = title;
public string Description { get; init; }
}
public sealed class Connector( GameObject source, GameObject target )
{
public GameObject Source { get; } = source;
public GameObject Target { get; } = target;
internal ConnectorProjection Layout { get; } = new();
}
internal sealed class Projection
{
public Vector2 ScreenPosition { get; set; }
public Vector2 CardOffset { get; set; }
public bool Visible { get; set; }
public bool CardBelow { get; set; }
public bool CardShiftRight { get; set; }
public bool CardShiftLeft { get; set; }
}
internal sealed class ConnectorProjection
{
public Vector2 Start { get; set; }
public Vector2 End { get; set; }
public float Length { get; set; }
public bool Visible { get; set; }
}
private readonly record struct Geometry( Vector3 Center, Vector3[] Bounds );
private const float CollectRadius = 4096f;
private readonly List<Entry> Entries = new();
private readonly List<Connector> Connectors = new();
private readonly List<Connector> _collectedConnectors = new();
private readonly Dictionary<GameObject, Geometry> _geometry = new();
private RealTimeSince _sinceGeometryRefresh = 1f;
private RealTimeSince _sinceShowRequested = 999f;
private RealTimeSince _sinceCollect = 999f;
private Entry _hoverEntry;
private int? _contentHash;
private int _revision;
private int _layoutHash;
private bool _hasLayoutHash;
/// <summary>
/// Keep the overlay visible this frame — call every frame while a tool wants it up,
/// optionally with a hover card to show. The overlay also shows itself while the
/// inspect menu is open; either way it collects and draws the wires on its own.
/// </summary>
public void Show( Entry hoverEntry = null )
{
_sinceShowRequested = 0;
_hoverEntry = hoverEntry;
}
private static IEnumerable<GameObject> FindPhysicsRoots( Scene scene, Sphere area )
{
var seen = new HashSet<GameObject>();
foreach ( var physicalObject in scene.FindInPhysics( area ) )
{
if ( !physicalObject.IsValid() ) continue;
var root = physicalObject.Root;
if ( root.IsValid() && seen.Add( root ) )
yield return root;
}
}
private void SetContent( IEnumerable<Entry> entries, IEnumerable<Connector> connectors )
{
Entries.Clear();
Entries.AddRange( entries );
Connectors.Clear();
Connectors.AddRange( connectors );
_hasLayoutHash = false;
_revision++;
// There won't be an update to project empty content, so clear the existing tree now.
if ( !HasContent )
StateHasChanged();
else
UpdateLayout();
}
private bool HasContent => Entries.Count > 0 || Connectors.Count > 0;
protected override void OnUpdate()
{
base.OnUpdate();
UpdateContent();
UpdateLayout();
}
private void UpdateContent()
{
var toolActive = _sinceShowRequested < 0.1f;
if ( !toolActive )
_hoverEntry = null;
var menuOpen = SpawnMenuHost.IsOpen && SpawnMenuHost.GetActiveMode() is ContextMenuHost;
if ( !menuOpen && !toolActive )
{
if ( HasContent )
SetContent( [], [] );
_contentHash = null;
return;
}
if ( _sinceCollect > 0.2f )
{
_sinceCollect = 0;
CollectConnectors( _collectedConnectors );
}
// Only rebuild the tree when what's shown actually changed.
var contentHash = BuildContentHash();
if ( contentHash == _contentHash ) return;
_contentHash = contentHash;
SetContent( _hoverEntry is null ? [] : [_hoverEntry], _collectedConnectors );
}
/// <summary>
/// Find every wire around the camera: signal connections on outputs, and component
/// links (references and presence) on their ManualLink stamps.
/// </summary>
private void CollectConnectors( List<Connector> connectors )
{
connectors.Clear();
var camera = Scene?.Camera;
if ( !camera.IsValid() ) return;
foreach ( var root in FindPhysicsRoots( Scene, new Sphere( camera.WorldPosition, CollectRadius ) ) )
{
if ( root.Tags.Has( "world" ) || root.Tags.Has( "player" ) ) continue;
foreach ( var output in SignalSystem.GetOutputs( root ) )
{
// Component outputs have no connection list; their wires show from the stamps below.
if ( output.Port is null ) continue;
foreach ( var connection in output.Port.Connections )
{
if ( connection?.Target.IsValid() == true )
connectors.Add( new Connector( output.Component.GameObject.Root, connection.Target.GameObject.Root ) );
}
}
foreach ( var link in root.GetComponentsInChildren<ManualLink>() )
{
if ( !link.HasWire || !link.IsComponentWire ) continue;
if ( !link.SignalSource.IsValid() || !link.SignalTarget.IsValid() ) continue;
// Each pair is stamped on both ends; draw once, from the source side.
var sourceRoot = link.SignalSource.GameObject.Root;
if ( sourceRoot != root ) continue;
connectors.Add( new Connector( sourceRoot, link.SignalTarget.GameObject.Root ) );
}
}
}
private int BuildContentHash()
{
var hash = new HashCode();
if ( _hoverEntry is { } entry )
{
hash.Add( entry.Target.IsValid() ? entry.Target.Id : Guid.Empty );
hash.Add( entry.Icon );
if ( entry.Card is { } card )
{
hash.Add( card.Title );
hash.Add( card.SelectedOption );
foreach ( var option in card.Options )
{
hash.Add( option.Icon );
hash.Add( option.Title );
hash.Add( option.Description );
}
}
}
foreach ( var connector in _collectedConnectors )
{
hash.Add( connector.Source.Id );
hash.Add( connector.Target.Id );
}
return hash.ToHashCode();
}
private void UpdateLayout()
{
if ( !HasContent ) return;
var camera = Scene.Camera;
var viewport = camera.IsValid() ? camera.ScreenRect.Size : Vector2.Zero;
var scale = Panel.ScaleFromScreen;
var panelViewport = viewport * scale;
if ( _sinceGeometryRefresh > 0.2f ) RefreshGeometry();
foreach ( var connector in Connectors )
{
connector.Layout.Visible = false;
if ( !camera.IsValid() || !connector.Source.IsValid() || !connector.Target.IsValid() ) continue;
var start = camera.PointToScreenPixels( GetWorldCenter( connector.Source ), out var startBehind );
var end = camera.PointToScreenPixels( GetWorldCenter( connector.Target ), out var endBehind );
if ( startBehind || endBehind ) continue;
var startCenter = start;
var endCenter = end;
// Lines run between the objects' projected bounds edges, not their centers.
if ( TryGetScreenBounds( camera, connector.Source, out var sourceBounds ) )
start = ClipToBounds( sourceBounds, start, end );
if ( TryGetScreenBounds( camera, connector.Target, out var targetBounds ) )
end = ClipToBounds( targetBounds, end, start );
start *= scale;
end *= scale;
var line = end - start;
var centerLine = (endCenter - startCenter) * scale;
// Overlapping bounds leave nothing to draw across — the clipped ends cross over.
if ( line.Length < 12f || line.x * centerLine.x + line.y * centerLine.y <= 0f ) continue;
connector.Layout.Start = start;
connector.Layout.End = end;
connector.Layout.Length = line.Length;
connector.Layout.Visible = true;
}
foreach ( var entry in Entries )
{
entry.Layout.Visible = false;
if ( entry.Card is null ) continue; // Entries only render their card.
if ( !camera.IsValid() ) continue;
if ( !entry.Target.IsValid() ) continue;
var geometry = GetGeometry( entry.Target );
var worldPosition = entry.Target.WorldTransform.PointToWorld( geometry.Center );
var pixels = camera.PointToScreenPixels( worldPosition, out var behind );
if ( behind || !camera.ScreenRect.IsInside( pixels ) )
continue;
var cardPixels = pixels;
if ( entry.Card is not null )
{
var min = new Vector2( float.MaxValue );
var max = new Vector2( float.MinValue );
var projected = false;
foreach ( var localCorner in geometry.Bounds )
{
var corner = entry.Target.WorldTransform.PointToWorld( localCorner );
var screenCorner = camera.PointToScreenPixels( corner, out var cornerBehind );
if ( cornerBehind ) continue;
min = Vector2.Min( min, screenCorner );
max = Vector2.Max( max, screenCorner );
projected = true;
}
if ( projected )
cardPixels = new Vector2( (min.x + max.x) * 0.5f, min.y );
}
entry.Layout.ScreenPosition = pixels * scale;
entry.Layout.CardOffset = (cardPixels - pixels) * scale;
entry.Layout.CardBelow = cardPixels.y < (entry.Card?.Options.Count > 0 ? 290f : 180f);
entry.Layout.CardShiftRight = cardPixels.x * scale < 180f;
entry.Layout.CardShiftLeft = cardPixels.x * scale > panelViewport.x - 180f;
entry.Layout.Visible = true;
}
// The Razor tree only includes projected entries. Rebuild only when their visible
// pixel layout changed, rather than recreating the dotted line every update.
var layoutHash = BuildLayoutHash();
if ( _hasLayoutHash && _layoutHash == layoutHash ) return;
_layoutHash = layoutHash;
_hasLayoutHash = true;
StateHasChanged();
}
private int BuildLayoutHash()
{
var hash = new HashCode();
hash.Add( _revision );
foreach ( var entry in Entries )
{
hash.Add( entry.Layout.Visible );
hash.Add( (int)MathF.Round( entry.Layout.ScreenPosition.x ) );
hash.Add( (int)MathF.Round( entry.Layout.ScreenPosition.y ) );
hash.Add( (int)MathF.Round( entry.Layout.CardOffset.x ) );
hash.Add( (int)MathF.Round( entry.Layout.CardOffset.y ) );
hash.Add( entry.Layout.CardBelow );
hash.Add( entry.Layout.CardShiftRight );
hash.Add( entry.Layout.CardShiftLeft );
}
foreach ( var connector in Connectors )
{
hash.Add( connector.Layout.Visible );
hash.Add( (int)MathF.Round( connector.Layout.Start.x ) );
hash.Add( (int)MathF.Round( connector.Layout.Start.y ) );
hash.Add( (int)MathF.Round( connector.Layout.End.x ) );
hash.Add( (int)MathF.Round( connector.Layout.End.y ) );
}
return hash.ToHashCode();
}
private void RefreshGeometry()
{
_sinceGeometryRefresh = 0;
var targets = Entries.Where( entry => entry.Target.IsValid() ).Select( entry => entry.Target ).ToHashSet();
targets.UnionWith( Connectors.Where( connector => connector.Source.IsValid() ).Select( connector => connector.Source ) );
targets.UnionWith( Connectors.Where( connector => connector.Target.IsValid() ).Select( connector => connector.Target ) );
foreach ( var target in _geometry.Keys.ToArray() )
{
if ( !targets.Contains( target ) )
_geometry.Remove( target );
}
foreach ( var target in targets )
_geometry[target] = BuildGeometry( target );
}
/// <summary>
/// The screen-space box around the object's projected bounds corners, in pixels.
/// False when any corner is behind the camera — the box would be meaningless.
/// </summary>
private bool TryGetScreenBounds( CameraComponent camera, GameObject target, out Rect bounds )
{
bounds = default;
var geometry = GetGeometry( target );
var min = new Vector2( float.MaxValue );
var max = new Vector2( float.MinValue );
foreach ( var localCorner in geometry.Bounds )
{
var corner = target.WorldTransform.PointToWorld( localCorner );
var screenCorner = camera.PointToScreenPixels( corner, out var behind );
if ( behind ) return false;
min = Vector2.Min( min, screenCorner );
max = Vector2.Max( max, screenCorner );
}
bounds = new Rect( min, max - min );
return true;
}
/// <summary>
/// Slide a point sitting inside the box along the line towards the outside point,
/// stopping at the box edge.
/// </summary>
private static Vector2 ClipToBounds( Rect bounds, Vector2 inside, Vector2 outside )
{
var direction = outside - inside;
var t = 1f;
if ( MathF.Abs( direction.x ) > 0.0001f )
t = MathF.Min( t, ((direction.x > 0f ? bounds.Right : bounds.Left) - inside.x) / direction.x );
if ( MathF.Abs( direction.y ) > 0.0001f )
t = MathF.Min( t, ((direction.y > 0f ? bounds.Bottom : bounds.Top) - inside.y) / direction.y );
return inside + direction * t.Clamp( 0f, 1f );
}
private Vector3 GetWorldCenter( GameObject target )
{
return target.WorldTransform.PointToWorld( GetGeometry( target ).Center );
}
private Geometry GetGeometry( GameObject target )
{
if ( !_geometry.TryGetValue( target, out var geometry ) )
{
geometry = BuildGeometry( target );
_geometry[target] = geometry;
}
return geometry;
}
private static Geometry BuildGeometry( GameObject target )
{
var bounds = target.GetBounds();
var transform = target.WorldTransform;
var corners = bounds.Corners.Select( corner => transform.PointToLocal( corner ) ).ToArray();
return new Geometry( transform.PointToLocal( bounds.Center ), corners );
}
private static string EntryClass( Entry entry )
{
return $"world-entry{(entry.Layout.CardBelow ? " card-below" : "")}{(entry.Layout.CardShiftRight ? " card-shift-right" : "")}{(entry.Layout.CardShiftLeft ? " card-shift-left" : "")}";
}
private static Option SelectedOption( Card card )
{
if ( card.Options.Count == 0 ) return null;
return card.Options[card.SelectedOption.Clamp( 0, card.Options.Count - 1 )];
}
private static IEnumerable<Option> VisibleOptions( Card card )
{
var count = card.Options.Count;
var selected = card.SelectedOption.Clamp( 0, count - 1 );
var first = (selected - 1).Clamp( 0, Math.Max( 0, count - 3 ) );
return card.Options.Skip( first ).Take( 3 );
}
// The line endpoints already sit on the bounds edges; just breathe a little off them.
private const float ConnectorGap = 10f;
private const float ConnectorAnimationDuration = 4f;
private static int ConnectorDotCount( Connector connector )
{
var usableLength = connector.Layout.Length + ConnectorAmplitude( connector ) * ConnectorWaveCount( connector ) - ConnectorGap * 2f;
return usableLength <= 0f ? 0 : ((int)(usableLength / 14f) + 1).Clamp( 1, 64 );
}
private static Vector2 ConnectorDotPosition( Connector connector, int count, int index )
{
var inset = (ConnectorGap / connector.Layout.Length).Clamp( 0f, 0.45f );
var progress = count <= 1 ? 0.5f : index / (count - 1f);
var t = inset + (1f - inset * 2f) * progress;
return ConnectorPoint( connector, t );
}
private static float ConnectorAmplitude( Connector connector ) => MathF.Min( 34f, connector.Layout.Length * 0.09f );
private static int ConnectorWaveCount( Connector connector ) => ((int)(connector.Layout.Length / 180f)).Clamp( 1, 3 );
private static Vector2 ConnectorPoint( Connector connector, float t )
{
var start = connector.Layout.Start;
var end = connector.Layout.End;
var line = end - start;
var direction = line / connector.Layout.Length;
var normal = new Vector2( -direction.y, direction.x );
var wave = MathF.Sin( t * ConnectorWaveCount( connector ) * MathF.PI * 2f );
return start + line * t + normal * wave * ConnectorAmplitude( connector );
}
private static float ConnectorDotDelay( Connector connector, int count, int index )
{
return count <= 1 ? 0f : ConnectorAnimationDuration * index / count;
}
protected override int BuildHash() => _revision;
}