Search the source of every open source package.
1193 results
using System;
namespace Editor;
/// <summary>
/// Blender-style radial menu with button boxes arranged in a circle.
/// Opens on Ctrl+Right Click.
/// </summary>
public class PieMenu : Widget
{
public class PieOption
{
public string Text { get; set; }
public string Icon { get; set; }
public Action Action { get; set; }
public bool Enabled { get; set; } = true;
}
private List<PieOption> _options = new();
private int _hoveredIndex = -1;
private Vector2 _centerPosition;
private float _currentIndicatorAngle = -90f; // Start at top
private float _targetIndicatorAngle = -90f;
public float Radius { get; set; } = 180f;
public float CenterRadius { get; set; } = 30f;
public float CenterRingThickness { get; set; } = 10f;
public float ButtonPadding { get; set; } = 20f;
public float ButtonHeight { get; set; } = 28f;
// s&box themed colors
public Color ButtonColor { get; set; } = Theme.ButtonBackground;
public Color ButtonHoverColor { get; set; } = Theme.Blue;
public Color CenterRingColor { get; set; } = Theme.ControlBackground.Darken( 0.2f );
public Color IndicatorColor { get; set; } = Theme.Yellow;
// Track which button this menu should respond to
public MouseButtons TriggerButton { get; set; } = MouseButtons.Forward;
public PieMenu( Widget parent = null ) : base( parent )
{
WindowFlags = WindowFlags.Popup | WindowFlags.FramelessWindowHint | WindowFlags.NoDropShadowWindowHint;
TranslucentBackground = true;
}
public PieOption AddOption( string text, string icon = null, Action action = null )
{
var option = new PieOption
{
Text = text,
Icon = icon,
Action = action
};
_options.Add( option );
return option;
}
public void Clear()
{
_options.Clear();
_hoveredIndex = -1;
}
public void OpenAtCursor()
{
OpenAt( Application.CursorPosition );
}
public void OpenAt( Vector2 position )
{
if ( _options.Count == 0 ) return;
Paint.SetDefaultFont( 10, 500 );
float maxButtonWidth = 0;
foreach ( var option in _options )
{
var textSize = Paint.MeasureText( option.Text );
float buttonWidth = textSize.x + ButtonPadding * 2;
maxButtonWidth = Math.Max( maxButtonWidth, buttonWidth );
}
var size = (Radius + maxButtonWidth + 40) * 2;
var widgetSize = new Vector2( size );
Size = widgetSize;
MinimumSize = widgetSize;
MaximumSize = widgetSize;
Position = position - (widgetSize / 2f);
_centerPosition = widgetSize / 2;
_hoveredIndex = -1;
Show();
Focus();
MouseTracking = true;
var localPos = Application.CursorPosition;
UpdateHoveredOption( localPos );
}
protected override void OnPaint()
{
base.OnPaint();
if ( _options.Count == 0 ) return;
Paint.Antialiasing = true;
var center = LocalRect.Center;
int optionCount = _options.Count;
float angleStep = 360f / optionCount;
float startAngle = -90f;
// Draw button boxes positioned around the circle
for ( int i = 0; i < optionCount; i++ )
{
var option = _options[i];
float angle = startAngle + (i * angleStep);
float angleRad = angle * MathF.PI / 180f;
// Calculate button position
var buttonCenter = center + new Vector2(
MathF.Cos( angleRad ) * Radius,
MathF.Sin( angleRad ) * Radius
);
// Measure text width to size button
Paint.SetDefaultFont( 10, 500 );
var textSize = Paint.MeasureText( option.Text );
// Button width = text width + padding
float buttonWidth = textSize.x + ButtonPadding * 2;
var buttonRect = new Rect(
buttonCenter.x - buttonWidth / 2,
buttonCenter.y - ButtonHeight / 2,
buttonWidth,
ButtonHeight
);
var buttonColor = i == _hoveredIndex ? ButtonHoverColor : ButtonColor;
Paint.ClearPen();
Paint.SetBrush( buttonColor );
Paint.DrawRect( buttonRect, 3 );
// Draw text - centered
Paint.SetPen( Color.White );
Paint.SetDefaultFont( 10, 500 );
var textRect = buttonRect.Shrink( 10, 0 );
Paint.DrawText( textRect, option.Text, TextFlag.Center );
}
// Draw center ring (donut shape)
Paint.ClearPen();
Paint.SetBrush( CenterRingColor );
DrawRing( center, CenterRadius - CenterRingThickness, CenterRadius );
if ( _hoveredIndex >= 0 && _hoveredIndex < _options.Count )
{
// Much faster interpolation for snappier feel
float angleDiff = _targetIndicatorAngle - _currentIndicatorAngle;
// Handle wraparound (shortest path)
if ( angleDiff > 180f ) angleDiff -= 360f;
if ( angleDiff < -180f ) angleDiff += 360f;
_currentIndicatorAngle += angleDiff * 0.6f; // Increased from 0.3f for faster response
// Normalize angle
if ( _currentIndicatorAngle < 0 ) _currentIndicatorAngle += 360f;
if ( _currentIndicatorAngle >= 360f ) _currentIndicatorAngle -= 360f;
// Draw a colored arc segment on the ring
float arcSpan = 40f;
Paint.SetBrush( IndicatorColor );
DrawArcSegment( center, CenterRadius - CenterRingThickness, CenterRadius, _currentIndicatorAngle - arcSpan / 2, _currentIndicatorAngle + arcSpan / 2 );
}
}
/// <summary>
/// Draw a ring (donut shape)
/// </summary>
private void DrawRing( Vector2 center, float innerRadius, float outerRadius )
{
const int segments = 64;
var points = new List<Vector2>();
// Outer circle
for ( int i = 0; i <= segments; i++ )
{
float angle = (i / (float)segments) * 360f;
float rad = angle * MathF.PI / 180f;
points.Add( center + new Vector2( MathF.Cos( rad ), MathF.Sin( rad ) ) * outerRadius );
}
// Inner circle (reverse)
for ( int i = segments; i >= 0; i-- )
{
float angle = (i / (float)segments) * 360f;
float rad = angle * MathF.PI / 180f;
points.Add( center + new Vector2( MathF.Cos( rad ), MathF.Sin( rad ) ) * innerRadius );
}
Paint.DrawPolygon( points.ToArray() );
}
/// <summary>
/// Draw an arc segment on a ring
/// </summary>
private void DrawArcSegment( Vector2 center, float innerRadius, float outerRadius, float startAngle, float endAngle )
{
const int segments = 20;
float angleRange = endAngle - startAngle;
int segmentCount = Math.Max( 2, (int)(segments * angleRange / 360f) );
var points = new List<Vector2>();
// Inner arc
for ( int i = 0; i <= segmentCount; i++ )
{
float angle = startAngle + (angleRange * i / segmentCount);
float rad = angle * MathF.PI / 180f;
points.Add( center + new Vector2( MathF.Cos( rad ), MathF.Sin( rad ) ) * innerRadius );
}
// Outer arc (reverse)
for ( int i = segmentCount; i >= 0; i-- )
{
float angle = startAngle + (angleRange * i / segmentCount);
float rad = angle * MathF.PI / 180f;
points.Add( center + new Vector2( MathF.Cos( rad ), MathF.Sin( rad ) ) * outerRadius );
}
Paint.DrawPolygon( points.ToArray() );
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
UpdateHoveredOption( e.LocalPosition );
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
e.Accepted = true;
}
protected override void OnMouseReleased( MouseEvent e )
{
// Check if this is the button that opened this menu
if ( e.Button == TriggerButton )
{
ExecuteHoveredOptionAndClose();
e.Accepted = true;
return;
}
base.OnMouseReleased( e );
}
/// <summary>
/// Execute the currently hovered option and close the menu
/// </summary>
public void ExecuteHoveredOptionAndClose()
{
if ( _hoveredIndex >= 0 && _hoveredIndex < _options.Count )
{
var option = _options[_hoveredIndex];
if ( option.Enabled )
{
option.Action?.Invoke();
}
}
Close();
}
protected override void OnKeyPress( KeyEvent e )
{
base.OnKeyPress( e );
if ( e.Key == KeyCode.Escape )
{
Close();
e.Accepted = true;
}
}
private void UpdateHoveredOption( Vector2 mousePos )
{
var center = LocalRect.Center;
var offset = mousePos - center;
float distance = offset.Length;
// More forgiving selection area
if ( distance < 10f || distance > Radius + 250 )
{
if ( _hoveredIndex != -1 )
{
_hoveredIndex = -1;
Update();
}
return;
}
// Calculate angle from center to mouse
float mouseAngle = MathF.Atan2( offset.y, offset.x ) * 180f / MathF.PI;
// Find the closest button by angular distance
int optionCount = _options.Count;
float angleStep = 360f / optionCount;
float startAngle = -90f;
int closestIndex = -1;
float closestDistance = float.MaxValue;
for ( int i = 0; i < optionCount; i++ )
{
// Calculate the angle of this button's center
float buttonAngle = startAngle + (i * angleStep);
// Normalize both angles to 0-360
float normMouseAngle = mouseAngle;
if ( normMouseAngle < 0 ) normMouseAngle += 360f;
float normButtonAngle = buttonAngle;
if ( normButtonAngle < 0 ) normButtonAngle += 360f;
// Calculate angular distance (shortest path around the circle)
float angularDist = Math.Abs( normMouseAngle - normButtonAngle );
if ( angularDist > 180f ) angularDist = 360f - angularDist;
if ( angularDist < closestDistance )
{
closestDistance = angularDist;
closestIndex = i;
}
}
// Set target angle to the selected button's angle for smooth snapping
if ( closestIndex != -1 )
{
_targetIndicatorAngle = startAngle + (closestIndex * angleStep);
}
_hoveredIndex = closestIndex;
Update();
}
[Shortcut( "editor.paste.color", "CTRL+V", typeof( SceneViewWidget ) )]
public static void PasteFromClipboard()
{
var clipboard = EditorUtility.Clipboard.Paste();
if ( string.IsNullOrWhiteSpace( clipboard ) )
{
// Try GameObject paste if clipboard text is empty
PasteGameObject();
return;
}
// Try to parse as hex color first
if ( clipboard.StartsWith( "#" ) )
{
if ( TryParseHexColor( clipboard, out Color color ) )
{
PasteColor( color );
return;
}
else
{
Log.Warning( $"Failed to parse color from clipboard: {clipboard}" );
return;
}
}
// Try to handle GameObject paste
PasteGameObject();
}
private static void PasteColor( Color color )
{
using var scope = SceneEditorSession.Scope();
var selection = SceneEditorSession.Active.Selection;
// Get all MeshComponents and ModelRenderers from selected GameObjects
var meshComponents = selection.OfType<GameObject>()
.Select( x => x.GetComponent<MeshComponent>() )
.Where( x => x.IsValid() )
.ToList();
var modelRenderers = selection.OfType<GameObject>()
.Select( x => x.GetComponent<ModelRenderer>() )
.Where( x => x.IsValid() )
.ToList();
var totalCount = meshComponents.Count + modelRenderers.Count;
if ( totalCount == 0 )
{
Log.Info( "No mesh or model renderer components selected" );
return;
}
// Combine both lists for undo tracking
var allComponents = meshComponents.Cast<Component>()
.Concat( modelRenderers.Cast<Component>() )
.ToList();
using ( SceneEditorSession.Active.UndoScope( "Paste Color" )
.WithComponentChanges( allComponents )
.Push() )
{
// Apply color to MeshComponents
foreach ( var component in meshComponents )
{
component.Color = color;
}
// Apply color to ModelRenderers
foreach ( var component in modelRenderers )
{
component.Tint = color;
}
}
Log.Info( $"Applied color to {meshComponents.Count} mesh component(s) and {modelRenderers.Count} model renderer(s)" );
}
private static void PasteGameObject()
{
var session = SceneEditorSession.Active;
if ( session == null ) return;
// Get the active scene viewport
var sceneView = SceneViewWidget.Current;
if ( sceneView?.LastSelectedViewportWidget == null ) return;
var viewport = sceneView.LastSelectedViewportWidget;
// First, paste the standard way
EditorScene.Paste();
// Get the pasted objects
var pastedObjects = session.Selection.OfType<GameObject>().ToList();
if ( pastedObjects.Count == 0 ) return;
// Compute the average point of all pasted objects
Vector3 middlePoint = Vector3.Zero;
foreach ( var go in pastedObjects )
middlePoint += go.WorldPosition;
middlePoint /= pastedObjects.Count;
// Get mouse position in viewport and trace
var mousePosition = SceneViewportWidget.MousePosition;
var camera = viewport.Renderer.Camera;
if ( !camera.IsValid() ) return;
// Create ray from mouse position
var ray = camera.ScreenPixelToRay( mousePosition );
// Trace to find world position
var trace = session.Scene.Trace
.Ray( ray, 10000f )
.UseRenderMeshes( true )
.UsePhysicsWorld( false )
.Run();
if ( trace.Hit )
{
using ( session.UndoScope( "Paste at Mouse" ).Push() )
{
// Reposition all game objects relative to new center
foreach ( var go in pastedObjects )
{
Vector3 offset = go.WorldPosition - middlePoint;
go.WorldPosition = trace.HitPosition + offset;
}
Log.Info( $"Pasted {pastedObjects.Count} GameObject(s) at mouse position" );
}
}
}
static bool TryParseHexColor( string hex, out Color color )
{
color = default;
if ( string.IsNullOrEmpty( hex ) )
return false;
hex = hex.TrimStart( '#' );
if ( hex.Length != 6 && hex.Length != 8 )
return false;
try
{
int r = Convert.ToInt32( hex.Substring( 0, 2 ), 16 );
int g = Convert.ToInt32( hex.Substring( 2, 2 ), 16 );
int b = Convert.ToInt32( hex.Substring( 4, 2 ), 16 );
int a = hex.Length == 8 ? Convert.ToInt32( hex.Substring( 6, 2 ), 16 ) : 255;
color = new Color( r / 255f, g / 255f, b / 255f, a / 255f );
return true;
}
catch
{
return false;
}
}
public IReadOnlyList<PieOption> Options => _options.AsReadOnly();
}
using Editor;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using static Editor.Label;
internal sealed class MappingToolSettingsWindow : BaseWindow
{
private NavigationView Navigation { get; }
[Menu( "Editor", "Mapping Tools/Settings", Icon = "settings" )]
public static MappingToolSettingsWindow Open()
{
var window = new MappingToolSettingsWindow();
window.Show();
return window;
}
public MappingToolSettingsWindow()
{
SetModal( true, true );
Size = new Vector2( 640, 420 );
MinimumSize = Size;
TranslucentBackground = true;
NoSystemBackground = true;
WindowTitle = "Mapping Tool Settings";
SetWindowIcon( "settings" );
Layout = Layout.Column();
Layout.Margin = 4;
Layout.Spacing = 4;
Navigation = new NavigationView();
Layout.Add( Navigation );
BuildPages();
}
private void BuildPages()
{
Navigation.AddSectionHeader( "Mapping Tools" );
Navigation.AddPage( "General", "tune", new PageGeneral( this ) );
Navigation.AddPage( "Materials Gallery", "image", new PageMaterialBrowser( this ) );
Navigation.AddPage( "Render Pie Menu", "menu", new PageRenderPieMenu( this ) );
Navigation.AddPage( "Mapping Pie Menu", "edit", new PageMappingPieMenu( this ) );
Navigation.AddPage( "Auto-Save", "save", new PageAutoSave( this ) );
}
}
internal sealed class PageGeneral : Widget
{
public PageGeneral( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 32;
Layout.Spacing = 16;
Layout.Add( new Subtitle( "General Settings" ) );
Layout.Add( new Editor.Label( "General mapping workflow enhancements" ) { WordWrap = true } );
var sheet = new Editor.ControlSheet();
sheet.AddProperty( () => MappingToolSettings.DoubleClickToMeshMode );
Layout.Add( sheet );
Layout.AddStretchCell();
}
}
internal sealed class PageMaterialBrowser : Widget
{
private Layout _orgListLayout;
public PageMaterialBrowser( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 32;
Layout.Spacing = 16;
Layout.Add( new Subtitle( "Materials Gallery" ) );
var sheet = new Editor.ControlSheet();
// Add default org property
sheet.AddProperty( () => MappingToolSettings.DefaultOrganization );
Layout.Add( sheet );
// Organizations list section
Layout.Add( new Editor.Label.Title( "Additional Organizations" ) );
Layout.Add( new Editor.Label( "Add organizations to search for materials" ) { WordWrap = true } );
// Scrollable container for organizations
var scrollArea = new ScrollArea( this );
scrollArea.MinimumHeight = 150;
scrollArea.MaximumHeight = 200;
Layout.Add( scrollArea );
var container = new Widget( scrollArea );
_orgListLayout = container.Layout = Layout.Column();
_orgListLayout.Spacing = 4;
scrollArea.Canvas = container;
// Add organization controls
var addRow = Layout.AddRow();
addRow.Spacing = 8;
var orgInput = new LineEdit();
orgInput.PlaceholderText = "Enter organization name...";
orgInput.MinimumWidth = 200;
addRow.Add( orgInput, 1 );
var addButton = new Editor.Button( "Add", "add" );
addButton.Clicked += () =>
{
var org = orgInput.Text?.Trim();
if ( !string.IsNullOrEmpty( org ) )
{
MappingToolSettings.AddOrganization( org );
orgInput.Text = "";
RefreshOrgList();
}
};
addRow.Add( addButton );
var resetButton = new Editor.Button( "Reset to Defaults" );
resetButton.Clicked += () =>
{
MappingToolSettings.ResetMaterialGalleryDefaults();
RefreshOrgList();
};
Layout.Add( resetButton );
Layout.AddStretchCell();
RefreshOrgList();
}
private void RefreshOrgList()
{
// Clear existing items
_orgListLayout.Clear( true );
var orgs = MappingToolSettings.AdditionalOrganizations.ToList();
if ( orgs.Count == 0 )
{
var emptyLabel = new Editor.Label( "No additional organizations added" );
emptyLabel.SetStyles( "color: rgba(255,255,255,0.4); font-style: italic;" );
_orgListLayout.Add( emptyLabel );
return;
}
foreach ( var org in orgs )
{
var row = _orgListLayout.AddRow();
row.Spacing = 8;
// Org name label
var nameLabel = new Editor.Label( org );
nameLabel.MinimumWidth = 150;
row.Add( nameLabel, 1 );
// Remove button
var removeBtn = new Editor.Button.Primary( "", "close" );
removeBtn.MinimumWidth = 32;
removeBtn.MaximumWidth = 32;
removeBtn.ToolTip = $"Remove {org}";
removeBtn.Clicked += () =>
{
MappingToolSettings.RemoveOrganization( org );
RefreshOrgList();
};
row.Add( removeBtn );
}
}
}
internal sealed class PageRenderPieMenu : Widget
{
public PageRenderPieMenu( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 32;
Layout.Spacing = 16;
Layout.Add( new Subtitle( "Render Pie Menu" ) );
Layout.Add( new Editor.Label( "Quick access to render modes and viewport settings" ) { WordWrap = true } );
var sheet = new Editor.ControlSheet();
// Automatically generate UI from the settings properties
sheet.AddProperty( () => MappingToolSettings.PieMenuButton );
sheet.AddProperty( () => MappingToolSettings.PieMenuUseModifier );
sheet.AddProperty( () => MappingToolSettings.PieMenuModifierKey );
sheet.AddProperty( () => MappingToolSettings.PieMenuSize );
var resetButton = new Editor.Button( "Reset to Defaults" );
resetButton.Clicked += () =>
{
MappingToolSettings.ResetPieMenuDefaults();
};
Layout.Add( sheet );
Layout.Add( resetButton );
Layout.AddStretchCell();
}
}
internal sealed class PageMappingPieMenu : Widget
{
public PageMappingPieMenu( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 32;
Layout.Spacing = 16;
Layout.Add( new Subtitle( "Mapping Pie Menu" ) );
Layout.Add( new Editor.Label( "Quick access to mesh editing modes (Object, Vertex, Edge, Face)" ) { WordWrap = true } );
var sheet = new Editor.ControlSheet();
// Automatically generate UI from the settings properties
sheet.AddProperty( () => MappingToolSettings.MappingPieMenuButton );
sheet.AddProperty( () => MappingToolSettings.MappingPieMenuUseModifier );
sheet.AddProperty( () => MappingToolSettings.MappingPieMenuModifierKey );
sheet.AddProperty( () => MappingToolSettings.MappingPieMenuSize );
var resetButton = new Editor.Button( "Reset to Defaults" );
resetButton.Clicked += () =>
{
MappingToolSettings.ResetMappingPieMenuDefaults();
};
Layout.Add( sheet );
Layout.Add( resetButton );
Layout.AddStretchCell();
}
}
// Add this new page class
internal sealed class PageAutoSave : Widget
{
public PageAutoSave( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 32;
Layout.Spacing = 16;
Layout.Add( new Subtitle( "Auto-Save" ) );
Layout.Add( new Editor.Label( "Automatically create backup saves at regular intervals" ) { WordWrap = true } );
var sheet = new Editor.ControlSheet();
sheet.AddProperty( () => MappingToolSettings.AutoSaveEnabled );
sheet.AddProperty( () => MappingToolSettings.AutoSaveIntervalMinutes );
sheet.AddProperty( () => MappingToolSettings.AutoSaveMaxBackups );
sheet.AddProperty( () => MappingToolSettings.AutoSaveShowNotification );
Layout.Add( sheet );
// Force save button
var saveNowButton = new Editor.Button( "Save Backup Now", "save" );
saveNowButton.Clicked += () => AutoSave.ForceAutoSave();
Layout.Add( saveNowButton );
// Open folder button
var openFolderButton = new Editor.Button( "Open Autosave Folder", "folder_open" );
openFolderButton.Clicked += OpenAutoSaveFolder;
Layout.Add( openFolderButton );
var resetButton = new Editor.Button( "Reset to Defaults" );
resetButton.Clicked += () => MappingToolSettings.ResetAutoSaveDefaults();
Layout.Add( resetButton );
Layout.AddStretchCell();
}
private void OpenAutoSaveFolder()
{
var session = SceneEditorSession.Active;
if ( session?.Scene?.Source?.ResourcePath == null )
{
Log.Info( "No active scene" );
return;
}
var sceneDirectory = Path.GetDirectoryName( session.Scene.Source.ResourcePath );
var autoSaveFolder = Path.Combine( sceneDirectory, "autosave" );
var fullPath = Editor.FileSystem.ProjectTemporary.GetFullPath( autoSaveFolder );
// Create the folder if it doesn't exist
if ( !Directory.Exists( fullPath ) )
{
Directory.CreateDirectory( fullPath );
}
System.Diagnostics.Process.Start( "explorer.exe", fullPath );
}
}
/// <summary>
/// Settings for mapping tools including pie menu keybinds
/// </summary>
public static class MappingToolSettings
{
private const string PreferencePrefix = "MappingTools.";
// Render Pie Menu Settings
[Title( "Mouse Button" )]
[Description( "Mouse button to open the render pie menu" )]
public static MouseButtons PieMenuButton
{
get => (MouseButtons)EditorCookie.Get( PreferencePrefix + "PieMenuButton", (int)MouseButtons.Forward );
set => EditorCookie.Set( PreferencePrefix + "PieMenuButton", (int)value );
}
[Title( "Use Modifier Key" )]
[Description( "Whether to require a modifier key to open the render pie menu" )]
public static bool PieMenuUseModifier
{
get => EditorCookie.Get( PreferencePrefix + "PieMenuUseModifier", false );
set => EditorCookie.Set( PreferencePrefix + "PieMenuUseModifier", value );
}
[Title( "Modifier Key" )]
[Description( "Modifier key to open the render pie menu with" )]
public static KeyCode PieMenuModifierKey
{
get => (KeyCode)EditorCookie.Get( PreferencePrefix + "PieMenuModifierKey", (int)KeyCode.Control );
set => EditorCookie.Set( PreferencePrefix + "PieMenuModifierKey", (int)value );
}
[Title( "Menu Size" )]
[Description( "Radius of the render pie menu in pixels" )]
[Range( 100, 400 )]
public static float PieMenuSize
{
get => EditorCookie.Get( PreferencePrefix + "PieMenuSize", 180f );
set => EditorCookie.Set( PreferencePrefix + "PieMenuSize", value );
}
// Mapping Pie Menu Settings
[Title( "Mouse Button" )]
[Description( "Mouse button to open the mapping mode pie menu" )]
public static MouseButtons MappingPieMenuButton
{
get => (MouseButtons)EditorCookie.Get( PreferencePrefix + "MappingPieMenuButton", (int)MouseButtons.Back );
set => EditorCookie.Set( PreferencePrefix + "MappingPieMenuButton", (int)value );
}
[Title( "Use Modifier Key" )]
[Description( "Whether to require a modifier key to open the mapping pie menu" )]
public static bool MappingPieMenuUseModifier
{
get => EditorCookie.Get( PreferencePrefix + "MappingPieMenuUseModifier", false );
set => EditorCookie.Set( PreferencePrefix + "MappingPieMenuUseModifier", value );
}
[Title( "Modifier Key" )]
[Description( "Modifier key to open the mapping pie menu with" )]
public static KeyCode MappingPieMenuModifierKey
{
get => (KeyCode)EditorCookie.Get( PreferencePrefix + "MappingPieMenuModifierKey", (int)KeyCode.Control );
set => EditorCookie.Set( PreferencePrefix + "MappingPieMenuModifierKey", (int)value );
}
[Title( "Menu Size" )]
[Description( "Radius of the mapping pie menu in pixels" )]
[Range( 100, 400 )]
public static float MappingPieMenuSize
{
get => EditorCookie.Get( PreferencePrefix + "MappingPieMenuSize", 180f );
set => EditorCookie.Set( PreferencePrefix + "MappingPieMenuSize", value );
}
// Material Gallery Settings
[Title( "Default Organization" )]
[Description( "Primary organization to search for materials" )]
public static string DefaultOrganization
{
get => EditorCookie.Get( PreferencePrefix + "DefaultOrganization", "facepunch" );
set => EditorCookie.Set( PreferencePrefix + "DefaultOrganization", value );
}
public static IEnumerable<string> AdditionalOrganizations
{
get
{
var json = EditorCookie.Get( PreferencePrefix + "AdditionalOrganizations", "[]" );
return Json.Deserialize<List<string>>( json ) ?? new List<string>();
}
set
{
var json = Json.Serialize( value );
EditorCookie.Set( PreferencePrefix + "AdditionalOrganizations", json );
}
}
public static void AddOrganization( string org )
{
var orgs = AdditionalOrganizations.ToList();
if ( !orgs.Contains( org, StringComparer.OrdinalIgnoreCase ) )
{
orgs.Add( org );
AdditionalOrganizations = orgs;
}
}
public static void RemoveOrganization( string org )
{
var orgs = AdditionalOrganizations.ToList();
orgs.RemoveAll( o => o.Equals( org, StringComparison.OrdinalIgnoreCase ) );
AdditionalOrganizations = orgs;
}
public static void ResetPieMenuDefaults()
{
PieMenuButton = MouseButtons.Forward;
PieMenuUseModifier = false;
PieMenuModifierKey = KeyCode.Control;
PieMenuSize = 180f;
}
public static void ResetMappingPieMenuDefaults()
{
MappingPieMenuButton = MouseButtons.Back;
MappingPieMenuUseModifier = false;
MappingPieMenuModifierKey = KeyCode.Control;
MappingPieMenuSize = 180f;
}
public static void ResetMaterialGalleryDefaults()
{
DefaultOrganization = "facepunch";
AdditionalOrganizations = new List<string>();
}
// Add these to MappingToolSettings class:
// Auto-Save Settings
[Title( "Enable Auto-Save" )]
[Description( "Automatically save backups of your scene at regular intervals" )]
public static bool AutoSaveEnabled
{
get => EditorCookie.Get( PreferencePrefix + "AutoSaveEnabled", true );
set => EditorCookie.Set( PreferencePrefix + "AutoSaveEnabled", value );
}
[Title( "Interval (Minutes)" )]
[Description( "How often to create a backup save" )]
[Range( 1, 60 )]
public static float AutoSaveIntervalMinutes
{
get => EditorCookie.Get( PreferencePrefix + "AutoSaveIntervalMinutes", 5f );
set => EditorCookie.Set( PreferencePrefix + "AutoSaveIntervalMinutes", value );
}
[Title( "Maximum Backups" )]
[Description( "Maximum number of backup files to keep per scene (0 = unlimited)" )]
[Range( 0, 50 )]
public static int AutoSaveMaxBackups
{
get => EditorCookie.Get( PreferencePrefix + "AutoSaveMaxBackups", 10 );
set => EditorCookie.Set( PreferencePrefix + "AutoSaveMaxBackups", value );
}
[Title( "Show Notification" )]
[Description( "Show a toast notification when auto-save completes" )]
public static bool AutoSaveShowNotification
{
get => EditorCookie.Get( PreferencePrefix + "AutoSaveShowNotification", true );
set => EditorCookie.Set( PreferencePrefix + "AutoSaveShowNotification", value );
}
// Double-Click Settings
[Title( "Double-Click to Mesh Mode" )]
[Description( "Double-click on a GameObject with MeshComponent to enter Mesh Tool mode" )]
public static bool DoubleClickToMeshMode
{
get => EditorCookie.Get( PreferencePrefix + "DoubleClickToMeshMode", false );
set => EditorCookie.Set( PreferencePrefix + "DoubleClickToMeshMode", value );
}
public static void ResetAutoSaveDefaults()
{
AutoSaveEnabled = true;
AutoSaveIntervalMinutes = 5f;
AutoSaveMaxBackups = 10;
AutoSaveShowNotification = true;
}
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace GeneralGame.Editor;
/// <summary>
/// Helper for building asset context menus with Create Material/Texture options.
/// </summary>
public static class AssetContextMenuHelper
{
/// <summary>
/// Add asset-type specific options like Create Material, Create Texture, etc.
/// </summary>
public static void AddAssetTypeOptions(Menu menu, Asset asset)
{
if (asset == null) return;
var assetType = asset.AssetType;
if (assetType == null) return;
// Image files - can create Material, Texture, Sprite
if (assetType == AssetType.ImageFile)
{
menu.AddSeparator();
menu.AddOption("Create Material", "image", () => CreateMaterialFromImage(asset));
menu.AddOption("Create Texture", "texture", () => CreateTextureFromImage(asset));
menu.AddOption("Create Sprite", "emoji_emotions", () => CreateSpriteFromImage(asset));
}
// Shader files - can create Material
if (assetType == AssetType.Shader)
{
menu.AddSeparator();
menu.AddOption("Create Material", "image", () => CreateMaterialFromShader(asset));
}
// Mesh files (FBX, OBJ) - can create Model
var meshExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".fbx", ".obj", ".dmx", ".gltf", ".glb" };
if (meshExtensions.Contains(Path.GetExtension(asset.AbsolutePath)))
{
menu.AddSeparator();
menu.AddOption("Create Model", "view_in_ar", () => CreateModelFromMesh(asset));
}
}
private static void CreateTextureFromImage(Asset asset)
{
var assetName = asset.Name;
var fd = new FileDialog(null);
fd.Title = "Create Texture from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vtex";
fd.SelectFile($"{assetName}.vtex");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Texture File (*.vtex)");
if (!fd.Execute())
return;
var imagePath = asset.RelativePath;
// Create simple vtex JSON structure
var vtexContent = new Dictionary<string, object>
{
{ "Sequences", new object[]
{
new Dictionary<string, object>
{
{ "Source", imagePath },
{ "IsLooping", true }
}
}
}
};
var json = Json.Serialize(vtexContent);
File.WriteAllText(fd.SelectedFile, json);
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static void CreateMaterialFromImage(Asset asset)
{
string[] types = new[] { "color", "ao", "normal", "metallic", "rough", "diff", "diffuse", "nrm", "spec", "selfillum", "mask" };
var assetName = asset.Name;
foreach (var t in types)
{
if (assetName.EndsWith($"_{t}"))
assetName = assetName.Substring(0, assetName.Length - (t.Length + 1));
}
var fd = new FileDialog(null);
fd.Title = "Create Material from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vmat";
fd.SelectFile($"{assetName}.vmat");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Material File (*.vmat)");
if (!fd.Execute())
return;
var assetPath = Path.GetDirectoryName(asset.AbsolutePath).NormalizeFilename(false);
var assetPeers = AssetSystem.All
.Where(x => x.AssetType == AssetType.ImageFile)
.Where(x => x.AbsolutePath.StartsWith(assetPath))
.ToArray();
var assetPeersWithSameBaseName = assetPeers
.Where(x => x.Name == assetName || x.Name.StartsWith(assetName + "_"))
.ToArray();
if (assetPeersWithSameBaseName.Length > 0)
{
assetPeers = assetPeersWithSameBaseName;
}
string texColor = assetPeers.Where(x => x.Name.Contains("_color") || x.Name.Contains("_diff")).Select(x => x.RelativePath).FirstOrDefault();
texColor ??= asset.RelativePath;
string texNormal = assetPeers.Where(x => x.Name.Contains("_nrm") || x.Name.Contains("_normal") || x.Name.Contains("_amb")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_normal.tga";
string texAo = assetPeers.Where(x => x.Name.Contains("_ao") || x.Name.Contains("_occ") || x.Name.Contains("_amb")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_ao.tga";
string texRough = assetPeers.Where(x => x.Name.Contains("_rough")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_rough.tga";
string texMetallic = assetPeers.Where(x => x.Name.Contains("_metallic")).Select(x => x.RelativePath).FirstOrDefault();
if (texMetallic != null)
{
texMetallic = $"\n\tF_METALNESS_TEXTURE 1\n\tF_SPECULAR 1\n\tTextureMetalness \"{texMetallic}\"";
}
string texSelfIllum = assetPeers.Where(x => x.Name.Contains("_selfillum")).Select(x => x.RelativePath).FirstOrDefault();
if (texSelfIllum != null)
{
texSelfIllum = $"\n\tF_SELF_ILLUM 1\n\tTextureSelfIllumMask \"{texSelfIllum}\"";
}
string tintMask = assetPeers.Where(x => x.Name.Contains("_mask")).Select(x => x.RelativePath).FirstOrDefault();
if (tintMask != null)
{
tintMask = $"\n\tF_TINT_MASK 1\n\tTextureTintMask \"{tintMask}\"";
}
var file = $@"
Layer0
{{
shader ""shaders/complex.shader_c""
TextureColor ""{texColor}""
TextureAmbientOcclusion ""{texAo}""
TextureNormal ""{texNormal}""
TextureRoughness ""{texRough}""{texMetallic}{texSelfIllum}{tintMask}
}}
";
File.WriteAllText(fd.SelectedFile, file);
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static async void CreateSpriteFromImage(Asset asset)
{
var assetName = asset.Name;
var fd = new FileDialog(null);
fd.Title = "Create Sprite from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".sprite";
fd.SelectFile($"{assetName}.sprite");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Sprite File (*.sprite)");
if (!fd.Execute())
return;
var path = Path.ChangeExtension(asset.Path, Path.GetExtension(asset.AbsolutePath));
var sprite = Sprite.FromTexture(Texture.Load(path));
var json = sprite.Serialize().ToJsonString();
File.WriteAllText(fd.SelectedFile, json);
var resultAsset = AssetSystem.RegisterFile(fd.SelectedFile);
while (!resultAsset.IsCompiledAndUpToDate)
{
await Task.Delay(10);
}
}
private static void CreateMaterialFromShader(Asset asset)
{
var assetName = asset.Name;
var fd = new FileDialog(null);
fd.Title = "Create Material from Shader..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vmat";
fd.SelectFile($"{assetName}.vmat");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Material File (*.vmat)");
if (!fd.Execute())
return;
var shaderPath = asset.GetCompiledFile();
var file = $@"
Layer0
{{
shader ""{shaderPath}""
}}
";
File.WriteAllText(fd.SelectedFile, file);
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static void CreateModelFromMesh(Asset asset)
{
var targetPath = EditorUtility.SaveFileDialog("Create Model..", "vmdl", Path.ChangeExtension(asset.AbsolutePath, "vmdl"));
if (targetPath == null)
return;
EditorUtility.CreateModelFromMeshFile(asset, targetPath);
}
}
using Editor;
using Sandbox;
using System;
using System.Linq;
using System.Reflection;
namespace SpriteTools.TilesetTool;
[Inspector( typeof( TilesetTool ) )]
public class TilesetToolInspector : InspectorWidget
{
public static TilesetToolInspector Active { get; private set; }
internal TilesetTool Tool;
StatusWidget Header;
ScrollArea scrollArea;
ControlSheet toolSheet;
ControlSheet mainSheet;
ControlSheet selectedSheet;
public TilesetToolInspector ( SerializedObject so ) : base( so )
{
if ( so.Targets.FirstOrDefault() is not TilesetTool tool ) return;
Tool = tool;
// Tool.UpdateInspector += UpdateHeader;
// Tool.UpdateInspector += UpdateSelectedSheet;
Layout = Layout.Column();
Layout.Margin = 4;
Layout.Spacing = 8;
Active = this;
Rebuild();
}
int lastBuildHash = 0;
[EditorEvent.Frame]
void Frame ()
{
int buildHash = 0;
if ( Tool.SelectedComponent.IsValid() )
{
buildHash += Tool.SelectedComponent.Layers.IndexOf( Tool?.SelectedLayer );
buildHash += Tool?.SelectedLayer?.TilesetResource?.ResourceId ?? 0;
}
if ( buildHash != lastBuildHash )
{
lastBuildHash = buildHash;
Rebuild();
}
}
[EditorEvent.Hotload]
void Rebuild ()
{
if ( Layout is null ) return;
Layout.Clear( true );
scrollArea = new ScrollArea( this );
scrollArea.Canvas = new Widget();
scrollArea.Canvas.Layout = Layout.Column();
scrollArea.Canvas.VerticalSizeMode = SizeMode.CanGrow;
scrollArea.Canvas.HorizontalSizeMode = SizeMode.Flexible;
scrollArea.Canvas.Layout.Spacing = 8;
Layout.Add( scrollArea );
Header = new StatusWidget( this );
scrollArea.Canvas.Layout.Add( Header );
UpdateHeader();
mainSheet = new ControlSheet();
scrollArea.Canvas.Layout.Add( mainSheet );
UpdateMainSheet();
selectedSheet = null;
UpdateSelectedSheet();
toolSheet = new ControlSheet();
scrollArea.Canvas.Layout.Add( toolSheet );
UpdateToolSheet();
// Preview = new Preview.Preview(this);
// scrollArea.Canvas.Layout.Add(Preview);
scrollArea.Canvas.Layout.AddStretchCell();
}
internal void UpdateHeader ()
{
Header.Text = "Paint Tiles";
Header.Color = ( false ) ? Theme.Red : Theme.Blue;
Header.Icon = ( false ) ? "warning" : "dashboard";
Header.Update();
}
internal void UpdateToolSheet ()
{
if ( !( Layout?.IsValid ?? false ) ) return;
if ( toolSheet is null ) return;
toolSheet?.Clear( true );
if ( Tool?.Settings is not null )
{
toolSheet.AddObject( Tool.Settings.GetSerialized(), x =>
{
return x.HasAttribute<PropertyAttribute>() && x.PropertyType != typeof( Action );
} );
}
}
internal void UpdateMainSheet ()
{
if ( !( Layout?.IsValid ?? false ) ) return;
if ( mainSheet is null ) return;
mainSheet?.Clear( true );
if ( Tool?.CurrentTool is not null )
{
var toolName = ( Tool.CurrentTool.GetType()?.GetCustomAttribute<TitleAttribute>()?.Value ?? "Unknown" ) + " Tool";
mainSheet.AddObject( Tool.CurrentTool.GetSerialized(), x => x.HasAttribute<PropertyAttribute>() && x.PropertyType != typeof( Action ) );
}
if ( Tool.SelectedComponent.IsValid() )
{
mainSheet.AddObject( Tool.SelectedComponent.GetSerialized(), x =>
{
if ( x.Name == nameof( TilesetComponent.Layers ) ) return true;
if ( !x.HasAttribute<PropertyAttribute>() ) return false;
if ( x.TryGetAttribute<FeatureAttribute>( out var feature ) && feature.Title == "Collision" ) return false;
if ( x.PropertyType == typeof( Action ) ) return false;
if ( x.PropertyType == typeof( TilesetComponent.ComponentControls ) ) return false;
return true;
} );
}
}
internal void UpdateSelectedSheet ()
{
if ( !( Layout?.IsValid ?? false ) ) return;
if ( selectedSheet is null || !( selectedSheet?.IsValid ?? false ) )
{
selectedSheet = new ControlSheet();
scrollArea.Canvas.Layout.Add( selectedSheet );
}
selectedSheet?.Clear( true );
if ( Tool.SelectedLayer is not null )
{
selectedSheet.AddObject( Tool.SelectedLayer.GetSerialized(), x => x.HasAttribute<PropertyAttribute>() && x.PropertyType != typeof( Action ) );
}
}
private class StatusWidget : Widget
{
public string Icon { get; set; }
public string Text { get; set; }
public string LeadText { get; set; }
public Color Color { get; set; }
TilesetToolInspector Inspector;
public StatusWidget ( TilesetToolInspector parent ) : base( parent )
{
Inspector = parent;
MinimumSize = 48;
Cursor = CursorShape.Finger;
SetSizeMode( SizeMode.Default, SizeMode.CanShrink );
}
protected override void OnPaint ()
{
var rect = new Rect( 0, Size );
Paint.ClearPen();
Paint.SetBrush( Theme.WindowBackground.Lighten( 0.9f ) );
Paint.DrawRect( rect );
rect.Left += 8;
Paint.SetPen( Color );
var iconRect = Paint.DrawIcon( rect, Icon, 24, TextFlag.LeftCenter );
rect.Top += 8;
rect.Left = iconRect.Right + 8;
Paint.SetPen( Color );
Paint.SetDefaultFont( 10, 500 );
var titleRect = Paint.DrawText( rect, Text, TextFlag.LeftTop );
rect.Top = titleRect.Bottom + 2;
Paint.SetPen( Color.WithAlpha( 0.6f ) );
Paint.SetDefaultFont( 8, 400 );
var preText = "Selected Component:";
if ( !Inspector.Tool.SelectedComponent.IsValid() )
preText = "No Tileset Component";
var selectedRect = Paint.DrawText( rect, preText, TextFlag.LeftTop );
if ( Inspector.Tool.SelectedComponent.IsValid() )
{
var name = Inspector.Tool.SelectedComponent.GameObject.Name;
var textPos = selectedRect.TopRight + new Vector2( 8, 0 );
var textRect = new Rect( textPos, Paint.MeasureText( name ) );
var boxRect = textRect.Grow( 4, 2, 18, 2 );
var isHovering = Paint.HasMouseOver;
var boxCol = isHovering ? Theme.ControlBackground.Lighten( 0.3f ) : Theme.ControlBackground.Darken( 0.2f );
var color = isHovering ? Color.Lighten( 0.2f ) : Color;
Paint.SetBrushAndPen( boxCol, Color.Transparent );
Paint.DrawRect( boxRect );
Paint.SetPen( color );
var drawnRect = Paint.DrawText( textPos, name );
var iconPos = drawnRect.TopRight + new Vector2( 2, 0 );
Paint.DrawIcon( Rect.FromPoints( iconPos, iconPos + 14 ), "expand_more", 14 );
}
}
protected override void OnMouseClick ( MouseEvent e )
{
base.OnMouseClick( e );
var components = SceneEditorSession.Active.Scene.GetAllComponents<TilesetComponent>();
Log.Info( components.Count() );
if ( components.Count() == 0 ) return;
var menu = new Menu();
foreach ( var tileset in components )
{
var option = menu.AddOption( tileset.GameObject.Name, null, () =>
{
Inspector.Tool.SelectedComponent = tileset;
Inspector.Tool.SelectedLayer = tileset.Layers.FirstOrDefault();
} );
option.Checkable = true;
option.Checked = tileset == Inspector.Tool.SelectedComponent;
}
menu.OpenAtCursor();
}
}
}using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace SpriteTools.TilesetEditor;
[EditorForAssetType( "tileset" )]
[EditorApp( "Tileset Editor", "calendar_view_month", "Edit Tilesets" )]
public partial class MainWindow : DockWindow, IAssetEditor
{
internal static List<MainWindow> OpenWindows = new();
public bool CanOpenMultipleAssets => false;
private readonly UndoStack _undoStack = new();
public UndoStack UndoStack => _undoStack;
bool _dirty = true;
private Asset _asset;
public TilesetResource Tileset;
[Property]
public List<TilesetResource.Tile> SelectedTiles
{
get => inspector?.tileList?.Selected?.Select( x => x?.Tile )?.ToList() ?? new();
set
{
inspector.tileList.Selected.Clear();
foreach ( var tile in value )
{
var control = inspector.tileList.Buttons.FirstOrDefault( x => x.Tile == tile );
if ( control != null )
{
inspector.tileList.Selected.Add( control );
}
}
}
}
ToolBar toolBar;
internal Inspector.Inspector inspector;
internal Preview.Preview preview;
Option _undoMenuOption;
Option _redoMenuOption;
public MainWindow ()
{
DeleteOnClose = true;
Size = new Vector2( 1280, 720 );
Tileset = new TilesetResource();
SetWindowIcon( "emoji_emotions" );
RestoreDefaultDockLayout();
OpenWindows.Add( this );
}
public override void OnDestroyed ()
{
base.OnDestroyed();
OpenWindows.Remove( this );
}
public void AssetOpen ( Asset asset )
{
Open( "", asset );
Show();
}
public void SelectMember ( string memberName )
{
}
void UpdateWindowTitle ()
{
Title = ( $"{_asset?.Name ?? "Untitled Tileset"} - Tileset Editor" ) + ( _dirty ? "*" : "" );
}
public void RebuildUI ()
{
MenuBar.Clear();
{
var file = MenuBar.AddMenu( "File" );
file.AddOption( "New", "common/new.png", () => New(), "editor.new" ).StatusTip = "New Tileset";
file.AddOption( "Open", "common/open.png", () => Open(), "editor.open" ).StatusTip = "Open Tileset";
file.AddOption( "Save", "common/save.png", () => Save(), "editor.save" ).StatusTip = "Save Tileset";
file.AddOption( "Save As...", "common/save.png", () => Save( true ), "editor.save-as" ).StatusTip = "Save Tileset As...";
file.AddSeparator();
file.AddOption( new Option( "Exit" ) { Triggered = Close } );
}
{
var edit = MenuBar.AddMenu( "Edit" );
_undoMenuOption = edit.AddOption( "Undo", "undo", () => Undo(), "editor.undo" );
_redoMenuOption = edit.AddOption( "Redo", "redo", () => Redo(), "editor.redo" );
// edit.AddSeparator();
// edit.AddOption( "Cut", "common/cut.png", CutSelection, "Ctrl+X" );
// edit.AddOption( "Copy", "common/copy.png", CopySelection, "Ctrl+C" );
// edit.AddOption( "Paste", "common/paste.png", PasteSelection, "Ctrl+V" );
// edit.AddOption( "Select All", "select_all", SelectAll, "Ctrl+A" );
}
{
var view = MenuBar.AddMenu( "View" );
view.AboutToShow += () => OnViewMenu( view );
}
CreateToolBar();
}
private void OnViewMenu ( Menu view )
{
view.Clear();
view.AddOption( "Restore To Default", "settings_backup_restore", RestoreDefaultDockLayout );
view.AddSeparator();
foreach ( var dock in DockManager.DockTypes )
{
var o = view.AddOption( dock.Title, dock.Icon );
o.Checkable = true;
o.Checked = DockManager.IsDockOpen( dock.Title );
o.Toggled += ( b ) => DockManager.SetDockState( dock.Title, b );
}
}
protected override void RestoreDefaultDockLayout ()
{
inspector = new Inspector.Inspector( this );
preview = new Preview.Preview( this );
// Timeline = new Timeline.Timeline(this);
// var animationList = new AnimationList.AnimationList(this);
DockManager.Clear();
DockManager.RegisterDockType( "Inspector", "edit", () => inspector = new Inspector.Inspector( this ) );
DockManager.RegisterDockType( "Preview", "emoji_emotions", () => preview = new Preview.Preview( this ) );
// DockManager.RegisterDockType("Animations", "directions_walk", () => new AnimationList.AnimationList(this));
// DockManager.RegisterDockType("Timeline", "view_column", () =>
// {
// Timeline = new Timeline.Timeline(this);
// return Timeline;
// });
DockManager.AddDock( null, inspector, DockArea.Left, DockManager.DockProperty.HideOnClose );
DockManager.AddDock( null, preview, DockArea.Right, DockManager.DockProperty.HideOnClose, split: 0.8f );
// DockManager.AddDock(preview, Timeline, DockArea.Bottom, DockManager.DockProperty.HideOnClose, split: 0.2f);
// DockManager.AddDock(inspector, animationList, DockArea.Bottom, DockManager.DockProperty.HideOnClose, split: 0.45f);
DockManager.Update();
RebuildUI();
}
void InitInspector ()
{
inspector.segmentedControl.SelectedIndex = ( ( Tileset?.Tiles?.Count ?? 0 ) == 0 ) ? 0 : 1;
}
void UpdateEverything ()
{
UpdateWindowTitle();
inspector.UpdateControlSheet();
inspector.UpdateSelectedSheet();
preview.UpdateTexture( Tileset.FilePath );
}
[Shortcut( "editor.new", "CTRL+N", ShortcutType.Window )]
public void New ()
{
PromptSave( () => CreateNew() );
}
public void CreateNew ()
{
var savePath = GetSavePath( "New 2D Tileset" );
_asset = null;
Tileset = AssetSystem.CreateResource( "tileset", savePath ).LoadResource<TilesetResource>();
_dirty = false;
_undoStack.Clear();
InitInspector();
UpdateEverything();
}
[Shortcut( "editor.open", "CTRL+O", ShortcutType.Window )]
public void Open ()
{
var fd = new FileDialog( null )
{
Title = "Open 2D Tileset",
DefaultSuffix = ".tileset"
};
fd.SetNameFilter( "2D Tileset (*.tileset)" );
if ( !fd.Execute() ) return;
PromptSave( () => Open( fd.SelectedFile ) );
}
public void Open ( string path, Asset asset = null )
{
if ( !string.IsNullOrEmpty( path ) )
{
asset ??= AssetSystem.FindByPath( path );
}
if ( asset == null ) return;
if ( asset == _asset )
{
Focus();
return;
}
var tileset = asset.LoadResource<TilesetResource>();
if ( tileset == null )
{
Log.Warning( $"Failed to load tileset from {asset.RelativePath}" );
return;
}
StateCookie = "tileset-editor-window-" + tileset.ResourceId;
_asset = asset;
_dirty = false;
_undoStack.Clear();
Tileset = tileset;
var firstTile = Tileset.Tiles?.FirstOrDefault();
if ( firstTile is not null )
inspector?.tileList?.Selected?.Add( inspector.tileList.Buttons.FirstOrDefault( x => x.Tile == firstTile ) );
InitInspector();
UpdateEverything();
}
private void Restore ()
{
var path = _asset?.AbsolutePath;
if ( string.IsNullOrEmpty( path ) )
{
_dirty = false;
return;
}
var contents = File.ReadAllText( path );
ReloadFromString( contents );
_dirty = false;
}
[Shortcut( "editor.save", "CTRL+S", ShortcutType.Window )]
public bool Save ( bool saveAs = false )
{
var savePath = ( _asset == null || saveAs ) ? GetSavePath() : _asset.AbsolutePath;
if ( string.IsNullOrWhiteSpace( savePath ) ) return false;
if ( saveAs )
{
// If we're saving as, we want to register the new asset
_asset = null;
}
// Register the asset if we haven't already
_asset ??= AssetSystem.CreateResource( "tileset", savePath );
_asset?.SaveToDisk( Tileset );
_dirty = false;
UpdateWindowTitle();
if ( _asset == null )
{
Log.Warning( $"Failed to register asset at path {savePath}" );
return false;
}
MainAssetBrowser.Instance?.Local?.UpdateAssetList();
TileAtlas.ClearCache( Tileset );
return true;
}
[Shortcut( "editor.save-as", "CTRL+SHIFT+S", ShortcutType.Window )]
private void SaveAs ()
{
Save( true );
}
[EditorEvent.Frame]
void Frame ()
{
_undoOption.Enabled = _undoStack.CanUndo;
_redoOption.Enabled = _undoStack.CanRedo;
_undoMenuOption.Enabled = _undoStack.CanUndo;
_redoMenuOption.Enabled = _undoStack.CanRedo;
_undoOption.Text = _undoStack.UndoName ?? "Undo";
_redoOption.Text = _undoStack.RedoName ?? "Redo";
_undoMenuOption.Text = _undoStack.UndoName ?? "Undo";
_redoMenuOption.Text = _undoStack.RedoName ?? "Redo";
_undoOption.StatusTip = _undoStack.UndoName ?? "Undo";
_redoOption.StatusTip = _undoStack.RedoName ?? "Redo";
_undoMenuOption.StatusTip = _undoStack.UndoName ?? "Undo";
_redoMenuOption.StatusTip = _undoStack.RedoName ?? "Redo";
}
protected override bool OnClose ()
{
if ( _dirty )
{
var confirm = new PopupWindow(
"Save Current Tileset", "The open tileset has unsaved changes. Would you like to save now?", "Cancel",
new Dictionary<string, System.Action>()
{
{ "No", () => { Restore(); Close(); } },
{ "Yes", () => { Save(); Close(); } }
}
);
confirm.Show();
return false;
}
return true;
}
string GetSavePath ( string title = "Save Tileset" )
{
var lastDirectory = EditorCookie.GetString( "LastSaveTilesetLocation", "" );
var fd = new FileDialog( null )
{
Title = title,
Directory = lastDirectory,
DefaultSuffix = $".tileset"
};
fd.SelectFile( "untitled.tileset" );
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter( "2D Tileset (*.tileset)" );
if ( !fd.Execute() ) return null;
var selectedFile = fd.SelectedFile;
EditorCookie.SetString( "LastSaveTilesetLocation", System.IO.Path.GetDirectoryName( selectedFile ) );
return selectedFile;
}
internal void CreateTile ( int x, int y, bool add = false )
{
var tileName = $"Tile {x},{y}";
PushUndo( $"Create Tile \"{tileName}\"" );
var tile = new TilesetResource.Tile( new Vector2Int( x, y ), 1 );
Tileset.AddTile( tile );
if ( Tileset.Tiles.Count == 1 )
{
Tileset.CurrentTileSize = Tileset.TileSize;
Tileset.CurrentTextureSize = (Vector2Int)preview.TextureSize;
inspector.UpdateControlSheet();
}
else
{
var control = new TilesetTileControl( inspector.tileList, tile );
inspector.tileList.content.Add( control );
inspector.tileList.Buttons.Add( control );
}
SelectTile( tile, add );
PushRedo();
SetDirty();
}
internal void SelectTile ( TilesetResource.Tile tile, bool add = false )
{
var btn = inspector.tileList.Buttons.FirstOrDefault( x => x.Tile == tile );
if ( add )
{
if ( inspector.tileList.Selected.Contains( btn ) )
inspector.tileList.Selected.Remove( btn );
else
inspector.tileList.Selected.Add( btn );
}
else
{
inspector.tileList.Selected.Clear();
inspector.tileList.Selected.Add( btn );
}
inspector.UpdateSelectedSheet();
}
internal void DeleteTile ( TilesetResource.Tile tile )
{
var tileName = tile.Name;
if ( string.IsNullOrEmpty( tileName ) ) tileName = $"Tile {tile.Position}";
PushUndo( $"Delete Tile \"{tileName}\"" );
bool isSelected = inspector.tileList.Selected.Any( x => x.Tile == tile );
Tileset.RemoveTile( tile );
if ( isSelected ) SelectTile( Tileset.Tiles?.FirstOrDefault() ?? null );
PushRedo();
if ( Tileset.Tiles.Count == 0 )
{
inspector.UpdateControlSheet();
}
else
{
var btns = inspector.tileList.Buttons.ToList();
foreach ( var btn in btns )
{
if ( btn.Tile == tile )
{
inspector.tileList.Buttons.Remove( btn );
btn.Destroy();
}
}
}
SetDirty();
}
internal void GenerateTiles ()
{
if ( Tileset is null ) return;
PushUndo( "Generate Tiles" );
foreach ( var tile in Tileset.Tiles.ToList() )
{
Tileset.RemoveTile( tile );
}
Tileset.CurrentTileSize = Tileset.TileSize;
Tileset.CurrentTextureSize = (Vector2Int)preview.TextureSize;
int x = 0;
int y = 0;
int framesPerRow = (int)preview.TextureSize.x / Tileset.TileSize.x;
int framesPerHeight = (int)preview.TextureSize.y / Tileset.TileSize.y;
var pixels = Texture.LoadFromFileSystem( Tileset.FilePath, Editor.FileSystem.Mounted ).GetPixels();
while ( y < framesPerHeight )
{
while ( x < framesPerRow )
{
var hasPixel = false;
for ( var xx = 0; xx < Tileset.TileSize.x; xx++ )
{
for ( var yy = 0; yy < Tileset.TileSize.y; yy++ )
{
var tx = x * Tileset.TileSize.x + xx;
var ty = y * Tileset.TileSize.y + yy;
int pixelIndex = (int)( ty * preview.TextureSize.x + tx );
if ( pixels[pixelIndex].a > 0 )
{
hasPixel = true;
break;
}
}
if ( hasPixel ) break;
}
if ( hasPixel )
{
Tileset.AddTile( new TilesetResource.Tile( new Vector2Int( x, y ), 1 ) );
}
x++;
}
x = 0;
y++;
}
PushRedo();
SetDirty();
UpdateEverything();
}
internal void DeleteAllTiles ()
{
if ( Tileset is null ) return;
PushUndo( "Delete All Tiles" );
Tileset.Tiles ??= new List<TilesetResource.Tile>();
Tileset.Tiles?.Clear();
PushRedo();
SetDirty();
UpdateEverything();
}
void PromptSave ( Action action )
{
if ( !_dirty )
{
action?.Invoke();
return;
}
var confirm = new PopupWindow(
"Save Current Tileset", "The open tileset has unsaved changes. Would you like to save before continuing?", "Cancel",
new Dictionary<string, Action>
{
{ "No", () => {
action?.Invoke();
} },
{ "Yes", () => {
if (Save()) action?.Invoke();
}}
} );
confirm.Show();
}
internal void SetDirty ()
{
_dirty = true;
UpdateWindowTitle();
}
public void PushUndo ( string name, string buffer = "" )
{
if ( string.IsNullOrEmpty( buffer ) ) buffer = Tileset.SerializeString();
_undoStack.PushUndo( name, buffer );
}
public void PushRedo ()
{
_undoStack.PushRedo( Tileset.SerializeString() );
SetDirty();
}
[Shortcut( "editor.undo", "CTRL+Z", ShortcutType.Window )]
public void Undo ()
{
if ( _undoStack.Undo() is UndoOp op )
{
ReloadFromString( op.undoBuffer );
Sound.Play( "ui.navigate.back" );
}
else
{
Sound.Play( "ui.navigate.deny" );
}
}
private void SetUndoLevel ( int level )
{
if ( _undoStack.SetUndoLevel( level ) is UndoOp op )
{
ReloadFromString( op.undoBuffer );
}
}
[Shortcut( "editor.redo", "CTRL+Y", ShortcutType.Window )]
public void Redo ()
{
if ( _undoStack.Redo() is UndoOp op )
{
ReloadFromString( op.redoBuffer );
Sound.Play( "ui.navigate.forward" );
}
else
{
Sound.Play( "ui.navigate.deny" );
}
}
internal void ReloadFromString ( string buffer )
{
Tileset.DeserializeString( buffer );
SetDirty();
UpdateEverything();
}
private Option _undoOption;
private Option _redoOption;
private void CreateToolBar ()
{
toolBar?.Destroy();
toolBar = new ToolBar( this, "TilesetEditorToolbar" );
AddToolBar( toolBar, ToolbarPosition.Top );
toolBar.AddOption( "New", "common/new.png", New ).StatusTip = "New Tileset";
toolBar.AddOption( "Open", "common/open.png", Open ).StatusTip = "Open Tileset";
toolBar.AddOption( "Save", "common/save.png", () => Save() ).StatusTip = "Save Tileset";
toolBar.AddSeparator();
_undoOption = toolBar.AddOption( "Undo", "undo", Undo );
_redoOption = toolBar.AddOption( "Redo", "redo", Redo );
toolBar.AddSeparator();
toolBar.AddSeparator();
_undoOption.Enabled = false;
_redoOption.Enabled = false;
}
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SpriteTools.TilesetTool.Tools;
/// <summary>
/// Used to paint tiles on the selected layer.
/// </summary>
[Title( "Paint" )]
[Icon( "brush" )]
[Alias( "tileset-tools.paint-tool" )]
[Group( "1" )]
[Order( 0 )]
public class PaintTileTool : BaseTileTool
{
public PaintTileTool ( TilesetTool parent ) : base( parent ) { }
/// <summary>
/// The size of the Brush when Painting.
/// </summary>
[Group( "Paint Tool" ), Property, Range( 1, 12 ), Step( 1 )]
public int BrushSize
{
get => _brushSize;
set
{
_brushSize = value;
lastTilePos = -999999;
}
}
private int _brushSize = 1;
/// <summary>
/// Whether the Brush is round or square.
/// </summary>
[Group( "Paint Tool" ), Property]
public bool IsRound
{
get => _isRound;
set
{
_isRound = value;
lastTilePos = -999999;
}
}
private bool _isRound = false;
/// <summary>
/// If enabled, Autotiles of different types will attempt to connect with each other.
/// </summary>
[Group( "Paint Tool" ), Property, ShowIf( nameof( this.CanSeeAutotileSettings ), true )]
public bool MergeDifferentAutotiles
{
get => ShouldMergeAutotiles;
set
{
ShouldMergeAutotiles = value;
}
}
private bool CanSeeAutotileSettings => AutotileWidget.Instance?.Brush is not null;
Vector2Int lastTilePos;
bool isPainting = false;
public override void OnUpdate ()
{
if ( !CanUseTool() ) return;
if ( Parent.SelectedComponent.Transform is null ) return;
var pos = GetGizmoPos();
var tile = Parent.SelectedTile;
var tilePos = (Vector2Int)( ( pos - Parent.SelectedComponent.WorldPosition ) / Parent.SelectedLayer.TilesetResource.GetTileSize() );
Parent._sceneObject.Transform = new Transform( pos, Rotation.Identity, 1 );
Parent._sceneObject.RenderingEnabled = true;
if ( tilePos != lastTilePos )
{
UpdateTilePositions();
}
if ( Gizmo.IsLeftMouseDown )
{
if ( _componentUndoScope is null )
{
_componentUndoScope = SceneEditorSession.Active.UndoScope( "Paint Tile" ).WithComponentChanges( Parent.SelectedComponent ).Push();
}
var brush = AutotileBrush;
if ( brush is not null )
{
Place( tilePos );
}
else if ( tile.Size.x > 1 || tile.Size.y > 1 )
{
for ( int x = 0; x < tile.Size.x; x++ )
{
var ux = x;
var xx = x;
if ( Parent.Settings.HorizontalFlip ) ux = tile.Size.x - x - 1;
for ( int y = 0; y < tile.Size.y; y++ )
{
var uy = y;
var yy = -y;
var offsetPos = new Vector2Int( xx, yy );
if ( Parent.Settings.Angle == 90 )
offsetPos = new Vector2Int( -offsetPos.y, offsetPos.x );
else if ( Parent.Settings.Angle == 180 )
offsetPos = new Vector2Int( -offsetPos.x, -offsetPos.y );
else if ( Parent.Settings.Angle == 270 )
offsetPos = new Vector2Int( offsetPos.y, -offsetPos.x );
Parent.PlaceTile( tilePos + offsetPos, tile.Id, new Vector2Int( ux, uy ), false );
}
}
Parent.SelectedComponent.IsDirty = true;
}
else
{
Place( tilePos );
}
isPainting = true;
}
else if ( isPainting )
{
_componentUndoScope?.Dispose();
_componentUndoScope = null;
isPainting = false;
}
// if (Parent?.SelectedLayer?.AutoTilePositions is not null)
// {
// var tileSize = Parent.SelectedLayer.TilesetResource.GetTileSize();
// using (Gizmo.Scope("test", Transform.Zero))
// {
// Gizmo.Draw.Color = Color.Red.WithAlpha(0.1f);
// foreach (var group in Parent.SelectedLayer.AutoTilePositions)
// {
// var brush = group.Key;
// foreach (var position in group.Value)
// {
// Gizmo.Draw.WorldText(Parent.SelectedLayer.GetAutotileBitmask(brush, position).ToString(),
// new Transform(
// Parent.SelectedComponent.WorldPosition + (Vector3)((Vector2)position * tileSize) + (Vector3)(tileSize * 0.5f) + Vector3.Up * 200,
// Rotation.Identity,
// 0.3f
// ),
// "Poppins", 24
// );
// }
// }
// }
// }
}
void UpdateTilePositions ()
{
var pos = GetGizmoPos();
var brush = AutotileBrush;
var tile = Parent.SelectedTile;
if ( tile is null ) return;
var tilePos = (Vector2Int)( ( pos - Parent.SelectedComponent.WorldPosition ) / Parent.SelectedLayer.TilesetResource.GetTileSize() );
List<(Vector2Int, Vector2Int)> positions = new();
if ( brush is null && ( tile.Size.x > 1 || tile.Size.y > 1 ) )
{
for ( int i = 0; i < tile.Size.x; i++ )
{
for ( int j = 0; j < tile.Size.y; j++ )
{
positions.Add( (new Vector2Int( i, -j ), tile.Position + new Vector2Int( i, j )) );
}
}
}
else if ( IsRound )
{
var size = ( BrushSize - 0.9f ) * 2;
var center = new Vector2Int( (int)( size / 2f ), (int)( size / 2f ) );
for ( int i = 0; i < size * 2; i++ )
{
for ( int j = 0; j < size * 2; j++ )
{
var offset = new Vector2Int( i, j ) - center;
if ( offset.LengthSquared <= ( size / 2 ) * ( size / 2 ) )
{
positions.Add( (offset, tile.Position) );
}
}
}
}
else
{
Vector2Int startPos = new Vector2Int( -BrushSize / 2, -BrushSize / 2 );
for ( int i = 0; i < BrushSize; i++ )
{
for ( int j = 0; j < BrushSize; j++ )
{
positions.Add( (new Vector2Int( i, j ) + startPos, tile.Position) );
}
}
}
// Set autobrush tiles if necessary
if ( brush is not null )
{
if ( brush.AutotileType == AutotileType.Bitmask2x2Edge )
{
List<Vector2Int> tilesToAdd = new();
foreach ( var ppos in positions )
{
bool touchingX = false;
bool touchingY = false;
var up = ppos.Item1.WithY( ppos.Item1.y + 1 );
var down = ppos.Item1.WithY( ppos.Item1.y - 1 );
var left = ppos.Item1.WithX( ppos.Item1.x - 1 );
var right = ppos.Item1.WithX( ppos.Item1.x + 1 );
foreach ( var ppos2 in positions )
{
if ( !touchingX && ( ppos2.Item1 == left || ppos2.Item1 == right ) )
{
touchingX = true;
}
if ( !touchingY && ( ppos2.Item1 == up || ppos2.Item1 == down ) )
{
touchingY = true;
}
if ( touchingX && touchingY ) break;
}
if ( touchingX && touchingY ) continue;
var upLeft = up.WithX( left.x );
var upRight = up.WithX( right.x );
var downLeft = down.WithX( left.x );
var downRight = down.WithX( right.x );
if ( !tilesToAdd.Contains( up ) ) tilesToAdd.Add( up );
if ( !tilesToAdd.Contains( down ) ) tilesToAdd.Add( down );
if ( !tilesToAdd.Contains( left ) ) tilesToAdd.Add( left );
if ( !tilesToAdd.Contains( right ) ) tilesToAdd.Add( right );
if ( !tilesToAdd.Contains( upLeft ) ) tilesToAdd.Add( upLeft );
if ( !tilesToAdd.Contains( upRight ) ) tilesToAdd.Add( upRight );
if ( !tilesToAdd.Contains( downLeft ) ) tilesToAdd.Add( downLeft );
if ( !tilesToAdd.Contains( downRight ) ) tilesToAdd.Add( downRight );
}
foreach ( var toAddPos in tilesToAdd )
{
if ( !positions.Contains( (toAddPos, tile.Position) ) )
positions.Add( (toAddPos, tile.Position) );
}
}
}
UpdateTilePositions( positions.Select( x => (Vector2)x.Item1 ).ToList() );
lastTilePos = tilePos;
}
void Place ( Vector2Int tilePos )
{
var brush = AutotileBrush;
var tile = Parent.SelectedTile;
foreach ( var position in Parent._sceneObject.MultiTilePositions )
{
if ( brush is null )
{
Parent.PlaceTile( tilePos + position.Item1, tile.Id, 0 );
}
else
{
Parent.PlaceAutotile( ( position.Item3 == Guid.Empty ) ? brush.Id : position.Item3, tilePos + position.Item1, false );
}
}
if ( brush is not null )
{
foreach ( var position in Parent._sceneObject.MultiTilePositions )
{
var brushId = ( position.Item3 == Guid.Empty ) ? brush.Id : position.Item3;
Parent.SelectedLayer.UpdateAutotile( brushId, tilePos + position.Item1, false, shouldMerge: MergeDifferentAutotiles );
}
}
return;
}
[Shortcut( "tileset-tools.paint-tool", "b", typeof( SceneViewportWidget ) )]
public static void ActivateSubTool ()
{
if ( EditorToolManager.CurrentModeName != nameof( TilesetTool ) ) return;
EditorToolManager.SetSubTool( nameof( PaintTileTool ) );
}
}using Editor;
using Sandbox;
using System;
using System.Linq;
namespace SpriteTools.SpriteEditor.Preview;
public class Preview : Widget
{
public MainWindow MainWindow { get; }
private readonly RenderingWidget Rendering;
Widget Overlay;
WidgetWindow overlayWindowZoom;
WidgetWindow overlayWindowPoint;
Vector2 attachmentCreatePosition;
public Preview ( MainWindow mainWindow ) : base( null )
{
MainWindow = mainWindow;
Name = "Preview";
WindowTitle = "Preview";
SetWindowIcon( "emoji_emotions" );
MinimumSize = new Vector2( 256, 256 );
Layout = Layout.Column();
Rendering = new RenderingWidget( MainWindow, this );
Layout.Add( Rendering );
Overlay = new Widget( this )
{
Layout = Layout.Row(),
TranslucentBackground = true,
NoSystemBackground = true,
WindowFlags = WindowFlags.FramelessWindowHint | WindowFlags.Tool
};
overlayWindowZoom = new WidgetWindow( this );
overlayWindowZoom.Parent = Overlay;
overlayWindowZoom.Layout = Layout.Row();
overlayWindowZoom.Layout.Spacing = 4;
overlayWindowZoom.Layout.Margin = 4;
var btnZoomOut = overlayWindowZoom.Layout.Add( new IconButton( "zoom_out" ) );
btnZoomOut.OnClick = () =>
{
Rendering.Zoom( -250 );
};
btnZoomOut.ToolTip = "Zoom Out";
btnZoomOut.StatusTip = "Zoom Out View";
var btnZoomIn = overlayWindowZoom.Layout.Add( new IconButton( "zoom_in" ) );
btnZoomIn.OnClick = () =>
{
Rendering.Zoom( 250 );
};
btnZoomIn.ToolTip = "Zoom In";
btnZoomIn.StatusTip = "Zoom In View";
overlayWindowZoom.Layout.AddSeparator();
var btnFit = overlayWindowZoom.Layout.Add( new IconButton( "zoom_out_map" ) );
btnFit.OnClick = () =>
{
Rendering.Fit();
};
btnFit.ToolTip = "Fit to Screen";
btnFit.StatusTip = "Fit View to Screen";
overlayWindowZoom.WindowTitle = "Zoom Controls";
Overlay.Layout.Add( overlayWindowZoom );
overlayWindowPoint = new WidgetWindow( this );
overlayWindowPoint.Parent = Overlay;
overlayWindowPoint.Layout = Layout.Column();
overlayWindowPoint.Layout.Margin = 4;
overlayWindowPoint.WindowTitle = "Point Controls";
var row1 = overlayWindowPoint.Layout.AddRow();
var btnTopLeft = row1.Add( new TextureModifyButton( this, "Align Top-Left", "Images/grid-align-top-left.png", () => SetOrigin( new Vector2( 0, 0f ) ) ) );
var btnTopMiddle = row1.Add( new TextureModifyButton( this, "Align Top-Center", "Images/grid-align-top-center.png", () => SetOrigin( new Vector2( 0.5f, 0f ) ) ) );
var btnTopRight = row1.Add( new TextureModifyButton( this, "Align Top-Right", "Images/grid-align-top-right.png", () => SetOrigin( new Vector2( 1f, 0f ) ) ) );
var row2 = overlayWindowPoint.Layout.AddRow();
var btnMiddleLeft = row2.Add( new TextureModifyButton( this, "Align Middle-Left", "Images/grid-align-middle-left.png", () => SetOrigin( new Vector2( 0f, 0.5f ) ) ) );
var btnMiddleCenter = row2.Add( new TextureModifyButton( this, "Align Middle-Center", "Images/grid-align-middle-center.png", () => SetOrigin( new Vector2( 0.5f, 0.5f ) ) ) );
var btnMiddleRight = row2.Add( new TextureModifyButton( this, "Align Middle-Right", "Images/grid-align-middle-right.png", () => SetOrigin( new Vector2( 1f, 0.5f ) ) ) );
var row3 = overlayWindowPoint.Layout.AddRow();
var btnBottomLeft = row3.Add( new TextureModifyButton( this, "Align Bottom-Left", "Images/grid-align-bottom-left.png", () => SetOrigin( new Vector2( 0, 1f ) ) ) );
var btnBottomCenter = row3.Add( new TextureModifyButton( this, "Align Bottom-Center", "Images/grid-align-bottom-center.png", () => SetOrigin( new Vector2( 0.5f, 1f ) ) ) );
var btnBottomRight = row3.Add( new TextureModifyButton( this, "Align Bottom-Right", "Images/grid-align-bottom-right.png", () => SetOrigin( new Vector2( 1f, 1f ) ) ) );
Overlay.Layout.Add( overlayWindowPoint );
Overlay.Show();
UpdateTexture();
SetSizeMode( SizeMode.Default, SizeMode.CanShrink );
MainWindow.OnTextureUpdate += UpdateTexture;
MainWindow.Moved += DoLayout;
}
public override void OnDestroyed ()
{
base.OnDestroyed();
MainWindow.OnTextureUpdate -= UpdateTexture;
MainWindow.Moved -= DoLayout;
}
void SetOrigin ( Vector2 origin )
{
if ( MainWindow.SelectedAnimation is null ) return;
MainWindow.SelectedAnimation.Origin = origin;
}
void UpdateTexture ()
{
if ( MainWindow.Sprite is null ) return;
if ( MainWindow.SelectedAnimation is null ) return;
if ( MainWindow.SelectedAnimation.Frames.Count <= 0 ) return;
if ( MainWindow.CurrentFrameIndex < 0 ) return;
if ( MainWindow.CurrentFrameIndex >= MainWindow.SelectedAnimation.Frames.Count ) return;
var frame = MainWindow.SelectedAnimation.Frames[MainWindow.CurrentFrameIndex];
if ( frame is null ) return;
if ( !Sandbox.FileSystem.Mounted.FileExists( frame.FilePath ) ) return;
var texture = Texture.LoadFromFileSystem( frame.FilePath, Sandbox.FileSystem.Mounted );
if ( texture is null ) return;
Rendering.SetTexture( texture, frame.SpriteSheetRect );
}
protected override void DoLayout ()
{
base.DoLayout();
if ( Overlay.IsValid() && Rendering.IsValid() )
{
Overlay.Position = Rendering.ScreenPosition;
Overlay.Size = Rendering.Size + 1;
if ( overlayWindowPoint.Visible )
{
overlayWindowPoint.AdjustSize();
overlayWindowPoint.AlignToParent( TextFlag.LeftTop, 4 );
}
overlayWindowZoom.AdjustSize();
overlayWindowZoom.AlignToParent( TextFlag.RightTop, 4 );
}
}
protected override void OnVisibilityChanged ( bool visible )
{
base.OnVisibilityChanged( visible );
if ( Overlay is not null )
{
Overlay.Visible = visible;
}
}
protected override void OnKeyRelease ( KeyEvent e )
{
base.OnKeyRelease( e );
if ( e.Key == KeyCode.Left )
{
MainWindow.FramePrevious();
}
else if ( e.Key == KeyCode.Right )
{
MainWindow.FrameNext();
}
}
void CreateAttachmentPopup ()
{
var popup = new PopupWidget( MainWindow );
popup.Layout = Layout.Column();
popup.Layout.Margin = 16;
popup.Layout.Spacing = 8;
popup.Layout.Add( new Label( $"What would you like to name the attachment point?" ) );
var entry = new LineEdit( popup );
var button = new Button.Primary( "Create" );
button.MouseClick = () =>
{
if ( !string.IsNullOrEmpty( entry.Text ) && !MainWindow.SelectedAnimation.Attachments.Any( a => a.Name.ToLowerInvariant() == entry.Text.ToLowerInvariant() ) )
{
CreateAttachment( entry.Text );
}
else
{
ShowNamingError( entry.Text );
}
popup.Visible = false;
};
entry.ReturnPressed += button.MouseClick;
popup.Layout.Add( entry );
var bottomBar = popup.Layout.AddRow();
bottomBar.AddStretchCell();
bottomBar.Add( button );
popup.Position = Editor.Application.CursorPosition;
popup.Visible = true;
entry.Focus();
}
void CreateAttachment ( string name )
{
MainWindow.PushUndo( "Add Attachment Point " + name );
var tr = Rendering.Scene.Trace.Ray( Rendering.Camera.ScreenPixelToRay( attachmentCreatePosition ), 5000f ).Run();
var pos = tr.EndPosition.WithZ( 0f );
var attachPos = new Vector2( pos.y, pos.x );
attachPos = ( attachPos / 100f ) + ( Vector2.One * 0.5f );
var attachment = new SpriteAttachment( name );
attachment.Points.Add( attachPos );
MainWindow.SelectedAnimation.Attachments.Add( attachment );
// MainWindow.SelectedAnimation.Frames[MainWindow.CurrentFrameIndex].AttachmentPoints[name] = attachPos;
MainWindow.PushRedo();
}
static void ShowNamingError ( string name )
{
if ( string.IsNullOrWhiteSpace( name ) )
{
var confirm = new PopupWindow( "Invalid name ''", "You cannot give an attachment point an empty name", "OK" );
confirm.Show();
return;
}
var confirm2 = new PopupWindow( "Invalid name", $"An attachment point named '{name}' already exists", "OK" );
confirm2.Show();
}
protected override void OnContextMenu ( ContextMenuEvent e )
{
base.OnContextMenu( e );
attachmentCreatePosition = e.LocalPosition;
var m = new Menu( this );
m.AddOption( "Add Attach Point", "push_pin", CreateAttachmentPopup );
m.OpenAtCursor( false );
}
private class TextureModifyButton : Widget
{
private readonly string Icon;
private Pixmap pixmap;
public TextureModifyButton ( Widget parent, string tooltip, string icon, Action onClick ) : base( parent )
{
Icon = icon;
ToolTip = tooltip;
FixedSize = 26;
Cursor = CursorShape.Finger;
MouseClick = onClick;
var fullPath = Editor.FileSystem.Content.GetFullPath( Icon );
if ( fullPath is not null )
{
pixmap = Pixmap.FromFile( fullPath );
}
}
protected override void OnPaint ()
{
base.OnPaint();
if ( Paint.HasMouseOver )
{
Paint.ClearPen();
var bg = Theme.ControlBackground.Lighten( 0.3f );
Paint.SetBrush( bg );
Paint.DrawRect( LocalRect, Theme.ControlRadius );
}
if ( pixmap is not null )
{
Paint.Draw( LocalRect.Shrink( 5 ), pixmap );
}
}
}
}#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Sandbox.git.models;
namespace Sandbox.git;
/// <summary>
/// Branch-related git operations (create, list, rename, delete, query).
/// </summary>
public static class Branch {
/// <summary>
/// Create a new branch from the given start point.
/// </summary>
/// <param name="repository">The repository in which to create the new branch.</param>
/// <param name="name">The name of the new branch.</param>
/// <param name="startPoint">A committish string that the new branch should be based on, or null if the branch should be created from the current HEAD.</param>
/// <param name="noTrack">If true, do not set up tracking (e.g. when branching from a remote branch).</param>
public static async Task CreateBranchAsync(
Repository repository,
string name,
string? startPoint,
bool noTrack = false) {
if ( repository == null )
throw new ArgumentNullException(nameof(repository));
if ( string.IsNullOrEmpty(name) )
throw new ArgumentException("Branch name is required.", nameof(name));
var args = GetCreateBranchArgs(name, startPoint, noTrack);
await Core.GitAsync(args, repository.Path, "createBranch").ConfigureAwait(false);
}
/// <summary>
/// Gets the short names of all local branches.
/// </summary>
public static async Task<string[]> GetBranchNamesAsync(Repository repository) {
if ( repository == null )
throw new ArgumentNullException(nameof(repository));
var result = await Core.GitAsync(
GetBranchNamesArgs(),
repository.Path,
"getBranchNames").ConfigureAwait(false);
return ParseBranchLines(result.Stdout)
.Select(line => line.Trim())
.Where(s => s.Length > 0)
.ToArray();
}
/// <summary>
/// Rename the given branch to a new name.
/// </summary>
/// <param name="repository">The repository.</param>
/// <param name="branch">The branch to rename.</param>
/// <param name="newName">The new name.</param>
/// <param name="force">If true, use -M to force rename (e.g. case-only renames on case-insensitive filesystems).</param>
public static async Task RenameBranchAsync(
Repository repository,
models.Branch branch,
string newName,
bool? force = null) {
if ( repository == null )
throw new ArgumentNullException(nameof(repository));
if ( branch == null )
throw new ArgumentNullException(nameof(branch));
if ( string.IsNullOrEmpty(newName) )
throw new ArgumentException("New branch name is required.", nameof(newName));
try {
await Core.GitAsync(
GetRenameBranchArgs(branch.NameWithoutRemote, newName, force),
repository.Path,
"renameBranch").ConfigureAwait(false);
} catch ( GitException ex ) {
// If we failed and the branch name only differs by case, retry with -M (see Desktop #21320).
if ( force != null )
throw;
if ( !IsBranchAlreadyExistsError(ex.Result) )
throw;
var m = Regex.Match(ex.Result.Stderr, @"fatal: a branch named '(.+?)' already exists");
if ( !m.Success || !string.Equals(m.Groups[1].Value, newName, StringComparison.OrdinalIgnoreCase) )
throw;
var names = await GetBranchNamesAsync(repository).ConfigureAwait(false);
if ( names.Contains(newName) )
throw;
await RenameBranchAsync(repository, branch, newName, true).ConfigureAwait(false);
}
}
/// <summary>
/// Delete the branch locally (force delete with -D).
/// </summary>
public static async Task DeleteLocalBranchAsync(Repository repository, string branchName) {
if ( repository == null )
throw new ArgumentNullException(nameof(repository));
if ( string.IsNullOrEmpty(branchName) )
throw new ArgumentException("Branch name is required.", nameof(branchName));
await Core.GitAsync(
GetDeleteLocalBranchArgs(branchName),
repository.Path,
"deleteLocalBranch").ConfigureAwait(false);
}
/// <summary>
/// Deletes a remote branch (push :ref to remote). If the remote ref was already deleted, removes the local remote-tracking ref.
/// </summary>
/// <param name="repository">The repository.</param>
/// <param name="remote">The remote (e.g. origin).</param>
/// <param name="remoteBranchName">The name of the branch on the remote.</param>
public static async Task DeleteRemoteBranchAsync(
Repository repository,
IRemote remote,
string remoteBranchName) {
if ( repository == null )
throw new ArgumentNullException(nameof(repository));
if ( remote == null )
throw new ArgumentNullException(nameof(remote));
if ( string.IsNullOrEmpty(remoteBranchName) )
throw new ArgumentException("Remote branch name is required.", nameof(remoteBranchName));
var args = GetDeleteRemoteBranchArgs(remote.Name, remoteBranchName);
var result = await Core.GitAsync(
args,
repository.Path,
"deleteRemoteBranch",
successExitCodes: new HashSet<int> { 0, 1 }).ConfigureAwait(false);
// If push failed (e.g. ref already deleted on remote), remove our local remote-tracking ref.
if ( result.ExitCode != 0 ) {
var refName = $"refs/remotes/{remote.Name}/{remoteBranchName}";
await DeleteRefAsync(repository, refName).ConfigureAwait(false);
}
}
/// <summary>
/// Finds branches whose tip equals the given committish (sha, HEAD, etc.).
/// </summary>
/// <returns>List of branch short names, or null if the committish could not be resolved or was malformed.</returns>
public static async Task<string[]?> GetBranchesPointedAtAsync(Repository repository, string commitish) {
if ( repository == null )
throw new ArgumentNullException(nameof(repository));
if ( string.IsNullOrEmpty(commitish) )
throw new ArgumentException("Committish is required.", nameof(commitish));
var result = await Core.GitAsync(
GetBranchesPointedAtArgs(commitish),
repository.Path,
"branchPointedAt",
successExitCodes: new HashSet<int> { 0, 1, 129 }).ConfigureAwait(false);
if ( result.ExitCode == 1 || result.ExitCode == 129 )
return null;
var lines = result.Stdout.Split('\n');
return lines.Length > 0 && string.IsNullOrEmpty(lines[lines.Length - 1])
? lines.Take(lines.Length - 1).ToArray()
: lines;
}
/// <summary>
/// Gets all branches that have been merged into the given branch.
/// </summary>
/// <param name="repository">The repository.</param>
/// <param name="branchName">The base branch name (e.g. main).</param>
/// <returns>Map of branch canonical ref to its tip sha (excluding the base branch itself).</returns>
public static async Task<Dictionary<string, string>> GetMergedBranchesAsync(
Repository repository,
string branchName) {
if ( repository == null )
throw new ArgumentNullException(nameof(repository));
if ( string.IsNullOrEmpty(branchName) )
throw new ArgumentException("Branch name is required.", nameof(branchName));
var canonicalBranchRef = FormatAsLocalRef(branchName);
var result = await Core.GitAsync(
GetMergedBranchesArgs(branchName),
repository.Path,
"mergedBranches").ConfigureAwait(false);
var merged = new Dictionary<string, string>(StringComparer.Ordinal);
foreach ( var line in ParseBranchLines(result.Stdout) ) {
var trimmed = line.Trim();
if ( trimmed.Length == 0 )
continue;
var firstSpace = trimmed.IndexOf(' ');
if ( firstSpace <= 0 )
continue;
var sha = trimmed.Substring(0, firstSpace);
var canonicalRef = trimmed.Substring(firstSpace + 1).Trim();
if ( canonicalRef == canonicalBranchRef )
continue;
merged[canonicalRef] = sha;
}
return merged;
}
// --- Helpers ---
/// <summary>
/// Canonical local branch ref (e.g. refs/heads/main).
/// </summary>
public static string FormatAsLocalRef(string branchName) {
if ( string.IsNullOrEmpty(branchName) )
throw new ArgumentException("Branch name is required.", nameof(branchName));
return "refs/heads/" + branchName;
}
static async Task DeleteRefAsync(Repository repository, string refName) {
await Core.GitAsync(
GetDeleteRefArgs(refName),
repository.Path,
"deleteRef",
successExitCodes: new HashSet<int> { 0, 1 }).ConfigureAwait(false);
}
// --- Args (exposed for testing) ---
/// <summary>Builds git arguments for creating a branch. Exposed for testing.</summary>
public static string[] GetCreateBranchArgs(string name, string? startPoint, bool noTrack = false) {
if ( string.IsNullOrEmpty(name) )
throw new ArgumentException("Branch name is required.", nameof(name));
var args = startPoint != null
? new[] { "branch", name, startPoint }
: new[] { "branch", name };
if ( noTrack )
args = args.Concat(new[] { "--no-track" }).ToArray();
return args;
}
/// <summary>Builds git arguments for listing branch names. Exposed for testing.</summary>
public static string[] GetBranchNamesArgs() {
return new[] { "branch", "--format=%(refname:short)" };
}
/// <summary>Builds git arguments for renaming a branch. Exposed for testing.</summary>
public static string[] GetRenameBranchArgs(string nameWithoutRemote, string newName, bool? force = null) {
if ( string.IsNullOrEmpty(nameWithoutRemote) )
throw new ArgumentException("Branch name is required.", nameof(nameWithoutRemote));
if ( string.IsNullOrEmpty(newName) )
throw new ArgumentException("New branch name is required.", nameof(newName));
return new[] { "branch", force == true ? "-M" : "-m", nameWithoutRemote, newName };
}
/// <summary>Builds git arguments for deleting a local branch. Exposed for testing.</summary>
public static string[] GetDeleteLocalBranchArgs(string branchName) {
if ( string.IsNullOrEmpty(branchName) )
throw new ArgumentException("Branch name is required.", nameof(branchName));
return new[] { "branch", "-D", branchName };
}
/// <summary>Builds git arguments for deleting a remote branch (push :ref). Exposed for testing.</summary>
public static string[] GetDeleteRemoteBranchArgs(string remoteName, string remoteBranchName) {
if ( string.IsNullOrEmpty(remoteName) )
throw new ArgumentException("Remote name is required.", nameof(remoteName));
if ( string.IsNullOrEmpty(remoteBranchName) )
throw new ArgumentException("Remote branch name is required.", nameof(remoteBranchName));
return new[] { "push", remoteName, $":{remoteBranchName}" };
}
/// <summary>Builds git arguments for listing branches pointed at a committish. Exposed for testing.</summary>
public static string[] GetBranchesPointedAtArgs(string commitish) {
if ( string.IsNullOrEmpty(commitish) )
throw new ArgumentException("Committish is required.", nameof(commitish));
return new[] { "branch", $"--points-at={commitish}", "--format=%(refname:short)" };
}
/// <summary>Builds git arguments for listing merged branches. Exposed for testing.</summary>
public static string[] GetMergedBranchesArgs(string branchName) {
if ( string.IsNullOrEmpty(branchName) )
throw new ArgumentException("Branch name is required.", nameof(branchName));
return new[] { "branch", "--format=%(objectname) %(refname)", "--merged", branchName };
}
/// <summary>Builds git arguments for deleting a ref. Exposed for testing.</summary>
public static string[] GetDeleteRefArgs(string refName) {
if ( string.IsNullOrEmpty(refName) )
throw new ArgumentException("Ref name is required.", nameof(refName));
return new[] { "update-ref", "-d", refName };
}
static bool IsBranchAlreadyExistsError(GitResult result) {
return result.ExitCode != 0
&& (result.Stderr?.Contains("a branch named ", StringComparison.OrdinalIgnoreCase) == true
&& result.Stderr?.Contains(" already exists", StringComparison.OrdinalIgnoreCase) == true);
}
static IEnumerable<string> ParseBranchLines(string stdout) {
if ( string.IsNullOrEmpty(stdout) )
yield break;
foreach ( var line in stdout.Split('\n') )
yield return line;
}
}
using System.Collections.Generic;
namespace Sandbox.git.models;
/// <summary>
/// The result of comparing two refs in a repository.
/// </summary>
public interface ICompareResult : IAheadBehind {
IReadOnlyList<Commit> Commits { get; }
}
using System;
using System.Collections.Generic;
namespace Sandbox.git.models;
/// <summary>
/// A parsed trailer from a commit message (e.g. Co-Authored-By: Name <email>).
/// </summary>
public interface ITrailer {
string Token { get; }
string Value { get; }
}
/// <summary>
/// Helpers for interpreting commit trailers.
/// </summary>
public static class TrailerHelpers {
public const string CoAuthoredByToken = "Co-Authored-By";
public static bool IsCoAuthoredByTrailer(ITrailer trailer) {
return trailer != null &&
string.Equals(trailer.Token, CoAuthoredByToken, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Parses unfolded trailer lines (e.g. from %(trailers:unfold,only)) with the given key-value separator.
/// Each line is "Token: Value" or "Token separator Value".
/// </summary>
public static IReadOnlyList<ITrailer> ParseRawUnfoldedTrailers(string text, char keyValueSeparator = ':') {
if ( string.IsNullOrWhiteSpace(text) )
return Array.Empty<ITrailer>();
var list = new List<ITrailer>();
foreach ( var line in text.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) ) {
var trimmed = line.Trim();
if ( trimmed.Length == 0 )
continue;
var sepIndex = trimmed.IndexOf(keyValueSeparator);
if ( sepIndex <= 0 )
continue;
var token = trimmed.Substring(0, sepIndex).Trim();
var value = trimmed.Substring(sepIndex + 1).Trim();
if ( token.Length > 0 )
list.Add(new Trailer(token, value));
}
return list;
}
}
namespace Sandbox.git.models;
/// <summary>
/// Base type for a directory you can run git commands in.
/// </summary>
public class WorkingTree {
public string Path { get; }
public WorkingTree(string path) {
Path = path ?? string.Empty;
}
}
#if DEBUG
using Sandbox.Reactivity.Internals;
using Sandbox.UI;
namespace Sandbox.Reactivity.Editor.Debugger;
// ReSharper disable once ClassNeverInstantiated.Global
[Dock("Editor", "Reactivity Debugger", "local_fire_department")]
internal sealed partial class DebuggerWidget : Widget
{
private readonly TreeView _tree;
public DebuggerWidget(Widget? parent)
: base(parent)
{
Layout = new Column
{
Spacing = 2,
Children =
[
new Widget
{
Layout = new Row
{
Spacing = 2,
Children =
[
// TODO: search bar
new Widget
{
HorizontalSizeMode = SizeMode.Flexible,
},
new Widget
{
BackgroundColor = Theme.ControlBackground,
BorderRadius = Theme.ControlRadius,
Layout = new Row
{
Children =
[
new ToolButton("Settings", "more_vert", this)
{
MouseLeftPress = () => Settings<DebuggerWidget>.OpenContextMenu(this),
},
],
},
},
],
},
},
new Widget
{
BackgroundColor = Theme.ControlBackground,
BorderRadius = Theme.ControlRadius,
Layout = new Column
{
Children =
[
_tree = new TreeView
{
Margin = new Margin(8, 0),
SelectionOverride = () => EditorUtility.InspectorObject,
},
],
},
},
],
};
Effect.OnEffectRootCreated += OnEffectRootCreated;
}
public override void OnDestroyed()
{
foreach (var item in _tree.Items) // .Items makes a copy
{
if (item is EffectTreeNode node)
{
node.Dispose();
}
}
_tree.Clear();
Effect.OnEffectRootCreated -= OnEffectRootCreated;
}
private void OnEffectRootCreated(Effect root)
{
if (root.IsDisposed)
{
// just in case - effects from other scenes (e.g. prefab editors) might cause this to happen
return;
}
if (!ShowUiEffects && root.Parent is IReactivePanel)
{
return;
}
if (!ShowGameplayEffects && root.Parent is (Component or GameObject) and not IReactivePanel)
{
return;
}
var node = new EffectTreeNode(root);
_tree.AddItem(node);
if (AutoExpand)
{
_tree.Open(node, true);
}
}
}
#endif
#if DEBUG
using Sandbox.Reactivity.Internals;
namespace Sandbox.Reactivity.Editor.Inspector;
internal class SerializedReactiveObjectProperty(IReactiveObject reactive) : SerializedProperty
{
protected readonly IReactiveObject ReactiveObject = reactive;
public override string Name { get; } = reactive.Name ?? "Reactive Object";
public override string DisplayName => Name;
public override Type PropertyType => ReactiveObject.GetType();
public override bool IsEditable => false;
public override bool IsValid => ReactiveObject is not Effect { IsDisposed: true } && base.IsValid;
public override void SetValue<T>(T value)
{
}
public override T GetValue<T>(T defaultValue = default!)
{
return ValueToType(ReactiveObject, defaultValue);
}
}
#endif
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using Editor;
using Sandbox;
namespace SboxMcpServer;
/// <summary>
/// Shared helpers: scene resolution, object tree walking, JSON arg extraction,
/// schema builders, and other utilities used by all handler files.
/// </summary>
internal static class OzmiumSceneHelpers
{
// ── Scene resolution ────────────────────────────────────────────────────
/// <summary>
/// Returns the best available scene:
/// 1. Game.ActiveScene (game is running)
/// 2. Active SceneEditorSession.Scene (prefab/scene editor)
/// 3. First available editor session
/// 4. null
/// </summary>
internal static Scene ResolveScene()
{
// Prefer the editor session scene — this is what the user sees in the hierarchy panel.
try
{
var active = SceneEditorSession.Active;
if ( active?.Scene != null ) return active.Scene;
foreach ( var s in SceneEditorSession.All )
if ( s?.Scene != null ) return s.Scene;
}
catch { }
// Fall back to runtime scene (only meaningful during play mode or tests)
if ( Game.ActiveScene != null ) return Game.ActiveScene;
return null;
}
// ── Tree walking ────────────────────────────────────────────────────────
internal static IEnumerable<GameObject> WalkAll( Scene scene, bool includeDisabled = true )
{
foreach ( var root in scene.Children )
foreach ( var go in WalkSubtree( root, includeDisabled ) )
yield return go;
}
/// <summary>Name marker that causes MCP to skip an object and its entire subtree.</summary>
internal const string IgnoreMarker = "(MCP IGNORE)";
/// <summary>Tag that causes MCP to skip an object and its entire subtree.</summary>
internal const string IgnoreTag = "mcp_ignore";
/// <summary>Max children before auto-skipping subtree walk (parent still returned).</summary>
internal const int MaxAutoWalkChildren = 25;
internal static IEnumerable<GameObject> WalkSubtree( GameObject root, bool includeDisabled = true )
{
if ( !includeDisabled && !root.Enabled ) yield break;
if ( root.Name != null && root.Name.IndexOf( IgnoreMarker, StringComparison.OrdinalIgnoreCase ) >= 0 ) yield break;
if ( root.Tags.Has( IgnoreTag ) ) yield break;
yield return root;
// Auto-skip children of objects with too many children (performance guard)
if ( root.Children.Count > MaxAutoWalkChildren ) yield break;
foreach ( var child in root.Children )
foreach ( var go in WalkSubtree( child, includeDisabled ) )
yield return go;
}
// ── Object path ─────────────────────────────────────────────────────────
internal static string GetObjectPath( GameObject go )
{
var parts = new List<string>();
var cur = go;
while ( cur != null ) { parts.Insert( 0, cur.Name ); cur = cur.Parent; }
return string.Join( "/", parts );
}
// ── Component / tag helpers ─────────────────────────────────────────────
internal static List<string> GetComponentNames( GameObject go )
=> go.Components.GetAll().Select( c => c.GetType().Name ).ToList();
internal static List<string> GetTags( GameObject go )
=> go.Tags.TryGetAll().ToList();
// ── Object builders ─────────────────────────────────────────────────────
internal static Dictionary<string, object> BuildSummary( GameObject go )
{
var pos = go.WorldPosition;
return new Dictionary<string, object>
{
["id"] = go.Id.ToString(),
["name"] = go.Name,
["path"] = GetObjectPath( go ),
["enabled"] = go.Enabled,
["tags"] = GetTags( go ),
["components"] = GetComponentNames( go ),
["position"] = V3( pos ),
["childCount"] = go.Children.Count,
["isPrefabInstance"] = go.IsPrefabInstance,
["prefabSource"] = go.IsPrefabInstance ? go.PrefabInstanceSource : null,
["isNetworkRoot"] = go.IsNetworkRoot,
["networkMode"] = go.NetworkMode.ToString()
};
}
internal static Dictionary<string, object> BuildDetail( GameObject go, bool recurse = false )
{
var comps = new List<Dictionary<string, object>>();
foreach ( var c in go.Components.GetAll() )
comps.Add( new Dictionary<string, object> { ["type"] = c.GetType().Name, ["enabled"] = c.Enabled } );
List<object> children;
if ( recurse )
children = go.Children.Select( c => (object)BuildDetail( c, true ) ).ToList();
else
children = go.Children.Select( c => (object)new Dictionary<string, object>
{
["id"] = c.Id.ToString(), ["name"] = c.Name,
["enabled"] = c.Enabled, ["components"] = GetComponentNames( c )
} ).ToList();
return new Dictionary<string, object>
{
["id"] = go.Id.ToString(),
["name"] = go.Name,
["path"] = GetObjectPath( go ),
["enabled"] = go.Enabled,
["tags"] = GetTags( go ),
["components"] = comps,
["worldTransform"] = new Dictionary<string, object>
{
["position"] = V3( go.WorldPosition ),
["rotation"] = Rot( go.WorldRotation ),
["scale"] = V3( go.WorldScale )
},
["localTransform"] = new Dictionary<string, object>
{
["position"] = V3( go.LocalPosition ),
["rotation"] = Rot( go.LocalRotation ),
["scale"] = V3( go.LocalScale )
},
["parent"] = go.Parent != null
? (object)new Dictionary<string, object> { ["id"] = go.Parent.Id.ToString(), ["name"] = go.Parent.Name }
: null,
["children"] = children,
["isNetworkRoot"] = go.IsNetworkRoot,
["isPrefabInstance"] = go.IsPrefabInstance,
["prefabSource"] = go.IsPrefabInstance ? go.PrefabInstanceSource : null,
["networkMode"] = go.NetworkMode.ToString()
};
}
// ── Find by id/name ─────────────────────────────────────────────────────
internal static GameObject FindGo( Scene scene, string id, string name )
{
GameObject target = null;
if ( !string.IsNullOrEmpty( id ) && Guid.TryParse( id, out var guid ) )
{
target = WalkAll( scene, true ).FirstOrDefault( g => g.Id == guid )
?? scene.Children.FirstOrDefault( g => g.Id == guid );
}
if ( target == null && !string.IsNullOrEmpty( name ) )
{
target = WalkAll( scene, true ).FirstOrDefault( g =>
string.Equals( g.Name, name, StringComparison.OrdinalIgnoreCase ) )
?? scene.Children.FirstOrDefault( g =>
string.Equals( g.Name, name, StringComparison.OrdinalIgnoreCase ) );
}
return target;
}
/// <summary>
/// Like FindGo but skips objects that don't have the requested component type.
/// Resolves name collisions where multiple objects share the same name.
/// </summary>
internal static GameObject FindGoWithComponent<T>( Scene scene, string id, string name ) where T : Component
{
// If ID provided, try exact match first (ID is unique, no collision possible)
if ( !string.IsNullOrEmpty( id ) && Guid.TryParse( id, out var guid ) )
{
var byId = WalkAll( scene, true ).FirstOrDefault( g => g.Id == guid )
?? scene.Children.FirstOrDefault( g => g.Id == guid );
if ( byId != null && byId.Components.Get<T>() != null )
return byId;
}
// Name lookup — find first object with this name that has the component
if ( !string.IsNullOrEmpty( name ) )
{
return WalkAll( scene, true ).FirstOrDefault( g =>
string.Equals( g.Name, name, StringComparison.OrdinalIgnoreCase )
&& g.Components.Get<T>() != null )
?? scene.Children.FirstOrDefault( g =>
string.Equals( g.Name, name, StringComparison.OrdinalIgnoreCase )
&& g.Components.Get<T>() != null );
}
return null;
}
// ── Hierarchy line ──────────────────────────────────────────────────────
internal static void AppendHierarchyLine( StringBuilder sb, GameObject go, int depth, bool showChildren )
{
var indent = new string( ' ', depth * 2 );
var comps = GetComponentNames( go );
var tags = GetTags( go );
var compStr = comps.Count > 0 ? $" [{string.Join( ", ", comps )}]" : "";
var tagStr = tags.Count > 0 ? $" #{string.Join( " #", tags )}" : "";
var disStr = go.Enabled ? "" : " (disabled)";
var cStr = showChildren ? $" children:{go.Children.Count}" : "";
sb.AppendLine( $"{indent}- {go.Name} (ID: {go.Id}){disStr}{tagStr}{compStr}{cStr}" );
}
// ── Formatting ──────────────────────────────────────────────────────────
internal static Dictionary<string, object> V3( Vector3 v ) => new()
{ ["x"] = MathF.Round( v.x, 2 ), ["y"] = MathF.Round( v.y, 2 ), ["z"] = MathF.Round( v.z, 2 ) };
internal static Dictionary<string, object> Rot( Rotation r ) => new()
{ ["pitch"] = MathF.Round( r.Pitch(), 2 ), ["yaw"] = MathF.Round( r.Yaw(), 2 ), ["roll"] = MathF.Round( r.Roll(), 2 ) };
// ── JSON helpers (shared across all handler files) ───────────────────
/// <summary>Wraps a plain text string into the MCP text-result envelope.</summary>
internal static object Txt( string text ) => new { content = new object[] { new { type = "text", text } } };
/// <summary>Extracts a typed value from a JsonElement, returning <paramref name="def"/> on missing/invalid.</summary>
internal static T Get<T>( JsonElement el, string key, T def )
{
if ( el.ValueKind == JsonValueKind.Undefined ) return def;
if ( !el.TryGetProperty( key, out var p ) ) return def;
try
{
var t = typeof( T );
if ( t == typeof( string ) ) return (T)(object)( p.ValueKind == JsonValueKind.Null ? null : p.GetString() );
if ( t == typeof( bool ) ) return (T)(object)p.GetBoolean();
if ( t == typeof( int ) ) return (T)(object)p.GetInt32();
if ( t == typeof( float ) ) return (T)(object)p.GetSingle();
return def;
}
catch { return def; }
}
/// <summary>Builds a tool definition object with name, description, and inputSchema.</summary>
internal static Dictionary<string, object> S( string name, string desc, Dictionary<string, object> props, string[] req = null )
{
var schema = new Dictionary<string, object> { ["type"] = "object", ["properties"] = props };
if ( req != null ) schema["required"] = req;
return new Dictionary<string, object> { ["name"] = name, ["description"] = desc, ["inputSchema"] = schema };
}
/// <summary>Shorthand for building a property dictionary from (key, type, description) tuples.</summary>
internal static Dictionary<string, object> Ps( params (string k, string type, string d)[] fields )
{
var d = new Dictionary<string, object>();
foreach ( var (k, tp, desc) in fields )
d[k] = new Dictionary<string, object> { ["type"] = tp, ["description"] = desc };
return d;
}
/// <summary>Strips a leading "Assets/" or "assets/" prefix so AssetSystem.FindByPath works.</summary>
internal static string NormalizePath( string path )
{
if ( path == null ) return null;
if ( path.StartsWith( "Assets/", StringComparison.OrdinalIgnoreCase ) )
path = path.Substring( "Assets/".Length );
return path;
}
// ── Selection helpers ─────────────────────────────────────────────────
/// <summary>
/// Returns the currently selected GameObjects in the editor, using reflection
/// to access the editor Selection API without hard dependencies.
/// </summary>
internal static List<GameObject> GetSelectedGameObjects()
{
var result = new List<GameObject>();
try
{
var session = SceneEditorSession.Active;
if ( session == null ) return result;
var selProp = session.GetType().GetProperty( "Selection",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance );
var selObj = selProp?.GetValue( session );
if ( selObj == null ) return result;
// SelectionSystem implements IEnumerable<object> — enumerate directly
if ( selObj is IEnumerable<object> objs )
foreach ( var o in objs )
if ( o is GameObject go ) result.Add( go );
}
catch { }
return result;
}
}
using System;
using System.Collections.Generic;
using System.Text.Json;
using Sandbox;
namespace SboxMcpServer;
/// <summary>
/// Rendering MCP tools: create_text_renderer, create_line_renderer, create_sprite_renderer, create_trail_renderer.
/// </summary>
internal static class RenderingToolHandlers
{
private static readonly JsonSerializerOptions _json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
// ── create_text_renderer ───────────────────────────────────────────────
internal static object CreateTextRenderer( JsonElement args )
{
var scene = OzmiumSceneHelpers.ResolveScene();
if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );
float x = OzmiumSceneHelpers.Get( args, "x", 0f );
float y = OzmiumSceneHelpers.Get( args, "y", 0f );
float z = OzmiumSceneHelpers.Get( args, "z", 0f );
string name = OzmiumSceneHelpers.Get( args, "name", "Text Renderer" );
try
{
var go = scene.CreateObject();
go.Name = name;
go.WorldPosition = new Vector3( x, y, z );
var tr = go.Components.Create<TextRenderer>();
tr.Text = OzmiumSceneHelpers.Get( args, "text", tr.Text );
tr.FontSize = OzmiumSceneHelpers.Get( args, "fontSize", tr.FontSize );
tr.Scale = OzmiumSceneHelpers.Get( args, "scale", tr.Scale );
if ( args.TryGetProperty( "color", out var colEl ) && colEl.ValueKind == JsonValueKind.String )
{
try { tr.Color = Color.Parse( colEl.GetString() ) ?? default; } catch { }
}
if ( args.TryGetProperty( "horizontalAlignment", out var hEl ) && hEl.ValueKind == JsonValueKind.String )
{
if ( Enum.TryParse<TextRenderer.HAlignment>( hEl.GetString(), true, out var h ) )
tr.HorizontalAlignment = h;
}
if ( args.TryGetProperty( "verticalAlignment", out var vEl ) && vEl.ValueKind == JsonValueKind.String )
{
if ( Enum.TryParse<TextRenderer.VAlignment>( vEl.GetString(), true, out var v ) )
tr.VerticalAlignment = v;
}
return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new
{
message = $"Created TextRenderer '{go.Name}'.",
id = go.Id.ToString(),
position = OzmiumSceneHelpers.V3( go.WorldPosition )
}, _json ) );
}
catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
}
// ── create_line_renderer ───────────────────────────────────────────────
internal static object CreateLineRenderer( JsonElement args )
{
var scene = OzmiumSceneHelpers.ResolveScene();
if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );
float x = OzmiumSceneHelpers.Get( args, "x", 0f );
float y = OzmiumSceneHelpers.Get( args, "y", 0f );
float z = OzmiumSceneHelpers.Get( args, "z", 0f );
string name = OzmiumSceneHelpers.Get( args, "name", "Line Renderer" );
try
{
var go = scene.CreateObject();
go.Name = name;
go.WorldPosition = new Vector3( x, y, z );
var lr = go.Components.Create<LineRenderer>();
lr.UseVectorPoints = true;
lr.VectorPoints = new List<Vector3>();
if ( args.TryGetProperty( "points", out var ptsEl ) && ptsEl.ValueKind == JsonValueKind.Array )
{
foreach ( var ptEl in ptsEl.EnumerateArray() )
{
if ( ptEl.ValueKind == JsonValueKind.Object )
{
lr.VectorPoints.Add( new Vector3(
OzmiumSceneHelpers.Get( ptEl, "x", 0f ),
OzmiumSceneHelpers.Get( ptEl, "y", 0f ),
OzmiumSceneHelpers.Get( ptEl, "z", 0f ) ) );
}
}
}
if ( args.TryGetProperty( "color", out var colEl ) && colEl.ValueKind == JsonValueKind.String )
{
try { lr.Color = Color.Parse( colEl.GetString() ) ?? default; } catch { }
}
if ( args.TryGetProperty( "width", out var wEl ) )
lr.Width = 5; // Width is a Curve, but we set a default via property
return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new
{
message = $"Created LineRenderer '{go.Name}'.",
id = go.Id.ToString(),
position = OzmiumSceneHelpers.V3( go.WorldPosition ),
pointCount = lr.VectorPoints?.Count ?? 0
}, _json ) );
}
catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
}
// ── create_sprite_renderer ──────────────────────────────────────────────
internal static object CreateSpriteRenderer( JsonElement args )
{
var scene = OzmiumSceneHelpers.ResolveScene();
if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );
float x = OzmiumSceneHelpers.Get( args, "x", 0f );
float y = OzmiumSceneHelpers.Get( args, "y", 0f );
float z = OzmiumSceneHelpers.Get( args, "z", 0f );
string name = OzmiumSceneHelpers.Get( args, "name", "Sprite Renderer" );
try
{
var go = scene.CreateObject();
go.Name = name;
go.WorldPosition = new Vector3( x, y, z );
// SpriteRenderer — look up via TypeLibrary (may not exist in all versions)
var spriteTd = OzmiumWriteHandlers.FindComponentTypeDescription( "SpriteRenderer" );
if ( spriteTd == null )
return OzmiumSceneHelpers.Txt( "SpriteRenderer is not available in this S&box version." );
var comp = go.Components.Create( spriteTd );
if ( args.TryGetProperty( "color", out var colEl ) && colEl.ValueKind == JsonValueKind.String )
{
try
{
var prop = spriteTd.TargetType.GetProperty( "Color" );
if ( prop != null ) prop.SetValue( comp, Color.Parse( colEl.GetString() ) ?? default );
}
catch { }
}
return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new
{
message = $"Created SpriteRenderer '{go.Name}'.",
id = go.Id.ToString(),
position = OzmiumSceneHelpers.V3( go.WorldPosition )
}, _json ) );
}
catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
}
// ── create_trail_renderer ───────────────────────────────────────────────
internal static object CreateTrailRenderer( JsonElement args )
{
var scene = OzmiumSceneHelpers.ResolveScene();
if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );
float x = OzmiumSceneHelpers.Get( args, "x", 0f );
float y = OzmiumSceneHelpers.Get( args, "y", 0f );
float z = OzmiumSceneHelpers.Get( args, "z", 0f );
string name = OzmiumSceneHelpers.Get( args, "name", "Trail Renderer" );
try
{
var go = scene.CreateObject();
go.Name = name;
go.WorldPosition = new Vector3( x, y, z );
var trail = go.Components.Create<TrailRenderer>();
trail.MaxPoints = OzmiumSceneHelpers.Get( args, "maxPoints", trail.MaxPoints );
trail.PointDistance = OzmiumSceneHelpers.Get( args, "pointDistance", trail.PointDistance );
trail.LifeTime = OzmiumSceneHelpers.Get( args, "lifetime", trail.LifeTime );
trail.Emitting = OzmiumSceneHelpers.Get( args, "emitting", trail.Emitting );
if ( args.TryGetProperty( "color", out var colEl ) && colEl.ValueKind == JsonValueKind.String )
{
try { trail.Color = Color.Parse( colEl.GetString() ) ?? default; } catch { }
}
return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new
{
message = $"Created TrailRenderer '{go.Name}'.",
id = go.Id.ToString(),
position = OzmiumSceneHelpers.V3( go.WorldPosition )
}, _json ) );
}
catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
}
// ── create_model_renderer ──────────────────────────────────────────────
internal static object CreateModelRenderer( JsonElement args )
{
var scene = OzmiumSceneHelpers.ResolveScene();
if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );
float x = OzmiumSceneHelpers.Get( args, "x", 0f );
float y = OzmiumSceneHelpers.Get( args, "y", 0f );
float z = OzmiumSceneHelpers.Get( args, "z", 0f );
string name = OzmiumSceneHelpers.Get( args, "name", "Model Renderer" );
try
{
var go = scene.CreateObject();
go.Name = name;
go.WorldPosition = new Vector3( x, y, z );
var mr = go.Components.Create<ModelRenderer>();
if ( args.TryGetProperty( "modelPath", out var mpEl ) && mpEl.ValueKind == JsonValueKind.String )
{
var model = Model.Load( mpEl.GetString() );
if ( model != null ) mr.Model = model;
}
if ( args.TryGetProperty( "tint", out var tintEl ) && tintEl.ValueKind == JsonValueKind.String )
{
try { mr.Tint = Color.Parse( tintEl.GetString() ) ?? default; } catch { }
}
mr.RenderType = OzmiumSceneHelpers.Get( args, "castsShadows", true )
? ModelRenderer.ShadowRenderType.On
: ModelRenderer.ShadowRenderType.Off;
if ( args.TryGetProperty( "bodyGroups", out var bgEl ) && bgEl.ValueKind == JsonValueKind.String )
{
if ( ulong.TryParse( bgEl.GetString(), out var bg ) ) mr.BodyGroups = bg;
}
return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new
{
message = $"Created ModelRenderer '{go.Name}'.",
id = go.Id.ToString(),
position = OzmiumSceneHelpers.V3( go.WorldPosition ),
model = mr.Model?.ResourcePath ?? "null"
}, _json ) );
}
catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
}
// ── create_skinned_model ──────────────────────────────────────────────
internal static object CreateSkinnedModel( JsonElement args )
{
var scene = OzmiumSceneHelpers.ResolveScene();
if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );
float x = OzmiumSceneHelpers.Get( args, "x", 0f );
float y = OzmiumSceneHelpers.Get( args, "y", 0f );
float z = OzmiumSceneHelpers.Get( args, "z", 0f );
string name = OzmiumSceneHelpers.Get( args, "name", "Skinned Model" );
try
{
var go = scene.CreateObject();
go.Name = name;
go.WorldPosition = new Vector3( x, y, z );
var sk = go.Components.Create<SkinnedModelRenderer>();
if ( args.TryGetProperty( "modelPath", out var mpEl ) && mpEl.ValueKind == JsonValueKind.String )
{
var model = Model.Load( mpEl.GetString() );
if ( model != null ) sk.Model = model;
}
if ( args.TryGetProperty( "tint", out var tintEl ) && tintEl.ValueKind == JsonValueKind.String )
{
try { sk.Tint = Color.Parse( tintEl.GetString() ) ?? default; } catch { }
}
sk.UseAnimGraph = OzmiumSceneHelpers.Get( args, "useAnimGraph", true );
sk.CreateBoneObjects = OzmiumSceneHelpers.Get( args, "createBoneObjects", false );
return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new
{
message = $"Created SkinnedModelRenderer '{go.Name}'.",
id = go.Id.ToString(),
position = OzmiumSceneHelpers.V3( go.WorldPosition ),
model = sk.Model?.ResourcePath ?? "null",
useAnimGraph = sk.UseAnimGraph
}, _json ) );
}
catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
}
// ── create_screen_panel ──────────────────────────────────────────────
internal static object CreateScreenPanel( JsonElement args )
{
var scene = OzmiumSceneHelpers.ResolveScene();
if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );
float x = OzmiumSceneHelpers.Get( args, "x", 0f );
float y = OzmiumSceneHelpers.Get( args, "y", 0f );
float z = OzmiumSceneHelpers.Get( args, "z", 0f );
string name = OzmiumSceneHelpers.Get( args, "name", "Screen Panel" );
try
{
var go = scene.CreateObject();
go.Name = name;
go.WorldPosition = new Vector3( x, y, z );
var sp = go.Components.Create<ScreenPanel>();
sp.Opacity = OzmiumSceneHelpers.Get( args, "opacity", 1f );
sp.Scale = OzmiumSceneHelpers.Get( args, "scale", 1f );
sp.ZIndex = OzmiumSceneHelpers.Get( args, "zIndex", 100 );
sp.AutoScreenScale = OzmiumSceneHelpers.Get( args, "autoScreenScale", true );
return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new
{
message = $"Created ScreenPanel '{go.Name}'.",
id = go.Id.ToString(),
position = OzmiumSceneHelpers.V3( go.WorldPosition ),
opacity = sp.Opacity,
scale = sp.Scale,
zIndex = sp.ZIndex
}, _json ) );
}
catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
}
// ── Schemas ─────────────────────────────────────────────────────────────
private static Dictionary<string, object> S( string name, string desc, Dictionary<string, object> props, string[] req = null )
{
var schema = new Dictionary<string, object> { ["type"] = "object", ["properties"] = props };
if ( req != null ) schema["required"] = req;
return new Dictionary<string, object> { ["name"] = name, ["description"] = desc, ["inputSchema"] = schema };
}
internal static Dictionary<string, object> SchemaCreateTextRenderer => S( "create_text_renderer",
"Create a GO with a TextRenderer component for 3D world text.",
new Dictionary<string, object>
{
["x"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World X position." },
["y"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Y position." },
["z"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Z position." },
["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Name for the GO." },
["text"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Text to display." },
["fontSize"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Font size." },
["color"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Text color hex." },
["scale"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World size scale." },
["horizontalAlignment"] = new Dictionary<string, object>
{
["type"] = "string", ["description"] = "Horizontal alignment.",
["enum"] = new[] { "Left", "Center", "Right" }
},
["verticalAlignment"] = new Dictionary<string, object>
{
["type"] = "string", ["description"] = "Vertical alignment.",
["enum"] = new[] { "Top", "Center", "Bottom" }
}
} );
internal static Dictionary<string, object> SchemaCreateLineRenderer => S( "create_line_renderer",
"Create a GO with a LineRenderer component for lines/paths.",
new Dictionary<string, object>
{
["x"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World X position." },
["y"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Y position." },
["z"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Z position." },
["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Name for the GO." },
["points"] = new Dictionary<string, object>
{
["type"] = "array", ["description"] = "Array of Vector3 points {x,y,z}.",
["items"] = new Dictionary<string, object> { ["type"] = "object",
["properties"] = new Dictionary<string, object>
{
["x"] = new Dictionary<string, object> { ["type"] = "number" },
["y"] = new Dictionary<string, object> { ["type"] = "number" },
["z"] = new Dictionary<string, object> { ["type"] = "number" }
}
}
},
["color"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Line color hex." }
} );
internal static Dictionary<string, object> SchemaCreateSpriteRenderer => S( "create_sprite_renderer",
"Create a GO with a SpriteRenderer for 2D billboards in 3D.",
new Dictionary<string, object>
{
["x"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World X position." },
["y"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Y position." },
["z"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Z position." },
["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Name for the GO." },
["color"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Sprite color hex." }
} );
internal static Dictionary<string, object> SchemaCreateTrailRenderer => S( "create_trail_renderer",
"Create a GO with a TrailRenderer for object trails.",
new Dictionary<string, object>
{
["x"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World X position." },
["y"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Y position." },
["z"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Z position." },
["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Name for the GO." },
["maxPoints"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Maximum trail points." },
["pointDistance"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Distance between trail points." },
["lifetime"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Trail point lifetime in seconds." },
["emitting"] = new Dictionary<string, object> { ["type"] = "boolean", ["description"] = "Whether the trail emits points." },
["color"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Trail color hex." }
} );
internal static Dictionary<string, object> SchemaCreateModelRenderer => S( "create_model_renderer",
"Create a GO with a ModelRenderer (static model display). Unlike Prop, this is purely visual — no physics, no breakable behavior. Essential for decorative models.",
new Dictionary<string, object>
{
["x"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World X position." },
["y"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Y position." },
["z"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Z position." },
["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Name for the GO." },
["modelPath"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Model asset path (e.g. 'models/citizen.vmdl')." },
["tint"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Color tint hex (default '#FFFFFF')." },
["castsShadows"] = new Dictionary<string, object> { ["type"] = "boolean", ["description"] = "Cast shadows (default true)." },
["bodyGroups"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Body group mask (ulong)." }
} );
internal static Dictionary<string, object> SchemaCreateSkinnedModel => S( "create_skinned_model",
"Create a GO with a SkinnedModelRenderer for animated models (NPCs, characters). THE component for animated characters — lets AI set up NPCs and animated props.",
new Dictionary<string, object>
{
["x"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World X position." },
["y"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Y position." },
["z"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Z position." },
["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Name for the GO." },
["modelPath"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Model asset path (e.g. 'models/citizen.vmdl')." },
["tint"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Color tint hex." },
["useAnimGraph"] = new Dictionary<string, object> { ["type"] = "boolean", ["description"] = "Use animation graph (default true)." },
["createBoneObjects"] = new Dictionary<string, object> { ["type"] = "boolean", ["description"] = "Create bone objects (default false)." }
} );
internal static Dictionary<string, object> SchemaCreateScreenPanel => S( "create_screen_panel",
"Create a GO with a ScreenPanel for 2D HUD-style UI (health bars, score counters, debug overlays). Renders flat to screen, unlike WorldPanel which is 3D.",
new Dictionary<string, object>
{
["x"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World X position." },
["y"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Y position." },
["z"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Z position." },
["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Name for the GO." },
["opacity"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Panel opacity (default 1)." },
["scale"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Panel scale (default 1)." },
["zIndex"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Z-order index (default 100)." },
["autoScreenScale"] = new Dictionary<string, object> { ["type"] = "boolean", ["description"] = "Auto-scale with screen resolution (default true)." }
} );
// ── create_render_entity (Omnibus) ──────────────────────────────────────
internal static object CreateRenderEntity( JsonElement args )
{
string renderType = OzmiumSceneHelpers.Get( args, "renderType", "" );
return renderType switch
{
"TextRenderer" => CreateTextRenderer( args ),
"LineRenderer" => CreateLineRenderer( args ),
"SpriteRenderer" => CreateSpriteRenderer( args ),
"TrailRenderer" => CreateTrailRenderer( args ),
"ModelRenderer" => CreateModelRenderer( args ),
"SkinnedModelRenderer" => CreateSkinnedModel( args ),
"ScreenPanel" => CreateScreenPanel( args ),
_ => OzmiumSceneHelpers.Txt( $"Unknown renderType: {renderType}" )
};
}
internal static Dictionary<string, object> SchemaCreateRenderEntity => S( "create_render_entity",
"Create a rendering entity (TextRenderer, LineRenderer, SpriteRenderer, TrailRenderer, ModelRenderer, SkinnedModelRenderer, ScreenPanel).",
new Dictionary<string, object>
{
["renderType"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Type of renderer.", ["enum"] = new[] { "TextRenderer", "LineRenderer", "SpriteRenderer", "TrailRenderer", "ModelRenderer", "SkinnedModelRenderer", "ScreenPanel" } },
["x"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World X position." },
["y"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Y position." },
["z"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "World Z position." },
["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Name for the GO." },
["text"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Text to display." },
["fontSize"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Font size." },
["color"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Color hex." },
["tint"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Color tint hex." },
["scale"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Size scale." },
["horizontalAlignment"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Horizontal alignment." },
["verticalAlignment"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Vertical alignment." },
["points"] = new Dictionary<string, object> { ["type"] = "array", ["description"] = "Array of Vector3 points {x,y,z}.", ["items"] = new Dictionary<string, object> { ["type"] = "object", ["properties"] = new Dictionary<string, object> { ["x"] = new Dictionary<string, object> { ["type"] = "number" }, ["y"] = new Dictionary<string, object> { ["type"] = "number" }, ["z"] = new Dictionary<string, object> { ["type"] = "number" } } } },
["maxPoints"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Maximum trail points." },
["pointDistance"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Distance between trail points." },
["lifetime"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Trail point lifetime in seconds." },
["emitting"] = new Dictionary<string, object> { ["type"] = "boolean", ["description"] = "Whether the trail emits points." },
["modelPath"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Model asset path." },
["castsShadows"] = new Dictionary<string, object> { ["type"] = "boolean", ["description"] = "Cast shadows." },
["bodyGroups"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Body group mask (ulong)." },
["useAnimGraph"] = new Dictionary<string, object> { ["type"] = "boolean", ["description"] = "Use animation graph." },
["createBoneObjects"] = new Dictionary<string, object> { ["type"] = "boolean", ["description"] = "Create bone objects." },
["opacity"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Panel opacity." },
["zIndex"] = new Dictionary<string, object> { ["type"] = "number", ["description"] = "Z-order index." },
["autoScreenScale"] = new Dictionary<string, object> { ["type"] = "boolean", ["description"] = "Auto-scale with screen resolution." }
},
new[] { "renderType" } );
}
using System.Threading.Tasks;
using Changelog.Git;
using Editor;
using Sandbox;
namespace Changelog.States;
public sealed class WelcomeWidget : Widget
{
private Button DownloadButton = new( "I don't have Git", "download" )
{
Tint = Theme.Primary,
ToolTip = "Opens the download page for Git"
};
private Button InitButton = new( "I have Git", "flag" )
{
Tint = Theme.Primary,
ToolTip = "Initialises a Git repository in your project folder"
};
public WelcomeWidget( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Alignment = TextFlag.Center;
Layout.Spacing = 8;
var header = new Label( "Welcome to Changelog!" );
header.SetStyles( "font-weight: 600; font-size: 23px;" );
var subtitle = new Label( "This library needs a Git repository to work." );
subtitle.SetStyles( "font-size: 15px;" );
var buttons = Layout.Row();
buttons.Spacing = 6;
DownloadButton.Clicked = DownloadGit;
InitButton.Clicked = InitRepo;
buttons.Add( DownloadButton );
buttons.Add( InitButton );
var footer =
new Label(
"This library interacts with the Git program on your computer" +
"\nIf you don't like that, please remove this library now." +
"\nGit isn't related to this library (or even s&box) at all. \ud83d\ude07" );
footer.SetStyles( "padding-top: 10px;" );
Layout.Add( header );
Layout.Add( subtitle );
Layout.Add( buttons );
Layout.Add( footer );
}
private void DownloadGit()
=> EditorUtility.OpenFolder( "https://git-scm.com/downloads" );
// initialise repo if it doesn't exist, or use current repo if it does
private async void InitRepo()
{
InitButton.Enabled = false;
InitButton.TransparentForMouseEvents = false;
InitButton.Icon = "pending";
// check if `git status` errors out
if ( !GitRepo.Exists )
{
// run `git init`
Log.Info( "Initialising git repo..." );
GitRepo.RunCommand( "init" );
}
InitButton.Tint = Theme.Green.Darken( 0.5f );
InitButton.Icon = "done";
await Task.Delay( 250 );
ProjectCookie.Set( "changelog.enabled", true );
GetAncestor<DockWidget>()?.Refresh();
}
}
using System;
using Editor;
using Sandbox;
namespace Changelog.Elements;
public class Toolbar : Widget
{
public SearchWidget Search { get; private set; }
public Toolbar( Widget parent ) : base( parent )
{
Layout = Layout.Row();
Layout.Margin = 2;
Layout.Spacing = 2;
MinimumHeight = Theme.RowHeight;
}
public void Add( Widget w, int stretch = 0 )
=> Layout.Add( w, stretch );
public Button AddButton( string text, string icon )
=> AddButton( text, icon, Theme.Primary );
public Button AddButton( string text, string icon, Color tint )
{
var btn = new Button( text, icon );
// every masterpiece (file class AddButton) has its cheap copy
btn.Tint = tint;
Add( btn );
return btn;
}
public SearchWidget AddSearch( string placeholder = null )
{
if ( Search.IsValid() )
return Search;
Search = new SearchWidget( this, placeholder );
Add( Search, 1 );
return Search;
}
public ToolButton AddIcon( string icon, string tooltip, Action onClick = null, Action onRightClick = null )
{
var btn = new ToolButton( tooltip, icon, this )
{
//IconSize = 20f
MouseClick = onClick,
MouseRightClick = onRightClick,
};
Add( btn );
return btn;
}
public ToolButton AddRefresh( Action onClick )
=> AddIcon( "refresh", "Refresh\n(right click: reset library)", onClick, ResetLib );
private void ResetLib()
=> GetAncestor<DockWidget>()?.Reset();
}using System;
using Editor;
namespace Changelog.Elements;
public abstract class Dialog : Editor.Dialog
{
public Action OnCancel;
protected Widget Body;
protected Widget Footer;
public Dialog()
{
Layout = Layout.Column();
Layout.Margin = 8;
Layout.Spacing = 4;
SetModal( true, true );
Window.SetWindowIcon( "paragliding" );
MinimumSize = new Vector2( 200, 100 );
Body = new Widget( this );
Body.Layout = Layout.Column();
Body.Layout.Spacing = 1;
Footer = new Widget( this );
Footer.Layout = Layout.Row();
Footer.Layout.Spacing = 8;
Layout.Add( Body, 1 );
Layout.Add( Footer );
}
protected Button AddFooterButton( string text, string icon = null, Action onClick = null )
{
var btn = new Button( text, icon, Footer );
btn.Clicked = onClick;
Footer.Layout.Add( btn );
return btn;
}
protected Button AddCancelButton()
=> AddFooterButton( "Cancel", "close", Cancel );
// ESC to close popup... oh look, it doesn't work!
[Shortcut( "editor.clear-selection", "ESC" )]
private void Cancel()
{
OnCancel?.Invoke();
Close();
}
}using System.Globalization;
namespace Editor.CitizenRetarget;
using NQuaternion = System.Numerics.Quaternion;
using NVector3 = System.Numerics.Vector3;
[Obsolete( "Deprecated diagnostic-only SMD writer. Production retarget output uses the template-FBX path.", false )]
internal sealed class SmdAnimationWriter : IRetargetOutputWriter
{
private static readonly NQuaternion RootYUpCorrection = RetargetMath.Normalize(
NQuaternion.CreateFromAxisAngle( NVector3.UnitX, -MathF.PI * 0.5f ) );
private static readonly NQuaternion ValveLegacyCorrection = RetargetMath.Normalize(
NQuaternion.CreateFromAxisAngle( NVector3.UnitY, MathF.PI * 0.5f ) *
NQuaternion.CreateFromAxisAngle( NVector3.UnitZ, MathF.PI * 0.5f ) );
public RetargetOutputFormat Format => RetargetOutputFormat.SmdDebug;
public string FileExtension => ".smd";
public string WriteClip( RetargetedClip clip, string outputAnimationFolder, SmdWriteOptions options = null )
{
options ??= new SmdWriteOptions();
var absoluteFolder = CitizenRetargetPaths.GetAssetAbsolutePath( outputAnimationFolder );
Directory.CreateDirectory( absoluteFolder );
var absolutePath = Path.Combine( absoluteFolder, $"{clip.SequenceName}.smd" );
var builder = new System.Text.StringBuilder();
builder.AppendLine( "version 1" );
builder.AppendLine( "nodes" );
foreach ( var bone in clip.Bones )
{
builder.AppendLine( $"{bone.Id} \"{bone.Name}\" {bone.ParentId}" );
}
builder.AppendLine( "end" );
builder.AppendLine( "skeleton" );
var previousEuler = new Dictionary<string, NVector3>( StringComparer.OrdinalIgnoreCase );
foreach ( var frame in clip.Frames )
{
builder.AppendLine( $"time {frame.Index}" );
for ( var boneIndex = 0; boneIndex < clip.Bones.Count; ++boneIndex )
{
var bone = clip.Bones[boneIndex];
var transform = SerializeTransform( bone, frame.BoneTransforms[boneIndex], options.RotationContract );
var euler = QuaternionToEulerXyz( transform.Rotation );
if ( previousEuler.TryGetValue( bone.Name, out var previous ) )
euler = UnwrapEuler( euler, previous );
previousEuler[bone.Name] = euler;
builder.Append( bone.Id.ToString( CultureInfo.InvariantCulture ) );
builder.Append( ' ' );
builder.Append( FormatFloat( transform.Translation.X ) );
builder.Append( ' ' );
builder.Append( FormatFloat( transform.Translation.Y ) );
builder.Append( ' ' );
builder.Append( FormatFloat( transform.Translation.Z ) );
builder.Append( ' ' );
builder.Append( FormatFloat( euler.X ) );
builder.Append( ' ' );
builder.Append( FormatFloat( euler.Y ) );
builder.Append( ' ' );
builder.AppendLine( FormatFloat( euler.Z ) );
}
}
builder.AppendLine( "end" );
File.WriteAllText( absolutePath, builder.ToString() );
return absolutePath;
}
string IRetargetOutputWriter.WriteClip( RetargetedClip clip, string outputFolder )
{
return WriteClip( clip, outputFolder );
}
private static BoneTransform SerializeTransform( RetargetedBone bone, BoneTransform transform, SmdRotationContract contract )
{
transform = new BoneTransform( transform.Translation, RetargetMath.Normalize( transform.Rotation ) );
var serialized = contract switch
{
SmdRotationContract.RawLocal => transform,
SmdRotationContract.InverseLocal => new BoneTransform(
transform.Translation,
RetargetMath.Normalize( NQuaternion.Inverse( transform.Rotation ) ) ),
SmdRotationContract.LegacyValve => new BoneTransform(
transform.Translation,
RetargetMath.Normalize( transform.Rotation * ValveLegacyCorrection ) ),
SmdRotationContract.LegacyValveInverse => new BoneTransform(
transform.Translation,
RetargetMath.Normalize( NQuaternion.Inverse( transform.Rotation * ValveLegacyCorrection ) ) ),
SmdRotationContract.LegacyValveBasis => ApplyBasisConjugation( transform, ValveLegacyCorrection, invertRotation: false ),
SmdRotationContract.LegacyValveBasisInverse => ApplyBasisConjugation( transform, ValveLegacyCorrection, invertRotation: true ),
SmdRotationContract.RawLocalRootYUp => transform,
SmdRotationContract.InverseLocalRootYUp => new BoneTransform(
transform.Translation,
RetargetMath.Normalize( NQuaternion.Inverse( transform.Rotation ) ) ),
_ => transform
};
if ( bone.ParentId < 0 && (contract == SmdRotationContract.RawLocalRootYUp || contract == SmdRotationContract.InverseLocalRootYUp) )
serialized = ApplyRootYUpCorrection( serialized );
return serialized;
}
private static BoneTransform ApplyBasisConjugation( BoneTransform transform, NQuaternion correction, bool invertRotation )
{
var inverseCorrection = RetargetMath.Normalize( NQuaternion.Inverse( correction ) );
var remappedTranslation = NVector3.Transform( transform.Translation, inverseCorrection );
var remappedRotation = RetargetMath.Normalize( inverseCorrection * transform.Rotation * correction );
if ( invertRotation )
remappedRotation = RetargetMath.Normalize( NQuaternion.Inverse( remappedRotation ) );
return new BoneTransform( remappedTranslation, remappedRotation );
}
private static BoneTransform ApplyRootYUpCorrection( BoneTransform transform )
{
return new BoneTransform(
NVector3.Transform( transform.Translation, RootYUpCorrection ),
RetargetMath.Normalize( RootYUpCorrection * transform.Rotation ) );
}
private static string FormatFloat( float value ) => value.ToString( "0.000000", CultureInfo.InvariantCulture );
private static NVector3 QuaternionToEulerXyz( NQuaternion rotation )
{
rotation = RetargetMath.Normalize( rotation );
var xx = rotation.X * rotation.X;
var yy = rotation.Y * rotation.Y;
var zz = rotation.Z * rotation.Z;
var xy = rotation.X * rotation.Y;
var xz = rotation.X * rotation.Z;
var yz = rotation.Y * rotation.Z;
var wx = rotation.W * rotation.X;
var wy = rotation.W * rotation.Y;
var wz = rotation.W * rotation.Z;
var m11 = 1f - 2f * (yy + zz);
var m12 = 2f * (xy - wz);
var m13 = 2f * (xz + wy);
var m23 = 2f * (yz - wx);
var m33 = 1f - 2f * (xx + yy);
var m32 = 2f * (yz + wx);
var m22 = 1f - 2f * (xx + zz);
var y = MathF.Asin( Math.Clamp( m13, -1f, 1f ) );
float x;
float z;
if ( MathF.Abs( m13 ) < 0.999999f )
{
x = MathF.Atan2( -m23, m33 );
z = MathF.Atan2( -m12, m11 );
}
else
{
x = MathF.Atan2( m32, m22 );
z = 0f;
}
return new NVector3( x, y, z );
}
private static NVector3 UnwrapEuler( NVector3 current, NVector3 previous )
{
return new NVector3(
UnwrapAngle( current.X, previous.X ),
UnwrapAngle( current.Y, previous.Y ),
UnwrapAngle( current.Z, previous.Z ) );
}
private static float UnwrapAngle( float value, float previous )
{
const float TwoPi = MathF.PI * 2f;
while ( value - previous > MathF.PI )
value -= TwoPi;
while ( previous - value > MathF.PI )
value += TwoPi;
return value;
}
}
using BspImport.Decompiler.Formats;
namespace BspImport.Decompiler;
public partial class MapDecompiler
{
/// <summary>
/// Refine format using the map file name (no lump parsing needed).
/// Called in Decompile() immediately after initial phase, using Context.Name.
/// </summary>
private void RefineFormatWithMapName( int bspVersion )
{
var mapName = Path.GetFileNameWithoutExtension( Context.Name );
Context.FormatDescriptor = BspFormatRegistry.RefineWithMapName(
Context.FormatDescriptor, bspVersion, mapName );
}
/// <summary>
/// Refines the BSP format using parsed entity classnames.
/// Helps disambiguate shared BSP versions; otherwise no-op.
/// Returns immediately if no entities are present.
/// </summary>
private void RefineFormatFromEntities( int bspVersion )
{
if ( Context.Entities is not { Length: > 0 } )
return;
var classNames = Context.Entities
.Select( e => e.ClassName )
.Where( c => !string.IsNullOrEmpty( c ) )
.Select( c => c! )
.Distinct( StringComparer.OrdinalIgnoreCase )
.ToList();
Context.FormatDescriptor = BspFormatRegistry.RefineWithEntities(
Context.FormatDescriptor, bspVersion, classNames );
}
}
using BspImport.Decompiler.Formats.Readers;
namespace BspImport.Decompiler.Formats.Descriptors;
/// <summary>
/// Format descriptor for Source Engine BSP version 22 (Portal 2 / CS:GO era).
///
/// V22 shares face and leaf binary layouts with v20/v21, differences are again
/// confined to game lump static-prop struct versions and some newer lumps.
/// Geometry parsing is identical to v20.
///
/// Games: CS:GO, Portal 2, DOTA 2, and others.
/// </summary>
public sealed class SourceV22BspFormatDescriptor : IBspFormatDescriptor
{
private static readonly IBspStructReaders _readers = new StandardBspStructReaders();
public BspGameFormat GameFormat => BspGameFormat.SourceV22;
public IReadOnlySet<int> SupportedVersions { get; } = new HashSet<int> { 22 };
public string DisplayName => "Source Engine BSP v22";
public int SpecificityScore => 50;
public LumpHeaderLayout LumpHeaderLayout => LumpHeaderLayout.Standard;
public BrushSideLayout BrushSideLayout => BrushSideLayout.Standard;
public StaticPropLayout StaticPropLayout => StaticPropLayout.V10;
public IBspStructReaders GetStructReaders( int bspVersion ) => _readers;
public bool MatchesMapName( string mapName ) => false;
public bool MatchesEntities( IReadOnlyList<string> entityClassNames ) => true;
}
using Editor.MovieMaker;
using System.IO.Compression;
namespace BspImport.Builder;
public class BspTreeParser
{
public class TreeParseResult
{
public List<ushort> FaceIndices = new();
}
private ImportContext Context { get; set; }
private List<int>[]? FaceLeavesLookup { get; set; }
public BspTreeParser( ImportContext context )
{
Context = context;
}
/// <summary>
/// Traverse the bsp tree to find the leaf index for a given point in world space.
/// </summary>
/// <param name="point"></param>
/// <returns>-1 if not found, otherwise the leaf index.</returns>
public int FindLeafIndex( Vector3 point )
{
int nodeIndex = 0; // Start at headnode (model 0)
while ( nodeIndex >= 0 )
{
var node = Context.Nodes![nodeIndex];
var plane = Context.Planes![node.PlaneIndex];
float distance = plane.Normal.x * point.x +
plane.Normal.y * point.y +
plane.Normal.z * point.z - plane.Distance;
nodeIndex = distance >= 0 ? node.Children[0] : node.Children[1];
}
return -1 - nodeIndex; // Convert negative leaf index to positive
}
/// <summary>
/// Get all unique Face indices from the BSP tree. Results represent render meshes, not brushes. Never brushes.
/// </summary>
/// <returns></returns>
public TreeParseResult GetUniqueWorldspawnFaces()
{
var result = new TreeParseResult();
if ( !Context.HasCompleteGeometry( out var geo ) )
return result;
var faces = new HashSet<ushort>();
ParseNodeFacesRecursively( 0, ref faces );
result.FaceIndices = faces.ToList();
return result;
}
private void ParseNodeFacesRecursively( int index, ref HashSet<ushort> faceIndices )
{
if ( Context.Nodes is null )
return;
MapNode node = Context.Nodes[index];
if ( Context.BuildSettings.CullSkybox && Context.SkyboxAreas.Contains( node.Area ) )
return;
// contribute to faces collection
for ( ushort i = 0; i < node.FaceCount; i++ )
{
ushort faceIndex = node.FirstFaceIndex;
faceIndex += i;
TryAddFace( faceIndex, ref faceIndices );
}
// gather faces from children
for ( int i = 0; i < 2; i++ )
{
var child = node.Children[i];
// 0 = no child
if ( child == 0 ) continue;
// <0 = leaf, not node
if ( child < 0 )
{
AddLeafFaces( -1 - child, ref faceIndices );
continue;
}
// parse child node recursively
ParseNodeFacesRecursively( child, ref faceIndices );
}
}
private void AddLeafFaces( int index, ref HashSet<ushort> faceIndices )
{
if ( Context.Leafs is null )
return;
if ( index >= Context.Leafs.Length )
return;
var leaf = Context.Leafs[index];
if ( leaf.WaterDataIndex != -1 )
return;
bool isWater = (leaf.Contents & ContentsFlags.Water) == ContentsFlags.Water;
if ( isWater )
return;
//var isWaterLeaf = leaf.WaterDataIndex != -1;
//var isSkyboxLeaf = (leaf.Flags & 0x01) != 0;
if ( Context.BuildSettings.CullSkybox && Context.SkyboxAreas.Contains( leaf.Area ) )
return;
// contribute to faces collection
for ( ushort i = 0; i < leaf.FaceCount; i++ )
{
ushort leafFaceIndex = leaf.FirstFaceIndex;
leafFaceIndex += i;
Context.Geometry.TryGetLeafFaceIndex( leafFaceIndex, out var faceIndex );
TryAddFace( faceIndex, ref faceIndices );
}
}
private bool TryAddFace( ushort faceIndex, ref HashSet<ushort> faceIndices )
{
if ( !Context.Geometry.TryGetFace( faceIndex, out var face ) )
return false;
if ( !faceIndices.Add( faceIndex ) )
return false;
return true;
}
}
using SkiaSharp;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace BspImport.Builder;
public partial class MapBuilder
{
/// <summary>
/// Builds cached PolygonMeshes for bsp models, skips index 0 (worldspawn).
/// </summary>
/// <param name="progress">Current progress section</param>
/// <param name="token">Progress cancellation token</param>
public async Task BuildModelMeshes( IProgressSection progress, CancellationToken token )
{
var modelCount = Context.Models?.Length ?? 0;
if ( modelCount <= 0 )
{
Log.Error( $"Unable to build bsp models, Context has no Models!" );
return;
}
Log.Info( $"Constructing {modelCount} Entity Models..." );
progress.Title = $"Constructing {modelCount} Entity Models...";
progress.TotalCount = modelCount;
progress.Current = 0;
var polyMeshes = new PolygonMesh[modelCount];
// i = 1 to skip 0 (worldspawn)
for ( int i = 1; i < modelCount; i++ )
{
if ( token.IsCancellationRequested )
return;
var polyMesh = ConstructModel( i );
progress.Current = i;
if ( polyMesh is null )
continue;
polyMeshes[i] = polyMesh;
await GameTask.Yield();
}
Context.CachedPolygonMeshes = polyMeshes;
}
/// <summary>
/// Find all areas with a sky_camera entity in them.
/// </summary>
private List<short> FindSkyboxAreas()
{
var result = new List<short>();
ArgumentNullException.ThrowIfNull( Context.Entities );
foreach ( var ent in Context.Entities )
{
if ( ent.ClassName != "sky_camera" )
continue;
var origin = ent.Position;
var leafIndex = TreeParse.FindLeafIndex( origin );
if ( leafIndex == -1 )
continue;
var leaf = Context.Leafs![leafIndex];
result.Add( leaf.Area );
}
return result;
}
/// <summary>
/// Constructs quad corners for a plane.
/// </summary>
/// <param name="p"></param>
private List<Vector3> BuildBasePolygon( Plane p )
{
var normal = p.Normal;
// pick a tangent
Vector3 up = Math.Abs( normal.z ) > 0.99f ? Vector3.Forward : Vector3.Up;
Vector3 right = Vector3.Cross( up, normal ).Normal;
up = Vector3.Cross( normal, right );
float size = 16384f; // big enough for BSP scale
Vector3 center = normal * p.Distance;
return new List<Vector3>
{
center + (-right - up) * size,
center + ( right - up) * size,
center + ( right + up) * size,
center + (-right + up) * size,
};
}
List<Vector3> ClipPolygon( List<Vector3> input, Plane plane )
{
var output = new List<Vector3>();
for ( int i = 0; i < input.Count; i++ )
{
var a = input[i];
var b = input[(i + 1) % input.Count];
float da = Vector3.Dot( plane.Normal, a ) - plane.Distance;
float db = Vector3.Dot( plane.Normal, b ) - plane.Distance;
const float EPS = 0.001f;
bool ina = da <= EPS;
bool inb = db <= EPS;
if ( ina && inb )
{
output.Add( b );
}
else if ( ina && !inb )
{
float t = da / (da - db);
output.Add( a + (b - a) * t );
}
else if ( !ina && inb )
{
float t = da / (da - db);
output.Add( a + (b - a) * t );
output.Add( b );
}
}
return output;
}
private void BuildClipBrushes( GameObject _parent )
{
if ( Context.Brushes is not null && Context.BrushSides is not null && Context.Planes is not null )
{
var clipBrushes = Context.Brushes.Where( b => b.IsClipBrush ).ToList();
if ( clipBrushes.Count == 0 )
return;
int count = 0;
var parent = new GameObject( _parent, true, "Clip Brushes" );
foreach ( var brush in clipBrushes )
{
if ( !brush.IsClipBrush )
continue;
var clipObject = new GameObject( parent, true, $"Clip Brush {count}" );
var clipMesh = new PolygonMesh();
// get brush sides
var firstSide = brush.FirstSide;
var numSides = brush.NumSides;
for ( int i = 0; i < numSides; i++ )
{
int sideIndex = firstSide + i;
var brushSide = Context.BrushSides[sideIndex];
var planeIndex = brushSide.PlaneNum;
var plane = Context.Planes[planeIndex];
// build quad
var poly = BuildBasePolygon( plane );
for ( int j = 0; j < numSides; j++ )
{
int otherSideIndex = firstSide + j;
if ( sideIndex == otherSideIndex )
continue;
var other = Context.Planes[Context.BrushSides[otherSideIndex].PlaneNum];
poly = ClipPolygon( poly, other );
if ( poly.Count == 0 )
{
break;
}
}
if ( poly.Count >= 3 )
{
var hVerts = clipMesh.AddVertices( poly.ToArray() );
var hFace = clipMesh.AddFace( hVerts );
clipMesh.SetFaceMaterial( hFace, "materials/tools/toolsclip.vmat" );
clipMesh.TextureAlignToGrid( Transform.Zero, hFace );
}
}
var meshComp = clipObject.Components.Create<MeshComponent>();
meshComp.Mesh = clipMesh;
meshComp.HideInGame = true;
meshComp.RenderType = ModelRenderer.ShadowRenderType.Off;
CenterMeshOrigin( meshComp );
count++;
}
}
}
/// <summary>
/// Builds the map world geometry of the current context. Brush entities require pre-built PolygonMeshes. See <see cref="BuildModelMeshes"/>.
/// </summary>
protected virtual async Task BuildWorldGeometry( GameObject parent, IProgressSection progress, int meshesPerFrame, CancellationToken token )
{
var displacementMeshes = await ConstructDisplacementMeshesAsync( token, progress, meshesPerFrame );
if ( token.IsCancellationRequested )
return;
var worldspawnMeshes = await ConstructWorldspawnMeshes( token, progress );
if ( token.IsCancellationRequested )
return;
Log.Info( "Building World..." );
progress.Title = "Building World...";
progress.TotalCount = displacementMeshes.Count + worldspawnMeshes.Count;
if ( displacementMeshes.Count >= 0 )
{
var displacementParent = new GameObject( parent, true, "Displacements" );
int count = 0;
progress.Subtitle = $"Building {displacementMeshes.Count} Displacement Meshes";
foreach ( var displacement in displacementMeshes )
{
try
{
if ( token.IsCancellationRequested )
{
return;
}
progress.Current = count;
ConstructMesh( displacementParent, $"Displacement {count}", displacement );
count++;
if ( count % meshesPerFrame == 0 )
{
await GameTask.Yield();
}
}
catch ( Exception )
{
Log.Error( "Failed building displacement!" );
continue;
}
}
}
if ( worldspawnMeshes.Count >= 0 )
{
var meshParent = new GameObject( parent, true, "Meshes" );
int count = 0;
progress.Subtitle = $"Building {worldspawnMeshes.Count} World Meshes";
foreach ( var meshResult in worldspawnMeshes )
{
if ( token.IsCancellationRequested )
{
return;
}
var mesh = meshResult.Mesh;
if ( mesh is null )
continue;
var meshName = $"Mesh {count}";
if ( meshResult.IsWater )
{
meshName = $"Water Mesh";
}
var meshComp = ConstructMesh( meshParent, meshName, mesh );
meshComp.Collision = meshResult.IsWater ? MeshComponent.CollisionType.None : MeshComponent.CollisionType.Mesh;
progress.Current = count + displacementMeshes.Count;
count++;
if ( count % meshesPerFrame == 0 )
{
await GameTask.Yield();
}
}
}
BuildClipBrushes( parent );
}
private MeshComponent ConstructMesh( GameObject parent, string name, PolygonMesh mesh )
{
using var scope = parent.Scene.Push();
var meshObj = new GameObject( parent, true, name );
var meshComp = meshObj.Components.Create<MeshComponent>();
meshComp.Mesh = mesh;
CenterMeshOrigin( meshComp );
return meshComp;
}
static void CenterMeshOrigin( MeshComponent meshComponent )
{
if ( !meshComponent.IsValid() ) return;
var mesh = meshComponent.Mesh;
if ( mesh is null ) return;
var children = meshComponent.GameObject.Children
.Select( x => (GameObject: x, Transform: x.WorldTransform) )
.ToArray();
var world = meshComponent.WorldTransform;
var bounds = mesh.CalculateBounds( world );
var center = bounds.Center;
var localCenter = world.PointToLocal( center );
meshComponent.WorldPosition = center;
meshComponent.Mesh.ApplyTransform( new Transform( -localCenter ) );
meshComponent.RebuildMesh();
foreach ( var child in children )
{
child.GameObject.WorldTransform = child.Transform;
}
}
private async Task<List<PolygonMesh>> ConstructDisplacementMeshesAsync( CancellationToken token, IProgressSection progress, int meshesPerFrame = 16 )
{
// gather unique displacement face indices
HashSet<ushort> dispIndices = new();
for ( short i = 0; i < Context.Geometry.DisplacementInfoCount; i++ )
{
Context.Geometry.TryGetDisplacementInfo( i, out var dispInfo );
dispIndices.Add( dispInfo.MapFace );
}
var displacements = new List<PolygonMesh>();
if ( dispIndices.Count == 0 )
return displacements;
Log.Info( "Constructing Displacement Meshes..." );
progress.Title = "Constructing Displacement Meshes...";
progress.TotalCount = dispIndices.Count;
int count = 0;
foreach ( ushort dispFaceIndex in dispIndices )
{
if ( token.IsCancellationRequested )
return displacements;
var dispOrigin = DisplacementHelper.GetDisplacementOrigin( Context, dispFaceIndex );
var dispLeafIndex = TreeParse.FindLeafIndex( dispOrigin!.Value );
var dispLeaf = Context.Leafs![dispLeafIndex];
if ( Context.BuildSettings.CullSkybox && Context.SkyboxAreas.Contains( dispLeaf.Area ) )
continue;
// create one mesh per displacement
var dispMesh = DisplacementHelper.CreateDisplacementMesh( Context, dispFaceIndex );
if ( dispMesh is null )
continue;
if ( dispMesh.FaceHandles.Any() )
{
displacements.Add( dispMesh );
}
progress.Current = count;
count++;
if ( count % meshesPerFrame == 0 )
{
await GameTask.Yield();
}
}
return displacements;
}
public static Vector2 GetTexCoords( ImportContext context, int texInfoIndex, Vector3 position, int width = 1024, int height = 1024 )
{
// validate texinfo availability and index
if ( context.TexInfo is null || texInfoIndex < 0 || texInfoIndex >= context.TexInfo.Length )
return default;
var ti = context.TexInfo[texInfoIndex];
if ( context.TexData is not null && ti.TexData >= 0 && ti.TexData < context.TexData.Length )
{
var texData = context.TexData[ti.TexData];
width = texData.Width;
height = texData.Height;
}
return ti.GetUvs( position, width, height );
}
private bool IsWaterSurface( ushort faceIndex )
{
if ( !Context.HasCompleteGeometry( out var geo ) )
return false;
if ( !geo.TryGetFace( faceIndex, out var face ) )
return false;
var surfaceFlags = face.GetSurfaceFlags( Context );
return (surfaceFlags & SurfaceFlags.Warp) != 0;
}
public class WorldspawnMesh
{
public PolygonMesh? Mesh { get; set; }
public bool IsTranslucent { get; set; }
public bool IsWater { get; set; }
}
/// <summary>
/// Construct PolygonMeshes from the bsp-tree, chunked into individual Meshes based on Settings.ChunkSize and surface properties such as Translucent or Water.
/// </summary>
/// <returns></returns>
private async Task<List<WorldspawnMesh>> ConstructWorldspawnMeshes( CancellationToken token, IProgressSection progress )
{
var geo = Context.Geometry;
var meshes = new List<WorldspawnMesh>();
if ( !Context.HasCompleteGeometry( out geo ) )
{
Log.Error( $"Failed constructing worldspawn geometry! No valid geometry in Context!" );
return meshes;
}
// construct world mesh faces from bsp tree
var result = TreeParse.GetUniqueWorldspawnFaces();
var faceIndices = result.FaceIndices;
var waterFaces = faceIndices.Where( fi => IsWaterSurface( fi ) ).ToList();
var solidFaces = faceIndices.Where( fi => !IsWaterSurface( fi ) ).ToList();
if ( solidFaces.Count == 0 )
{
Log.Error( $"Failed constructing worldspawn geometry! No faces in tree!" );
return meshes;
}
// spawn solid geometry first
var chunks = solidFaces.Chunk( Context.BuildSettings.ChunkSize );
if ( token.IsCancellationRequested )
return meshes;
Log.Info( "Constructing World Meshes..." );
progress.Title = "Constructing World Meshes...";
progress.TotalCount = chunks.Count();
progress.Current = 0;
// chunk tree faces into batches for MeshComponent
foreach ( var chunk in chunks )
{
if ( token.IsCancellationRequested )
return meshes;
var mesh = new PolygonMesh();
foreach ( var face in chunk )
{
if ( token.IsCancellationRequested )
return meshes;
if ( !geo.TryGetFace( face, out var _ ) )
continue;
mesh.AddMeshFace( Context, face );
}
progress.Current++;
if ( mesh.FaceHandles.Any() )
{
var meshResult = new WorldspawnMesh()
{
Mesh = mesh,
IsTranslucent = false,
IsWater = false
};
meshes.Add( meshResult );
}
await GameTask.Yield();
}
// add water surfaces as a mesh
var waterMesh = new PolygonMesh();
foreach ( var face in waterFaces )
{
waterMesh.AddMeshFace( Context, face );
}
if ( waterMesh.FaceHandles.Any() )
{
var meshResult = new WorldspawnMesh()
{
Mesh = waterMesh,
IsTranslucent = true,
IsWater = true
};
meshes.Add( meshResult );
}
return meshes;
}
/// <summary>
/// Construct a PolygonMesh from a bsp model index.
/// </summary>
/// <param name="modelIndex"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
private PolygonMesh? ConstructModel( int modelIndex )
{
// return already cached mesh
if ( Context.CachedPolygonMeshes?[modelIndex] is not null )
{
return Context.CachedPolygonMeshes[modelIndex];
}
var geo = Context.Geometry;
if ( !Context.HasCompleteGeometry( out geo ) )
{
throw new Exception( "No valid map geometry to construct!" );
}
if ( Context.Models is null )
{
throw new Exception( "No valid models to construct!" );
}
if ( modelIndex < 0 || modelIndex >= Context.Models.Length )
{
throw new Exception( $"Tried to construct map model with index: {modelIndex}. Exceeds available Models!" );
}
var model = Context.Models[modelIndex];
return ConstructPolygonMesh( model.FirstFace, model.FaceCount );
}
/// <summary>
/// Construct a PolygonMesh from a firstFace index and face count.
/// </summary>
/// <param name="firstFaceIndex"></param>
/// <param name="faceCount"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
private PolygonMesh? ConstructPolygonMesh( int firstFaceIndex, int faceCount )
{
if ( faceCount <= 0 )
return null;
//Log.Info( $"construct poly mesh: [{firstFaceIndex}, {faceCount}]" );
var geo = Context.Geometry;
if ( !geo.IsValid() )
{
throw new Exception( "No valid map geometry to construct!" );
}
// models support int firstFace and faceCount for some reason, but faces are limited to ushort
var faces = GetFaceIndices( (ushort)firstFaceIndex, (ushort)faceCount );
// invalid world mesh
if ( faces.Length <= 0 )
return null;
// build all split faces
var polyMesh = new PolygonMesh();
foreach ( ushort faceIndex in faces )
{
polyMesh.AddMeshFace( Context, faceIndex );
}
return polyMesh;
}
/// <summary>
/// Gather all unique face indices from a firstFace index and a face count. Skips displacement faces.
/// </summary>
/// <param name="firstFaceIndex"></param>
/// <param name="faceCount"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
private ushort[] GetFaceIndices( ushort firstFaceIndex, ushort faceCount )
{
var geo = Context.Geometry;
if ( !geo.IsValid() )
{
throw new Exception( "No valid map geometry to construct!" );
}
var faces = new HashSet<ushort>();
for ( ushort i = 0; i < faceCount; i++ )
{
var faceIndex = firstFaceIndex;
faceIndex += i;
geo.TryGetFace( faceIndex, out var face );
// skip faces with invalid area
if ( face.Area <= 0 || face.Area.AlmostEqual( 0 ) )
{
//Log.Info( $"skipping face with invalid area: {faceIndex}" );
continue;
}
// skip displacement faces, is this needed anymore?
if ( face.DisplacementInfo >= 0 )
{
continue;
}
faces.Add( faceIndex );
}
return faces.ToArray();
}
}
using Editor;
using Editor.NodeEditor;
using ExtendedEditor.Attributes;
using ExtendedEditor.TypeLibraryFixes;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
namespace ExtendedEditor.ControlWidgets;
public class TypeSelectControlWidget : ControlWidget
{
public override bool IsControlButton => true;
public override bool IsControlHovered => base.IsControlHovered || _menu.IsValid();
public override bool SupportsMultiEdit => false;
private Menu? _menu;
private bool CanSetNone => IsValidType(null);
private readonly bool _whitelistedOnly;
private Type? _targetTypeAttributeType;
private TypeSelectAttribute? _limiter;
public TypeSelectControlWidget(SerializedProperty property) : this(property, true)
{
}
public TypeSelectControlWidget(SerializedProperty property, bool whitelistedOnly = true) : base(property.Fix())
{
_whitelistedOnly = whitelistedOnly;
Cursor = CursorShape.Finger;
Layout = Layout.Row();
Layout.Spacing = 2;
UpdateLimiters();
if(!CanSetNone)
{
var value = SerializedProperty.GetValue<Type>();
if(value is null)
{
value = GetPossibleTypes().FirstOrDefault();
if(value is not null)
SerializedProperty.SetValue(value);
}
}
}
[EditorEvent.Hotload]
private void UpdateLimiters()
{
var attributes = SerializedProperty.GetAttributes();
_targetTypeAttributeType = attributes?.OfType<TargetTypeAttribute>().FirstOrDefault()?.Type;
_limiter = attributes?.OfType<TypeSelectAttribute>().FirstOrDefault();
if(_limiter?.ValidatorName is not null)
{
_limiter = new TypeSelectAttribute(_limiter);
_limiter.FindAndAppendValidatorMethod(SerializedProperty);
}
}
protected override void PaintControl()
{
var value = SerializedProperty.GetValue<Type>();
var color = IsControlHovered ? Theme.Blue : Theme.TextControl;
var rect = LocalRect;
rect = rect.Shrink(8, 0);
var desc = value is not null ? TypeLibrary.GetType(value) : null;
var title = "None";
if(desc is not null)
{
title = desc.Title;
if(value!.IsGenericType)
{
var parameters = value.GetGenericArguments();
title += $" <{string.Join(", ", parameters.Select(x => x.Name))}>";
}
}
Paint.SetPen(color);
Paint.DrawText(rect, title, TextFlag.LeftCenter);
Paint.SetPen(color);
Paint.DrawIcon(rect, "Arrow_Drop_Down", 17, TextFlag.RightCenter);
}
protected override void OnMousePress(MouseEvent e)
{
base.OnMousePress(e);
if(e.LeftMouseButton && !_menu.IsValid())
{
OpenMenu();
}
}
public IEnumerable<Type> GetPossibleTypes() => GetPossibleTypes(IsValidType);
private bool IsValidType(Type? type) => IsValidType(type, _targetTypeAttributeType, _limiter, _whitelistedOnly);
public static IEnumerable<Type> GetPossibleTypes(IEnumerable<Attribute> attributes)
{
var targetTypeAttributeType = attributes.OfType<TargetTypeAttribute>().FirstOrDefault()?.Type;
var limiter = attributes.OfType<TypeSelectAttribute>().FirstOrDefault();
return GetPossibleTypes(t => IsValidType(t, targetTypeAttributeType, limiter, whitelistedOnly: true));
}
public static IEnumerable<Type> GetPossibleTypes(Func<Type?, bool> typeValidator)
{
var listedTypes = new HashSet<Type>();
var allTypes = TypeResolver.SystemTypes.Concat(
TypeLibrary.GetTypes().Select(x => x.TargetType)
);
foreach(var type in allTypes)
{
if(!typeValidator(type))
continue;
if(!listedTypes.Add(type))
continue;
yield return type;
}
}
private static bool IsValidType(Type? type, Type? baseType, TypeSelectAttribute? limiter, bool whitelistedOnly)
{
if(type is not null)
{
if(type.IsAbstract && type.IsSealed) // is static
return false;
if(type.CustomAttributes.Any(x => x.AttributeType == typeof(CompilerGeneratedAttribute)))
return false;
if(type.Name.StartsWith('<') || type.Name.StartsWith('_'))
return false;
if(baseType is not null && !type.IsAssignableTo(baseType))
return false;
if(whitelistedOnly && TypeLibrary.GetType(type) is null)
return false;
}
if(limiter is not null && !limiter.IsAllowed(type))
return false;
return true;
}
public Menu CreateMenu(Action<Type?>? action = null)
{
var types = GetPossibleTypes().Select(x => new TypeResolver.TypeOption(x)).ToArray();
var menu = new ContextMenu(null);
bool canSetNone = CanSetNone;
menu.AddLineEdit("Filter",
placeholder: "Filter Types..",
autoFocus: true,
onChange: s => PopulateTypeMenu(menu, types, action, s, canSetNone));
menu.AboutToShow += () =>
{
PopulateTypeMenu(menu, types, action, canSetNone: canSetNone);
};
return menu;
}
private void OpenMenu()
{
_menu = CreateMenu(type =>
{
PropertyStartEdit();
SerializedProperty.SetValue(type);
PropertyFinishEdit();
SignalValuesChanged();
});
_menu.DeleteOnClose = true;
_menu.OpenAtCursor(true);
_menu.MinimumWidth = ScreenRect.Width;
}
private static void PopulateTypeMenu(Menu menu, IEnumerable<TypeResolver.TypeOption> types, Action<Type?>? action, string? filter = null, bool canSetNone = false)
{
menu.RemoveMenus();
menu.RemoveOptions();
foreach(var widget in menu.Widgets.Skip(1))
{
menu.RemoveWidget(widget);
}
if(canSetNone)
{
menu.AddOption("None", "cancel", () => action?.Invoke(null));
}
const int maxFiltered = 10;
var useFilter = !string.IsNullOrEmpty(filter);
var truncated = 0;
if(useFilter)
{
var filtered = types.Where(x => x.Type.Name.Contains(filter!, StringComparison.OrdinalIgnoreCase)).ToArray();
if(filtered.Length > maxFiltered + 1)
{
truncated = filtered.Length - maxFiltered;
types = filtered.Take(maxFiltered);
}
else
{
types = filtered;
}
}
menu.AddOptions(types, x => x.Path, x => action?.Invoke(x.Type), flat: useFilter);
if(truncated > 0)
{
menu.AddOption($"...and {truncated} more");
}
menu.AdjustSize();
menu.Update();
}
}using Facepunch.ActionGraphs;
using Sandbox;
using Sandbox.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace ExtendedBox;
public sealed class ActionBasedSerializedProperty : SerializedProperty
{
public Func<SerializedProperty, SerializedObject>? PropertyToObject;
private readonly string _name;
private readonly string _title;
private readonly string _description;
private readonly string _groupName;
private readonly string _sourceFile;
private readonly int _sourceLine;
private readonly Func<object> _get;
private readonly Action<object> _set;
private readonly List<Attribute> _attributes;
private readonly SerializedObject _parent;
public override SerializedObject Parent => _parent;
public override bool IsMethod => false;
public override string Name => _name;
public override string DisplayName => _title;
public override string Description => _description;
public override string GroupName => _groupName;
public override bool IsEditable => true;
public override int Order => 0;
public override Type PropertyType { get; }
public override string SourceFile => _sourceFile;
public override int SourceLine => _sourceLine;
public override bool HasChanges => false;
public override ref AsAccessor As => ref base.As;
public ActionBasedSerializedProperty(Type type, string name, string title, string description, Func<object> get, Action<object> set, IEnumerable<Attribute> attributes, SerializedObject parent)
{
PropertyType = type;
_name = name;
_title = title;
_description = description;
_get = get;
_set = set;
_attributes = new List<Attribute>(attributes ?? []);
_parent = parent;
_groupName = _attributes.OfType<IGroupAttribute>().FirstOrDefault()?.Value ?? _groupName!;
_groupName = _attributes.OfType<ICategoryProvider>().FirstOrDefault()?.Value ?? _groupName;
_description = _attributes.OfType<IDescriptionAttribute>().FirstOrDefault()?.Value ?? _description;
_title = _attributes.OfType<ITitleProvider>().FirstOrDefault()?.Value ?? _title;
_sourceFile = _attributes.OfType<ISourcePathProvider>().FirstOrDefault()?.Path ?? _sourceFile!;
_sourceLine = _attributes.OfType<ISourceLineProvider>().FirstOrDefault()?.Line ?? _sourceLine;
}
public override U GetValue<U>(U defaultValue = default!)
{
return ValueToType<U>(_get());
}
public override void SetValue<U>(U value)
{
_set(ValueToType(PropertyType, value)!);
}
public override IEnumerable<Attribute> GetAttributes()
{
return _attributes;
}
public override bool TryGetAsObject(out SerializedObject obj)
{
obj = PropertyToObject?.Invoke(this) ?? null!;
return obj != null;
}
private object? ValueToType(Type targetType, object value, object? defaultValue = null)
{
try
{
if(value == null)
{
return GetDefaultValue();
}
if(value.GetType().IsAssignableTo(targetType))
return value;
if(targetType == typeof(string))
return Convert.ChangeType($"{value}", targetType);
if(value is string str)
return JsonSerializer.Deserialize(str, targetType);
if(targetType.IsEnum && value is IConvertible)
{
try
{
return Enum.ToObject(targetType, Convert.ToInt64(value));
}
catch
{
return GetDefaultValue();
}
}
object obj2 = Convert.ChangeType(value, targetType);
if(obj2 != null)
return obj2;
return JsonSerializer.SerializeToElement(value).Deserialize(targetType);
}
catch(Exception)
{
return GetDefaultValue();
}
object? GetDefaultValue()
{
if(defaultValue is null || !defaultValue.GetType().IsAssignableTo(targetType))
{
if(targetType.IsValueType)
return Activator.CreateInstance(targetType)!;
return null;
}
return defaultValue;
}
}
}
using Editor;
using ExtendedBox.Editors.Attributes;
using ExtendedBox.General;
using Sandbox;
namespace ExtendedBox;
[CustomEditor(typeof(float), WithAllAttributes = [typeof(HighPrecisionAttribute)])]
[CustomEditor(typeof(decimal), WithAllAttributes = [typeof(HighPrecisionAttribute)])]
[CustomEditor(typeof(double), WithAllAttributes = [typeof(HighPrecisionAttribute)])]
public class HighPrecisionFloatControlWidget : FloatControlWidget
{
public HighPrecisionFloatControlWidget(SerializedProperty property) : base(property)
{
}
protected override string ValueToString()
{
double num = SerializedProperty.As.Double;
if(num == 0.0)
num = 0.0;
string format = "0.#######";
if(SerializedProperty.TryGetAttribute<HighPrecisionAttribute>(out var attribute))
format = attribute.Format;
return num.ToString(format);
}
}
[CustomEditor(typeof(Vector2), WithAllAttributes = [typeof(HighPrecisionAttribute)])]
[CustomEditor(typeof(Vector3), WithAllAttributes = [typeof(HighPrecisionAttribute)])]
[CustomEditor(typeof(Vector4), WithAllAttributes = [typeof(HighPrecisionAttribute)])]
public class HighPrecisionVectorControlWidget : ControlObjectWidget
{
private FloatControlWidget? _firstControl;
public override bool SupportsMultiEdit => true;
public HighPrecisionVectorControlWidget(SerializedProperty property)
: base(property, true)
{
Layout = Layout.Row();
Layout.Spacing = 2f;
_firstControl = TryAddField("x", Theme.Red, "X");
TryAddField("y", Theme.Green, "Y");
TryAddField("z", Theme.Blue, "Z");
TryAddField("w", Theme.Yellow, "W");
}
private FloatControlWidget? TryAddField(string propertyName, Color color, string text)
{
SerializedProperty property = SerializedObject.GetProperty(propertyName);
if(property == null)
return null;
FloatControlWidget floatControlWidget = Layout.Add(new HighPrecisionFloatControlWidget(property)
{
HighlightColor = color,
Label = text
});
floatControlWidget.MinimumWidth = Theme.RowHeight;
floatControlWidget.HorizontalSizeMode = SizeMode.Expand | SizeMode.CanGrow;
return floatControlWidget;
}
public override void StartEditing()
{
_firstControl?.StartEditing();
}
protected override void OnPaint()
{
}
}
[CustomEditor(typeof(BBox), WithAllAttributes = [typeof(HighPrecisionAttribute)])]
[CustomEditor(typeof(BBox2), WithAllAttributes = [typeof(HighPrecisionAttribute)])]
public class HighPrecisionBBoxControlWidget : ControlObjectWidget
{
public override bool SupportsMultiEdit => true;
public HighPrecisionBBoxControlWidget(SerializedProperty property)
: base(property, true)
{
PaintBackground = false;
Layout = Layout.Column();
Layout.Spacing = 2f;
Layout.Add(new HighPrecisionVectorControlWidget(SerializedObject.GetProperty("Mins")));
Layout.Add(new HighPrecisionVectorControlWidget(SerializedObject.GetProperty("Maxs")));
}
}
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Editor.Assets;
namespace Editor;
[AssetPreview( "fencedef" )]
public sealed class FenceDefinitionAssetPreview : AssetPreview
{
private static readonly Color GuideColor = Color.Parse( "#f8a64c" ) ?? Color.Orange;
private FenceDefinition definition;
private GameObject previewRoot;
private readonly List<FenceGuideSceneObject> guides = [];
private int lastHash;
public FenceDefinitionAssetPreview( Asset asset ) : base( asset )
{
}
public override float PreviewWidgetCycleSpeed => 0.0f;
public override async Task InitializeAsset()
{
await base.InitializeAsset();
definition = Asset.LoadResource<FenceDefinition>();
if ( definition is null )
return;
using ( Scene.Push() )
{
RebuildPreview();
}
}
public override void UpdateScene( float cycle, float timeStep )
{
if ( definition is not null )
{
var currentHash = FenceDefinitionEditorUtility.ComputeDefinitionHash( definition );
if ( currentHash != lastHash )
{
using ( Scene.Push() )
{
RebuildPreview();
}
}
}
using ( Scene.Push() )
{
var rotation = new Angles( 18.0f, 225.0f, 0.0f ).ToRotation();
var distance = MathX.SphereCameraDistance( Math.Max( SceneSize.Length * 0.5f, 1.0f ), Camera.FieldOfView );
var aspect = ScreenSize.y > 0 ? (float)ScreenSize.x / ScreenSize.y : 1.0f;
if ( aspect > 1.0f )
{
distance *= aspect;
}
Camera.WorldRotation = rotation;
Camera.WorldPosition = SceneCenter + rotation.Forward * -distance;
}
TickScene( timeStep );
}
public override void Dispose()
{
using ( Scene?.Push() )
{
ClearPreview();
}
base.Dispose();
}
private void RebuildPreview()
{
FenceGuideSceneObject.EnsureAssetsLoaded();
ClearPreview();
previewRoot = new GameObject( true, "Fence Preview" );
PrimaryObject = previewRoot;
lastHash = FenceDefinitionEditorUtility.ComputeDefinitionHash( definition );
var prototypes = FenceDefinitionEditorUtility.BuildPrototypes( definition );
var cursor = 0.0f;
const float gap = 16.0f;
var hasBounds = false;
var bounds = BBox.FromPositionAndSize( Vector3.Zero, 8.0f );
foreach ( var prototype in prototypes )
{
var segmentRoot = SpawnPreviewSegment( prototype );
if ( !segmentRoot.IsValid() )
continue;
segmentRoot.SetParent( previewRoot, true );
var range = FenceDefinitionEditorUtility.MeasureProjectedRange( segmentRoot.GetBounds(), Vector3.Forward );
segmentRoot.WorldPosition += Vector3.Forward * (cursor - range.Min);
var segmentBounds = segmentRoot.GetBounds();
var guideZ = segmentBounds.Mins.z - 2.0f;
var guideStart = new Vector3( cursor, segmentBounds.Center.y, guideZ );
var guideEnd = guideStart + Vector3.Forward * prototype.CanonicalLength;
guides.Add( new FenceGuideSceneObject( Scene.SceneWorld, guideStart, guideEnd, GuideColor ) );
if ( !hasBounds )
{
bounds = segmentBounds;
hasBounds = true;
}
else
{
bounds = bounds.AddBBox( segmentBounds );
}
bounds = bounds.AddPoint( guideStart );
bounds = bounds.AddPoint( guideEnd );
cursor += prototype.CanonicalLength + gap;
}
SceneCenter = hasBounds ? bounds.Center : Vector3.Zero;
SceneSize = hasBounds ? bounds.Size : new Vector3( 64.0f, 64.0f, 64.0f );
}
private GameObject SpawnPreviewSegment( FencePrototype prototype )
{
if ( prototype is null )
return null;
var plan = new FencePlacementPlan
{
RootName = prototype.DisplayName,
Origin = Vector3.Zero,
Direction = Vector3.Forward,
TotalLength = prototype.CanonicalLength,
Segments =
[
new FencePlacementSegment
{
Prototype = prototype,
LinePoint = Vector3.Zero,
SurfacePoint = Vector3.Zero,
Direction = Vector3.Forward,
Up = Vector3.Up,
Length = prototype.CanonicalLength,
StartDistance = 0.0f,
EndDistance = prototype.CanonicalLength
}
]
};
var placed = FenceDefinitionEditorUtility.CommitPlacementPlan( plan );
if ( !placed.IsValid() )
return null;
var segment = placed.Children.Count > 0 ? placed.Children[0] : null;
if ( segment.IsValid() )
{
segment.Parent = null;
}
placed.Destroy();
return segment;
}
private void ClearPreview()
{
FenceDefinitionEditorUtility.DestroyHierarchy( previewRoot );
previewRoot = null;
PrimaryObject = null;
foreach ( var guide in guides )
{
guide?.Delete();
}
guides.Clear();
}
}
internal sealed class FenceGuideSceneObject : SceneCustomObject
{
private static Material lineMaterial;
private readonly Vertex[] vertices;
internal static void EnsureAssetsLoaded()
{
lineMaterial ??= Material.Load( "materials/gizmo/line.vmat" );
}
public FenceGuideSceneObject( SceneWorld world, Vector3 start, Vector3 end, Color color ) : base( world )
{
var direction = (end - start).Normal;
var arrowLength = Math.Max( (end - start).Length * 0.12f, 4.0f );
var arrowBase = end - direction * arrowLength;
var side = Vector3.Cross( direction, Vector3.Up ).Normal * Math.Max( arrowLength * 0.45f, 1.5f );
vertices =
[
new Vertex( start, color ),
new Vertex( end, color ),
new Vertex( end, color ),
new Vertex( arrowBase + side, color ),
new Vertex( end, color ),
new Vertex( arrowBase - side, color )
];
var bounds = BBox.FromPositionAndSize( start, 1.0f );
bounds = bounds.AddPoint( end );
bounds = bounds.AddPoint( arrowBase + side );
bounds = bounds.AddPoint( arrowBase - side );
Bounds = bounds;
}
public override void RenderSceneObject()
{
if ( lineMaterial is null )
return;
Graphics.Draw( vertices.AsSpan(), vertices.Length, lineMaterial, Attributes, Graphics.PrimitiveType.Lines );
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
public class SearchResult
{
public string Title;
public string Subtitle;
public string Type;
public string Icon;
public float Score;
public Action OnSelect;
}
public sealed class IndexerPopup : Window
{
private LineEdit Search;
private ListView Results;
private List<ISearchProvider> Providers = new();
public static Action PendingAction;
public IndexerPopup()
{
IsDialog = true;
WindowTitle = "Indexer";
Size = new Vector2( 1200, 400 );
Canvas = new Widget( null );
Canvas.Layout = Layout.Column();
Canvas.Layout.Spacing = 4;
Canvas.Layout.Margin = 8;
Search = Canvas.Layout.Add( new LineEdit( "", this ) );
Search.PlaceholderText = "Search assets, objects, actions...";
Search.TextChanged += OnSearchChanged;
Results = Canvas.Layout.Add( new ListView( this ) );
Results.ItemSize = new Vector2( 0, 32 );
Results.ItemPaint = PaintResultItem;
Results.ItemClicked += OnResultClicked;
Providers.Add( new AssetSearchProvider() );
Providers.Add( new SceneObjectsProvider() );
Providers.Add( new ComponentSearchProvider() );
Providers.Add( new EditorActionProvider() );
Providers.Add( new MathProvider() );
Providers.Add( new ColorProvider() );
Show();
Search.Focus();
}
protected override void OnKeyPress( KeyEvent e )
{
if ( e.Key == KeyCode.Down )
{
Results.SelectMoveRow( 1 );
e.Accepted = true;
return;
}
if ( e.Key == KeyCode.Up )
{
Results.SelectMoveRow( -1 );
e.Accepted = true;
return;
}
if ( e.Key == KeyCode.Enter || e.Key == KeyCode.Return )
{
var selected = Results.SelectedItems.FirstOrDefault() as SearchResult;
selected?.OnSelect?.Invoke();
Close();
e.Accepted = true;
return;
}
if ( e.Key == KeyCode.Escape )
{
Close();
e.Accepted = true;
return;
}
base.OnKeyPress( e );
}
private void OnResultClicked( object obj )
{
if ( obj is SearchResult result )
{
Close();
}
}
private void OnSearchChanged( string query )
{
if ( string.IsNullOrWhiteSpace( query ) )
{
Results.SetItems( Array.Empty<SearchResult>() );
return;
}
var results = Providers.SelectMany( p => p.Search( query ) ).OrderByDescending( r => r.Score ).Take( 50 )
.ToList();
Results.SetItems( results );
}
void PaintResultItem( VirtualWidget vw )
{
var item = vw.Object as SearchResult;
if ( item == null ) return;
var rect = vw.Rect;
if ( Paint.HasSelected )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Primary );
var bg = rect.Shrink( 1f );
Paint.DrawRect( in bg, Theme.ControlRadius );
}
else if ( Paint.HasMouseOver )
{
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
var bg = rect.Shrink( 1f );
Paint.DrawRect( in bg, Theme.ControlRadius );
}
var inner = rect.Shrink( 8f, 0f, 8f, 0f );
var iconRect = inner;
iconRect.Width = inner.Height;
var iconColor = Paint.HasSelected ? Theme.TextSelected : Theme.TextLight;
Paint.SetPen( in iconColor );
Paint.DrawIcon( iconRect, item.Icon, inner.Height * 0.55f );
if ( item.Type == "Color" )
{
var parsed = Color.Parse( item.Title );
if ( parsed.HasValue )
{
var swatchRect = new Rect( iconRect.Left, iconRect.Top + 4f, iconRect.Width, iconRect.Height - 8f );
Paint.ClearPen();
Paint.SetBrush( parsed.Value );
Paint.DrawRect( in swatchRect, 4f );
}
}
var textRect = inner;
textRect.Left = iconRect.Right + 6f;
textRect.Right = inner.Right - inner.Width * 0.5f;
var titleColor = Paint.HasSelected ? Theme.TextSelected : Theme.Text;
Paint.SetPen( in titleColor );
Paint.SetFont( Theme.DefaultFont, 9f, 500 );
Paint.DrawText( in textRect, item.Title, TextFlag.LeftCenter );
var typeRect = inner;
typeRect.Left = textRect.Right + 4f;
typeRect.Right = inner.Right - inner.Width * 0.25f;
var typeColor = Paint.HasSelected
? Theme.TextSelected.WithAlpha( 0.5f )
: Theme.TextDisabled;
Paint.SetPen( in typeColor );
Paint.SetFont( Theme.DefaultFont, 8f, 400 );
Paint.DrawText( in typeRect, item.Type, TextFlag.Center );
var subRect = inner;
subRect.Left = typeRect.Right + 4f;
var subColor = Paint.HasSelected
? Theme.TextSelected.WithAlpha( 0.6f )
: Theme.TextDisabled;
Paint.SetPen( in subColor );
Paint.SetFont( Theme.DefaultFont, 8f, 400 );
Paint.DrawText( in subRect, item.Subtitle, TextFlag.RightCenter );
}
[EditorEvent.Frame]
public static void ProcessPending()
{
if ( PendingAction == null ) return;
var action = PendingAction;
PendingAction = null;
action();
}
[Shortcut( "editor.indexer", "CTRL+P" )]
public static void OpenIndexer()
{
new IndexerPopup();
}
}
namespace SboxMcp;
/// <summary>
/// Captures recent Log output so editor.console_output can return it.
/// Appends entries via AddEntry() — call that from any log hook or manually.
/// </summary>
public static class ConsoleCapture
{
private static readonly List<string> _entries = new();
private static readonly object _lock = new();
private const int MaxEntries = 200;
private static bool _hooked;
/// <summary>
/// Call once during addon initialisation to start capturing log output.
/// Safe to call multiple times — only hooks once.
/// </summary>
public static void EnsureHooked()
{
if ( _hooked )
return;
_hooked = true;
// s&box Logger does not expose an OnEntry event.
// Entries are added manually via AddEntry() from handlers.
Log.Info( "[MCP] ConsoleCapture initialised (manual capture mode)" );
}
/// <summary>
/// Manually append a line (e.g. from ExecutionHandler output).
/// </summary>
public static void AddEntry( string line )
{
lock ( _lock )
{
_entries.Insert( 0, line );
if ( _entries.Count > MaxEntries )
_entries.RemoveAt( _entries.Count - 1 );
}
}
/// <summary>
/// Returns a snapshot of recent log lines (newest-first).
/// </summary>
public static List<string> GetRecent()
{
lock ( _lock )
{
return new List<string>( _entries );
}
}
}
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
namespace SboxMcp.Mcp.Docs;
internal sealed class ApiCacheManifest
{
public int Version { get; set; } = 1;
public string SchemaUrl { get; set; } = "";
public long FetchedAt { get; set; }
public int TypeCount { get; set; }
}
public sealed class ApiCache
{
private const int CacheVersion = 1;
private const int DefaultTtlSeconds = 86400;
private readonly string _cacheDir;
private readonly string _manifestPath;
private readonly string _typesPath;
private readonly long _ttlMs;
private ApiCacheManifest _manifest = new();
public ApiCache()
{
_cacheDir = Environment.GetEnvironmentVariable( "SBOX_DOCS_CACHE_DIR" )
?? Path.Combine(
Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ),
".sbox-mcp", "cache" );
_manifestPath = Path.Combine( _cacheDir, "api-manifest.json" );
_typesPath = Path.Combine( _cacheDir, "api-types.json" );
var ttlSeconds = int.TryParse(
Environment.GetEnvironmentVariable( "SBOX_API_CACHE_TTL" ),
out var t ) ? t : DefaultTtlSeconds;
_ttlMs = ttlSeconds * 1000L;
}
public void Init()
{
Directory.CreateDirectory( _cacheDir );
if ( File.Exists( _manifestPath ) )
{
try
{
var raw = File.ReadAllText( _manifestPath );
var parsed = JsonSerializer.Deserialize<ApiCacheManifest>( raw, JsonOpts.Default );
if ( parsed is { Version: CacheVersion } )
_manifest = parsed;
}
catch
{
// Corrupt cache — start fresh
}
}
}
public bool IsFresh()
{
if ( _manifest.FetchedAt == 0 ) return false;
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _manifest.FetchedAt < _ttlMs;
}
public int GetTypeCount() => _manifest.TypeCount;
public string GetSchemaUrl() => _manifest.SchemaUrl;
public List<ApiType> LoadTypes()
{
if ( !File.Exists( _typesPath ) ) return null;
try
{
var raw = File.ReadAllText( _typesPath );
return JsonSerializer.Deserialize<List<ApiType>>( raw, JsonOpts.Default );
}
catch
{
return null;
}
}
public void Save( string schemaUrl, List<ApiType> types )
{
Directory.CreateDirectory( _cacheDir );
File.WriteAllText( _typesPath, JsonSerializer.Serialize( types, JsonOpts.Default ) );
_manifest = new ApiCacheManifest
{
Version = CacheVersion,
SchemaUrl = schemaUrl,
FetchedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
TypeCount = types.Count,
};
File.WriteAllText( _manifestPath, JsonSerializer.Serialize( _manifest, JsonOpts.Default ) );
}
public void Clear()
{
_manifest = new ApiCacheManifest();
if ( File.Exists( _typesPath ) ) File.Delete( _typesPath );
if ( File.Exists( _manifestPath ) )
File.WriteAllText( _manifestPath, JsonSerializer.Serialize( _manifest, JsonOpts.Default ) );
}
}
using Editor;
using Sandbox.UI;
using System;
using System.Collections.Generic;
namespace XGUI.XGUIEditor
{
/// <summary>
/// A widget that displays and controls panel edge alignments similar to the Windows Forms Anchor property
/// </summary>
public class AlignmentSelectorWidget : PropertyEditor
{
// The panel and node being edited
private Panel _targetPanel;
private MarkupNode _targetNode;
// UI elements
private Widget _container;
private Editor.Button _leftBtn;
private Editor.Button _topBtn;
private Editor.Button _rightBtn;
private Editor.Button _bottomBtn;
// Current alignment state
private PanelAlignment _alignment = new PanelAlignment();
// Boolean indicating if the position mode is absolute (only enable alignment in absolute mode)
private bool _isAbsolutePosition;
public AlignmentSelectorWidget( string propertyName, string displayName, bool isStyle = false )
: base( propertyName, displayName, isStyle )
{
}
/// <summary>
/// Sets the current target panel and node to modify
/// </summary>
public void SetTarget( Panel panel, MarkupNode node, Dictionary<string, string> styles )
{
_targetPanel = panel;
_targetNode = node;
// Extract position mode and update enable state
_isAbsolutePosition = styles.TryGetValue( "position", out var position ) && position == "absolute";
RootWidget.Enabled = _isAbsolutePosition;
// Update alignment state from styles
_alignment = PanelAlignment.FromStyles( styles );
UpdateButtonVisuals();
}
public override Widget CreateUI( Layout layout )
{
// Create a container widget for the editor, following the standard pattern
var rootWidget = new Widget( null );
rootWidget.Layout = Layout.Row();
rootWidget.Layout.Margin = 0;
rootWidget.Layout.Spacing = 2;
// Add label like other editors
rootWidget.Layout.Add( new Editor.Label( DisplayName ) { FixedWidth = 100 } );
// Create the alignment control container
_container = new Widget( null );
rootWidget.Layout.Add( _container, 1 );
_container.Layout = Layout.Column();
_container.Layout.Spacing = 4;
// Top row (with spacers for centering)
var topRow = new Widget( null );
_container.Layout.Add( topRow );
topRow.Layout = Layout.Row();
topRow.Layout.Spacing = 4;
// Add first spacer (fixed width)
var topLeftSpacer = new Widget( null );
topLeftSpacer.FixedWidth = 60;
topRow.Layout.Add( topLeftSpacer );
// Add top button
_topBtn = CreateAlignmentButton( "arrow_upward" );
_topBtn.ToolTip = "Anchor to top edge";
_topBtn.Clicked += () => ToggleAlignment( "top", !_alignment.Top );
topRow.Layout.Add( _topBtn );
// Add second spacer (fixed width - must match first spacer)
var topRightSpacer = new Widget( null );
topRightSpacer.FixedWidth = 60;
topRow.Layout.Add( topRightSpacer );
// Middle row
var middleRow = new Widget( null );
_container.Layout.Add( middleRow );
middleRow.Layout = Layout.Row();
middleRow.Layout.Spacing = 8; // More spacing in middle row
// Left button
_leftBtn = CreateAlignmentButton( "arrow_back" );
_leftBtn.ToolTip = "Anchor to left edge";
_leftBtn.Clicked += () => ToggleAlignment( "left", !_alignment.Left );
middleRow.Layout.Add( _leftBtn );
// Center panel (fixed width to maintain spacing)
var centerPanel = new Widget( null );
centerPanel.FixedWidth = 48;
centerPanel.MinimumSize = new Vector2( 24, 24 );
centerPanel.ToolTip = "Panel representation";
centerPanel.SetStyles( "background-color: #444444; border: 1px solid #666666;" );
middleRow.Layout.Add( centerPanel );
// Right button
_rightBtn = CreateAlignmentButton( "arrow_forward" );
_rightBtn.ToolTip = "Anchor to right edge";
_rightBtn.Clicked += () => ToggleAlignment( "right", !_alignment.Right );
middleRow.Layout.Add( _rightBtn );
// Bottom row (with spacers for centering)
var bottomRow = new Widget( null );
_container.Layout.Add( bottomRow );
bottomRow.Layout = Layout.Row();
bottomRow.Layout.Spacing = 4;
// Add first spacer (fixed width)
var bottomLeftSpacer = new Widget( null );
bottomLeftSpacer.FixedWidth = 60;
bottomRow.Layout.Add( bottomLeftSpacer );
// Add bottom button
_bottomBtn = CreateAlignmentButton( "arrow_downward" );
_bottomBtn.ToolTip = "Anchor to bottom edge";
_bottomBtn.Clicked += () => ToggleAlignment( "bottom", !_alignment.Bottom );
bottomRow.Layout.Add( _bottomBtn );
// Add second spacer (fixed width - must match first spacer)
var bottomRightSpacer = new Widget( null );
bottomRightSpacer.FixedWidth = 60;
bottomRow.Layout.Add( bottomRightSpacer );
// Add the root widget to the parent layout
layout.Add( rootWidget );
// Set the RootWidget property
RootWidget = rootWidget;
// Initial button states
UpdateButtonVisuals();
// Default to disabled until initialized with absolute positioning
RootWidget.Enabled = false;
return rootWidget;
}
/// <summary>
/// Creates a button for alignment selection
/// </summary>
private Editor.Button CreateAlignmentButton( string text )
{
var btn = new Editor.Button( null );
btn.Text = text;
btn.MinimumSize = new Vector2( 24, 24 );
btn.MaximumSize = new Vector2( 24, 24 );
return btn;
}
/// <summary>
/// Updates the visual state of buttons based on current alignment
/// </summary>
private void UpdateButtonVisuals()
{
// Ensure buttons have appropriate styling
UpdateButtonStyle( _leftBtn, _alignment.Left );
UpdateButtonStyle( _topBtn, _alignment.Top );
UpdateButtonStyle( _rightBtn, _alignment.Right );
UpdateButtonStyle( _bottomBtn, _alignment.Bottom );
}
/// <summary>
/// Updates the visual style of a button based on its active state
/// </summary>
private void UpdateButtonStyle( Editor.Button btn, bool isActive )
{
if ( btn == null ) return;
btn.SetStyles( $"Button {{ font-family: 'Material Icons'; padding: 0; border: 0; font-size: 13px;}}" );
btn.Tint = isActive ? Theme.Primary : Color.FromRgb( 0x5b5d62 );
}
/// <summary>
/// Toggles an alignment direction and updates the UI and node styles
/// </summary>
private void ToggleAlignment( string edge, bool newState )
{
// Don't allow removing both horizontal alignments
if ( edge == "left" && !newState && !_alignment.Right )
return;
// Don't allow removing both vertical alignments
if ( edge == "top" && !newState && !_alignment.Bottom )
return;
// Update alignment state
switch ( edge )
{
case "left": _alignment.Left = newState; break;
case "top": _alignment.Top = newState; break;
case "right": _alignment.Right = newState; break;
case "bottom": _alignment.Bottom = newState; break;
}
// Update button visuals
UpdateButtonVisuals();
// Apply the changes to the styles
ApplyAlignmentChanges( edge, newState );
}
/// <summary>
/// Applies alignment changes to the panel styles
/// </summary>
private void ApplyAlignmentChanges( string edge, bool newState )
{
if ( _targetNode == null || _targetPanel == null || !_isAbsolutePosition )
return;
// Current styles for checking/updating
var styles = GetCurrentStyles();
if ( newState )
{
// Calculate edge position based on current box/rect values
string cssValue = CalculateEdgePosition( edge );
// Apply the position to the style
_targetNode.TryModifyStyle( edge, cssValue );
// Special handling for horizontal and vertical stretching:
// If both left+right set, remove width to enable stretching
if ( edge == "left" || edge == "right" )
{
bool hasLeft = edge == "left" ? true : styles.ContainsKey( "left" );
bool hasRight = edge == "right" ? true : styles.ContainsKey( "right" );
// Both edges are now anchored, remove width to allow stretching
if ( hasLeft && hasRight )
{
_targetNode.TryModifyStyle( "width", null );
}
}
// Similarly for top+bottom with height
if ( edge == "top" || edge == "bottom" )
{
bool hasTop = edge == "top" ? true : styles.ContainsKey( "top" );
bool hasBottom = edge == "bottom" ? true : styles.ContainsKey( "bottom" );
// Both edges are now anchored, remove height to allow stretching
if ( hasTop && hasBottom )
{
_targetNode.TryModifyStyle( "height", null );
}
}
NotifyValueChanged( new AlignmentChangeInfo { Edge = edge, Value = cssValue } );
}
else
{
// Remove the style property
_targetNode.TryModifyStyle( edge, null );
// When removing an alignment, we may need to add back width/height
// based on the current box size so the element doesn't collapse
// Handle horizontal case
if ( edge == "left" || edge == "right" )
{
bool hasLeft = edge == "left" ? false : styles.ContainsKey( "left" );
bool hasRight = edge == "right" ? false : styles.ContainsKey( "right" );
// We no longer have both edges anchored, so set explicit width
if ( !(hasLeft && hasRight) && !styles.ContainsKey( "width" ) )
{
string widthValue = $"{Math.Max( Math.Round( _targetPanel.Box.Rect.Width ), 10 )}px";
_targetNode.TryModifyStyle( "width", widthValue );
}
}
// Handle vertical case
if ( edge == "top" || edge == "bottom" )
{
bool hasTop = edge == "top" ? false : styles.ContainsKey( "top" );
bool hasBottom = edge == "bottom" ? false : styles.ContainsKey( "bottom" );
// We no longer have both edges anchored, so set explicit height
if ( !(hasTop && hasBottom) && !styles.ContainsKey( "height" ) )
{
string heightValue = $"{Math.Max( Math.Round( _targetPanel.Box.Rect.Height ), 10 )}px";
_targetNode.TryModifyStyle( "height", heightValue );
}
}
NotifyValueChanged( new AlignmentChangeInfo { Edge = edge, Value = null } );
}
}
/// <summary>
/// Helper method to get current styles as a dictionary
/// </summary>
private Dictionary<string, string> GetCurrentStyles()
{
if ( _targetNode == null || !_targetNode.Attributes.TryGetValue( "style", out var styleStr ) )
return new Dictionary<string, string>();
// Parse style string into dictionary
var styles = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
var declarations = styleStr.Split( ';', StringSplitOptions.RemoveEmptyEntries );
foreach ( var decl in declarations )
{
var parts = decl.Split( ':', 2 );
if ( parts.Length == 2 )
{
string property = parts[0].Trim();
string value = parts[1].Trim();
styles[property] = value;
}
}
return styles;
}
/// <summary>
/// Calculates the position value for an edge based on current panel layout
/// </summary>
private string CalculateEdgePosition( string edge )
{
if ( _targetPanel == null || _targetPanel.Parent == null )
return "0px";
float value = 0;
var rect = _targetPanel.Box.Rect;
var parentRect = _targetPanel.Parent.Box.Rect;
switch ( edge )
{
case "left":
value = rect.Left - parentRect.Left;
break;
case "top":
value = rect.Top - parentRect.Top;
break;
case "right":
// Right edge is calculated as parent width - (panel left + panel width)
value = parentRect.Width - (rect.Left - parentRect.Left + rect.Width);
break;
case "bottom":
// Bottom edge is calculated as parent height - (panel top + panel height)
value = parentRect.Height - (rect.Top - parentRect.Top + rect.Height);
break;
}
return $"{Math.Max( Math.Round( value ), 0 )}px";
}
// This is just a dummy implementation to satisfy the abstract method
public override void SetValueSilently( string value )
{
// Nothing to do here
}
// Class to pass alignment change info when notifying value changed
public class AlignmentChangeInfo
{
public string Edge { get; set; }
public string Value { get; set; }
}
}
}
using Editor;
using Sandbox;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.Linq;
using XGUI.XGUIEditor;
namespace XGUI;
public partial class XGUIView : SceneRenderingWidget
{
XGUIRootPanel Panel;
XGUIRootComponent _rootComponent;
public Window Window;
public Panel WindowContent;
// Add delegate for selection callback
public Action<Panel> OnElementSelected { get; set; }
public XGUIDesigner OwnerDesigner;
public XGUIView()
{
MinimumSize = 300;
Scene = new Scene();
var cam = Scene.CreateObject();
Camera = cam.AddComponent<CameraComponent>();
_rootComponent = cam.AddComponent<XGUIRootComponent>();
_rootComponent.MouseUnlocked = false;
Scene.GameTick();
Scene.GameTick();
Scene.GameTick();
Panel = _rootComponent.XGUIPanel;
}
public void CreateBlankWindow()
{
Window = new Window();
Panel.AddChild( Window );
WindowContent = new Panel();
WindowContent.AddClass( "window-content" );
Window.AddChild( WindowContent );
Window.FocusWindow();
}
public void Setup()
{
}
public override void OnDestroyed()
{
base.OnDestroyed();
CleanUp();
}
public void CleanUp()
{
Scene.Destroy();
Panel.Delete();
}
int mouseIconHash = 0;
protected override void PreFrame()
{
base.PreFrame();
Scene.GameTick();
var mousePosLocal = Editor.Application.CursorPosition - ScreenPosition;
var hash = HashCode.Combine( mousePosLocal, SelectedPanel, isDragging );
if ( hash != mouseIconHash )
{
mouseIconHash = hash;
Cursor = CursorShape.None;
if ( isDragging ) Cursor = CursorShape.DragMove;
int handleIndex = GetHandleAtPosition( mousePosLocal );
if ( ShouldOnlyHorizontalResize( SelectedPanel ) )
{
UpdateResizeCursor( handleIndex, verticalenabled: false, diagonalenabled: false );
}
else
{
UpdateResizeCursor( handleIndex );
}
}
}
// Resize handle tracking
private bool _isDraggingHandle = false;
private int _activeHandle = -1; // -1 = none, 0-7 = handles clockwise from top-left
private Vector2 _dragStartPos;
private Rect _originalRect;
public void OnOverlayDraw()
{
if ( SelectedPanel != null )
{
// Draw selection outline
Paint.ClearPen();
Paint.SetPen( Color.Cyan.WithAlpha( 0.8f ), 1.0f, PenStyle.Dot );
Paint.DrawRect( SelectedPanel.Box.Rect );
// Draw resize handles
if ( ShouldOnlyHorizontalResize( SelectedPanel ) )
{
DrawResizeHandles( SelectedPanel.Box.Rect, verticalenabled: false, diagonalenabled: false );
}
else
{
DrawResizeHandles( SelectedPanel.Box.Rect );
}
DrawSnappingGuides();
}
}
public Panel SelectedPanel;
public Panel DraggingPanel;
private bool isDragging = false;
private bool isMouseDown = false;
private Vector2 dragOffset;
private Vector2 dragStartPos;
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
if ( e.Accepted )
return;
isMouseDown = true;
ResizeMousePress( e );
if ( e.Accepted )
return;
if ( e.Button != MouseButtons.Left )
return;
// Check if we're in nested selection mode (multiple clicks at the same position)
CheckNestedSelectionMode( e.LocalPosition );
TryInteractAtPosition( Window, e );
if ( e.Accepted )
return;
// Find the panel under the mouse (excluding WindowContent itself)
Panel hovered = FindPanelAtPosition( Window, e.LocalPosition, skipSelf: true, selectNested: _isNestedSelectionMode ) as Panel;
if ( hovered == WindowContent )
{
hovered = Window;
}
Select( hovered );
}
private void Select( Panel panel )
{
SelectedPanel = panel;
OnElementSelected?.Invoke( panel );
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
if ( isMouseDown && !_isDraggingHandle && !isDragging )
{
var result = FindPanelAtPosition( WindowContent, e.LocalPosition, skipSelf: true );
Panel hovered = result as Panel;
if ( hovered != null )
{
// Start dragging
isDragging = true;
DraggingPanel = hovered;
dragStartPos = e.LocalPosition;
dragOffset = e.LocalPosition - hovered.Box.Rect.Position;
}
}
if ( !isDragging )
{
ResizeMouseMove( e );
}
if ( isDragging && DraggingPanel != null )
{
// 1. Check for reparenting (hovering over a panel and within margin)
Panel dropTarget = FindPanelAtPosition( WindowContent, e.LocalPosition, skipSelf: true, skip: DraggingPanel ) as Panel;
if ( dropTarget != null && dropTarget != DraggingPanel.Parent && dropTarget != DraggingPanel )
{
var rect = dropTarget.Box.Rect;
float margin = 4f;
if ( e.LocalPosition.x > rect.Left + margin && e.LocalPosition.x < rect.Right - margin &&
e.LocalPosition.y > rect.Top + margin && e.LocalPosition.y < rect.Bottom - margin )
{
return; // Don't allow reparenting if within margin, this will be reparenting
}
}
// 2. If absolute, update position
if ( DraggingPanel.ComputedStyle?.Position == PositionMode.Absolute )
{
DraggingPanel.Style.Position = PositionMode.Absolute;
var newPosition = e.LocalPosition - dragOffset;
newPosition = ApplySnappingToPosition( newPosition );
newPosition -= DraggingPanel.Parent.Box.Rect.Position; // Adjust for WindowContent position
// Apply snapping to nearby elements and parent container
var node = OwnerDesigner.LookupNodeByPanel( DraggingPanel );
if ( node != null )
{
// Get the panel's alignment settings
var alignment = GetPanelAlignment( DraggingPanel );
//Log.Info( $"Alignment Left: {alignment.Left}" );
//Log.Info( $"Alignment Top: {alignment.Top}" );
//Log.Info( $"Alignment Right: {alignment.Right}" );
//Log.Info( $"Alignment Bottom: {alignment.Bottom}" );
//Log.Info( $"New Position: {newPosition}" );
// Clear any existing positioning styles first
if ( alignment.Left )
node.TryModifyStyle( "left", $"{newPosition.x}px" );
else
node.TryModifyStyle( "left", null );
if ( alignment.Top )
node.TryModifyStyle( "top", $"{newPosition.y}px" );
else
node.TryModifyStyle( "top", null );
if ( alignment.Right && DraggingPanel.Parent != null )
{
// Calculate right value as: parent_width - (left + width)
float parentWidth = DraggingPanel.Parent.Box.Rect.Width;
float panelWidth = DraggingPanel.Box.Rect.Width;
float rightValue = parentWidth - (newPosition.x + panelWidth);
node.TryModifyStyle( "right", $"{rightValue}px" );
}
else
{
node.TryModifyStyle( "right", null );
}
if ( alignment.Bottom && DraggingPanel.Parent != null )
{
// Calculate bottom value as: parent_height - (top + height)
float parentHeight = DraggingPanel.Parent.Box.Rect.Height;
float panelHeight = DraggingPanel.Box.Rect.Height;
float bottomValue = parentHeight - (newPosition.y + panelHeight);
node.TryModifyStyle( "bottom", $"{bottomValue}px" );
}
else
{
node.TryModifyStyle( "bottom", null );
}
OwnerDesigner.ForceUpdate( false );
DraggingPanel.Style.Dirty();
DraggingPanel = OwnerDesigner.LookupPanelByNode( node );
if ( DraggingPanel == null )
{
Log.Warning( "DraggingPanel is null after UI rebuild!" );
}
return;
}
}
// 3. Rearranging among siblings (auto-layout)
else
{
var parent = DraggingPanel.Parent;
if ( parent != null )
{
var siblings = parent.Children.OfType<Panel>().Where( p => p != DraggingPanel ).ToList();
Panel targetSibling = null;
bool insertAfter = false;
FlexDirection flexDirection = parent.ComputedStyle?.FlexDirection ?? FlexDirection.Column; // Default to column
foreach ( var sibling in siblings )
{
var rect = sibling.Box.Rect;
if ( flexDirection == FlexDirection.Row || flexDirection == FlexDirection.RowReverse )
{
// Horizontal
if ( e.LocalPosition.x > rect.Left && e.LocalPosition.x < rect.Right )
{
if ( e.LocalPosition.x < rect.Left + rect.Width / 2 )
{
targetSibling = sibling;
insertAfter = (flexDirection == FlexDirection.RowReverse) ^ false;
break;
}
else if ( e.LocalPosition.x >= rect.Left + rect.Width / 2 )
{
targetSibling = sibling;
insertAfter = (flexDirection == FlexDirection.RowReverse) ^ true;
break;
}
}
}
else
{
// Vertical (column or column-reverse)
if ( e.LocalPosition.y > rect.Top && e.LocalPosition.y < rect.Bottom )
{
if ( e.LocalPosition.y < rect.Top + rect.Height / 2 )
{
targetSibling = sibling;
insertAfter = (flexDirection == FlexDirection.ColumnReverse) ^ false;
break;
}
else if ( e.LocalPosition.y >= rect.Top + rect.Height / 2 )
{
targetSibling = sibling;
insertAfter = (flexDirection == FlexDirection.ColumnReverse) ^ true;
break;
}
}
}
}
if ( targetSibling != null && targetSibling != DraggingPanel )
{
Log.Info( $"Rearranging {DraggingPanel} before {targetSibling}" );
// Update MarkupNode tree as well
var parentNode = OwnerDesigner.LookupNodeByPanel( parent );
var draggedNode = OwnerDesigner.LookupNodeByPanel( DraggingPanel );
var targetSiblingNode = OwnerDesigner.LookupNodeByPanel( targetSibling );
Log.Info( $"Parent: {parentNode} aka {parent}, InsertBefore: {targetSiblingNode}, Dragged: {draggedNode}" );
if ( parentNode != null && draggedNode != null && targetSiblingNode != null )
{
var nodeChildrenList = parentNode.Children as List<MarkupNode>;
if ( nodeChildrenList != null )
{
nodeChildrenList.Remove( draggedNode );
int nodeInsertIndex = nodeChildrenList.IndexOf( targetSiblingNode );
if ( insertAfter )
nodeInsertIndex++;
nodeChildrenList.Insert( nodeInsertIndex, draggedNode );
draggedNode.Parent = parentNode;
}
}
OwnerDesigner.ForceUpdate( false );
DraggingPanel.Style.Dirty();
// Restore DraggingPanel after UI rebuild
DraggingPanel = OwnerDesigner.LookupPanelByNode( draggedNode );
if ( DraggingPanel == null )
{
Log.Warning( "DraggingPanel is null after UI rebuild!" );
}
}
}
}
}
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
isMouseDown = false;
ResizeMouseReleased( e );
_isSnappedX = false;
_isSnappedY = false;
if ( isDragging && DraggingPanel != null )
{
// 1. Check for reparenting (hovering over a panel and within margin)
var result = FindPanelAtPosition( WindowContent, e.LocalPosition, skipSelf: true, skip: DraggingPanel );
Panel dropTarget = result as Panel;
if ( dropTarget != null && dropTarget != DraggingPanel.Parent && dropTarget != DraggingPanel )
{
var rect = dropTarget.Box.Rect;
float margin = 6f;
if ( e.LocalPosition.x > rect.Left + margin && e.LocalPosition.x < rect.Right - margin &&
e.LocalPosition.y > rect.Top + margin && e.LocalPosition.y < rect.Bottom - margin )
{
// --- Reparent ---
DraggingPanel.Parent = null;
dropTarget.AddChild( DraggingPanel );
// --- Update MarkupNode tree as well ---
var draggedNode = OwnerDesigner.LookupNodeByPanel( DraggingPanel );
var newParentNode = OwnerDesigner.LookupNodeByPanel( dropTarget );
if ( draggedNode != null && newParentNode != null && draggedNode.Parent != null )
{
draggedNode.Parent.Children.Remove( draggedNode );
newParentNode.Children.Add( draggedNode );
draggedNode.Parent = newParentNode;
OwnerDesigner.ForceUpdate();
DraggingPanel.Style.Dirty();
isDragging = false;
DraggingPanel = null;
return;
}
}
}
// 2. Rearranging among siblings (auto-layout) -- only on release!
isDragging = false;
DraggingPanel = null;
}
}
private void TryInteractAtPosition( Panel root, MouseEvent e )
{
var pos = e.LocalPosition;
// recursively look for a tab button anywhere in the root panel
foreach ( var child in root.Children )
{
if ( child != null )
{
if ( child is Sandbox.UI.Button tabButton && tabButton.Parent?.Parent is TabContainer tabContainer )
{
if ( tabButton.Box.Rect.IsInside( pos ) )
{
//find the <tab> node (not the button's node) that the button belongs to
Log.Info( tabContainer );
// find tab entry in the tab container
var tabentry = tabContainer.Tabs.FirstOrDefault( x => x.Button == tabButton );
var page = tabentry.Page;
Log.Info( page );
// select, else click if already selected
if ( page == SelectedPanel )
{
// Click event
tabButton.Click();
e.Accepted = true;
}
else
{
// Select the owner <tab> node
Select( page );
e.Accepted = true;
}
return;
}
}
else if ( child is Panel panel )
{
TryInteractAtPosition( panel, e );
}
}
}
}
// Utility: Recursively find the hovering panel at a position
private object FindPanelAtPosition( Panel root, Vector2 pos, bool skipSelf = false, Panel skip = null, bool selectNested = false )
{
if ( root == null || root == skip ) return null;
// Store all panels at this position that have corresponding markup nodes
List<Panel> panelsAtPosition = selectNested ? new List<Panel>() : null;
bool foundAny = false;
// First check all children (reverse order to prioritize elements on top)
foreach ( var child in root.Children.OfType<Panel>().Reverse() )
{
var found = FindPanelAtPosition( child, pos, skipSelf: false, skip: skip, selectNested: selectNested );
if ( found != null )
{
foundAny = true;
if ( selectNested )
{
if ( found is List<Panel> foundList )
{
panelsAtPosition.AddRange( foundList );
}
else if ( found is Panel foundPanel )
{
panelsAtPosition.Add( foundPanel );
}
}
else
{
return found; // Return the first (deepest) child found
}
}
}
// Then check if this panel contains the position AND has a corresponding markup node
if ( !skipSelf && root.Box.Rect.IsInside( pos ) )
{
// Only consider panels that have a corresponding markup node
bool hasMarkupNode = OwnerDesigner != null && OwnerDesigner.LookupNodeByPanel( root ) != null;
// Is visible (example, if the panel is a child of a tab that isnt selected)
bool isVisible = root.IsVisible;
if ( hasMarkupNode && isVisible )
{
if ( selectNested )
{
panelsAtPosition.Add( root );
}
else if ( !foundAny )
{
return root; // Return this panel if no children were found
}
}
}
// Return the list for nested selection mode, or null for normal mode
if ( selectNested && panelsAtPosition.Count > 0 )
{
return panelsAtPosition;
}
return null;
}
// Keep track of which nested panel we're selecting at a specific position
private Dictionary<Vector2, int> _panelSelectionIndices = new Dictionary<Vector2, int>();
// Helper to handle multiple clicks at the same position
private Vector2 _lastClickPosition;
private bool _isNestedSelectionMode = false;
// Add this to your OnMousePress method before calling FindPanelAtPosition
private void CheckNestedSelectionMode( Vector2 position )
{
// If clicking at the same spot, enable nested selection mode
if ( Vector2.Distance( _lastClickPosition, position ) < 1.0f )
{
_isNestedSelectionMode = true;
}
else
{
_isNestedSelectionMode = false;
_panelSelectionIndices.Clear();
}
_lastClickPosition = position;
}
private PanelAlignment GetPanelAlignment( Panel panel )
{
if ( panel == null )
return new PanelAlignment();
var node = OwnerDesigner.LookupNodeByPanel( panel );
if ( node == null )
return new PanelAlignment();
string styleAttr = node.Attributes.GetValueOrDefault( "style", "" );
var styles = ParseStyleAttribute( styleAttr );
return XGUIEditor.PanelAlignment.FromStyles( styles );
}
// Helper method to parse style strings
private Dictionary<string, string> ParseStyleAttribute( string styleString )
{
var styles = new Dictionary<string, string>( System.StringComparer.OrdinalIgnoreCase );
if ( string.IsNullOrWhiteSpace( styleString ) )
return styles;
var declarations = styleString.Split( ';', StringSplitOptions.RemoveEmptyEntries );
foreach ( var declaration in declarations )
{
var parts = declaration.Split( ':', 2 );
if ( parts.Length == 2 )
{
string property = parts[0].Trim();
string value = parts[1].Trim();
if ( !string.IsNullOrEmpty( property ) )
{
styles[property] = value;
}
}
}
return styles;
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using SboxUiDesigner.EditorUi.Commands;
using SboxUiDesigner.Runtime;
namespace SboxUiDesigner.EditorUi;
/// <summary>
/// Single source of truth for an open .sui document inside the editor window.
/// Owns the document, the selection state, the dirty state, and the command
/// stack. Region widgets read state through the controller and mutate state
/// only via commands — direct widget-to-widget event wiring is avoided so undo,
/// dirty-tracking, and persistence stay coherent.
///
/// All edits go through <see cref="Execute(ISuiCommand)"/>. Cosmetic-only state
/// (selection) goes through <see cref="SetSelected"/> and does NOT enter the
/// command stack — selection itself isn't undoable, only document mutations are.
/// </summary>
public sealed class SuiDesignerController
{
public SuiDocument Document { get; private set; }
/// <summary>
/// Multi-selection state. <see cref="Selected"/> is the "primary" — the most
/// recently focused element used by the Details panel and chrome handles.
/// <see cref="SelectedSet"/> is everyone in the current selection (always
/// contains <see cref="Selected"/> when non-null).
/// </summary>
public SuiElement Selected { get; private set; }
public IReadOnlyCollection<SuiElement> SelectedSet => _selectedSet;
public int SelectedCount => _selectedSet.Count;
private readonly HashSet<SuiElement> _selectedSet = new();
public bool IsDirty { get; private set; }
public SuiCommandStack Commands { get; }
public event Action DocumentChanged;
public event Action SelectionChanged;
public event Action DirtyChanged;
public event Action CommandsChanged;
public SuiDesignerController()
{
Commands = new SuiCommandStack();
Commands.Changed += () => CommandsChanged?.Invoke();
}
// ─────────────────────────────────────────────────────────────────────
// Document lifecycle
// ─────────────────────────────────────────────────────────────────────
public void SetDocument( SuiDocument doc )
{
Document = doc;
_selectedSet.Clear();
Selected = doc?.GetRoot();
if ( Selected != null ) _selectedSet.Add( Selected );
IsDirty = false;
Commands.Clear();
DocumentChanged?.Invoke();
SelectionChanged?.Invoke();
DirtyChanged?.Invoke();
}
public void MarkSaved()
{
if ( !IsDirty ) return;
IsDirty = false;
DirtyChanged?.Invoke();
}
private void SetDirty()
{
if ( IsDirty ) return;
IsDirty = true;
DirtyChanged?.Invoke();
}
/// <summary>
/// Mark the document dirty when a non-command mutation happens — e.g. user
/// picks an output folder via Window's PromptOutputFolder. These mutations
/// don't go through the command stack (they're settings, not undoable
/// edits) but still need to trigger a Save prompt on close.
/// </summary>
public void MarkDirtyExternally()
{
SetDirty();
}
// ─────────────────────────────────────────────────────────────────────
// Selection
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Replace the entire selection with this single element (or clear when null).
/// </summary>
public void SetSelected( SuiElement element )
{
var same = _selectedSet.Count == 1 && Selected == element;
if ( same && _selectedSet.Contains( element ) ) return;
_selectedSet.Clear();
Selected = element;
if ( element != null ) _selectedSet.Add( element );
SelectionChanged?.Invoke();
}
public void SetSelectedById( string elementId )
{
if ( Document == null ) return;
var el = Document.GetElement( elementId );
SetSelected( el );
}
public void ClearSelection()
{
if ( _selectedSet.Count == 0 && Selected == null ) return;
_selectedSet.Clear();
Selected = null;
SelectionChanged?.Invoke();
}
/// <summary>Replace the selection with the given set. Primary becomes the
/// last element in the enumeration.</summary>
public void SetSelection( IEnumerable<SuiElement> elements )
{
_selectedSet.Clear();
Selected = null;
if ( elements != null )
{
foreach ( var el in elements )
{
if ( el == null ) continue;
_selectedSet.Add( el );
Selected = el;
}
}
SelectionChanged?.Invoke();
}
/// <summary>Add an element to the selection (Shift+click). Becomes primary.</summary>
public void AddSelected( SuiElement element )
{
if ( element == null ) return;
_selectedSet.Add( element );
Selected = element;
SelectionChanged?.Invoke();
}
/// <summary>Remove an element from the selection. Primary may shift.</summary>
public void RemoveSelected( SuiElement element )
{
if ( element == null ) return;
if ( !_selectedSet.Remove( element ) ) return;
if ( Selected == element )
Selected = _selectedSet.LastOrDefault();
SelectionChanged?.Invoke();
}
/// <summary>Toggle an element in/out of the selection (Shift+click on element).</summary>
public void ToggleSelected( SuiElement element )
{
if ( element == null ) return;
if ( _selectedSet.Contains( element ) ) RemoveSelected( element );
else AddSelected( element );
}
/// <summary>Is this element currently selected?</summary>
public bool IsSelected( SuiElement element )
=> element != null && _selectedSet.Contains( element );
// ─────────────────────────────────────────────────────────────────────
// Commands
// ─────────────────────────────────────────────────────────────────────
public void Execute( ISuiCommand cmd )
{
if ( Document == null || cmd == null ) return;
Commands.Push( cmd, Document );
SetDirty();
DocumentChanged?.Invoke();
}
public void Undo()
{
if ( Document == null || !Commands.CanUndo ) return;
Commands.Undo( Document );
SetDirty();
// If the selected element was deleted by the command we undid, the
// selection might now point at a stale instance. Validate and clear.
ValidateSelection();
DocumentChanged?.Invoke();
SelectionChanged?.Invoke();
}
public void Redo()
{
if ( Document == null || !Commands.CanRedo ) return;
Commands.Redo( Document );
SetDirty();
ValidateSelection();
DocumentChanged?.Invoke();
SelectionChanged?.Invoke();
}
private void ValidateSelection()
{
// Drop any selected elements that no longer exist in the document.
// (Undo/redo can resurrect or remove elements, leaving stale references.)
_selectedSet.RemoveWhere( el => Document?.GetElement( el.Id ) == null );
if ( Selected != null && Document?.GetElement( Selected.Id ) == null )
Selected = _selectedSet.LastOrDefault() ?? Document?.GetRoot();
if ( Selected == null ) return;
if ( Document.GetElement( Selected.Id ) == null )
{
Selected = Document.GetRoot();
}
}
// ─────────────────────────────────────────────────────────────────────
// High-level operations
// These wrap a command + an optional selection update so callers don't
// have to construct command objects manually.
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Add a new element of <paramref name="type"/> as a child of
/// <paramref name="parent"/> (or the current selection's container, or root).
/// Selects the new element automatically.
/// </summary>
public SuiElement AddElement( SuiElementType type, SuiElement parent = null )
{
if ( Document == null ) return null;
parent ??= ResolveAddTarget();
if ( parent == null ) return null;
var element = new SuiElement
{
Id = SuiDocument.NewElementId(),
Name = SuggestUniqueName( type ),
Type = type,
ParentId = parent.Id,
};
element.ApplyTypeDefaults();
element.Style.ClassName = SuiDocumentValidator.SanitizeClassName( element.Name );
Execute( new SuiAddElementCommand( element, parent.Id ) );
SetSelected( element );
return element;
}
/// <summary>
/// Pick the parent for a click-to-add operation. M5 default is "always Root"
/// — auto-nesting based on the current selection caused surprising behaviour
/// (click Panel → click Image → Image becomes child of Panel even though
/// the user clicked the palette without dragging). Drag-and-drop in M6 will
/// reintroduce nesting via explicit drop targets.
///
/// Callers that already know the parent (e.g. a future drag handler) should
/// pass the parent explicitly to <see cref="AddElement(SuiElementType, SuiElement)"/>
/// rather than rely on this resolver.
/// </summary>
private SuiElement ResolveAddTarget()
{
return Document.GetRoot();
}
private static bool IsContainer( SuiElementType type ) => type switch
{
SuiElementType.Canvas
or SuiElementType.Panel
or SuiElementType.Overlay
or SuiElementType.HorizontalBox
or SuiElementType.VerticalBox
or SuiElementType.Grid
or SuiElementType.ScrollPanel
or SuiElementType.InventoryGrid
or SuiElementType.Hotbar => true,
_ => false,
};
private string SuggestUniqueName( SuiElementType type )
{
var baseName = type.ToString();
if ( Document == null ) return baseName;
// Try the bare type name first; otherwise append _2, _3, ...
if ( !NameExists( baseName ) ) return baseName;
for ( int i = 2; i < 1000; i++ )
{
var candidate = $"{baseName}_{i}";
if ( !NameExists( candidate ) ) return candidate;
}
return $"{baseName}_{System.Guid.NewGuid().ToString( "N" ).Substring( 0, 4 )}";
}
private bool NameExists( string name )
{
foreach ( var el in Document.Elements )
if ( string.Equals( el.Name, name, StringComparison.OrdinalIgnoreCase ) ) return true;
return false;
}
/// <summary>
/// Delete the selected element (or the explicitly given one). Root cannot be deleted.
/// </summary>
public void DeleteElement( SuiElement element = null )
{
element ??= Selected;
if ( element == null || string.IsNullOrEmpty( element.ParentId ) ) return; // root or unset
var newSelection = Document.GetElement( element.ParentId ) ?? Document.GetRoot();
Execute( new SuiDeleteElementCommand( element.Id ) );
SetSelected( newSelection );
}
public void RenameElement( SuiElement element, string newName )
{
if ( element == null || string.IsNullOrEmpty( newName ) ) return;
if ( element.Name == newName ) return; // no-op
Execute( new SuiRenameElementCommand( element.Id, newName ) );
}
/// <summary>
/// Duplicate an element together with its descendants. New ids throughout
/// the cloned subtree, inserted as a sibling immediately after the source.
/// Selects the duplicate after the operation.
/// </summary>
public SuiElement DuplicateElement( SuiElement element = null )
{
element ??= Selected;
if ( element == null || string.IsNullOrEmpty( element.ParentId ) ) return null; // refuse root
var cmd = new SuiDuplicateElementCommand( element.Id );
Execute( cmd );
// Select the new clone if Apply succeeded.
if ( !string.IsNullOrEmpty( cmd.ResultingElementId ) && Document != null )
{
var newElement = Document.GetElement( cmd.ResultingElementId );
if ( newElement != null ) SetSelected( newElement );
}
return Selected;
}
/// <summary>Move the element up among its siblings (or selection if null).</summary>
public void MoveElementUp( SuiElement element = null )
{
element ??= Selected;
if ( element == null || string.IsNullOrEmpty( element.ParentId ) ) return;
Execute( new SuiReorderElementCommand( element.Id, -1 ) );
}
/// <summary>Move the element down among its siblings (or selection if null).</summary>
public void MoveElementDown( SuiElement element = null )
{
element ??= Selected;
if ( element == null || string.IsNullOrEmpty( element.ParentId ) ) return;
Execute( new SuiReorderElementCommand( element.Id, +1 ) );
}
// ─────────────────────────────────────────────────────────────────────
// Clipboard — Cut / Copy / Paste
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Copy the element (or current selection) and its descendants to the
/// process-local clipboard. Doesn't mutate the document — clipboard holds
/// deep clones, not references.
/// </summary>
public void CopyElement( SuiElement element = null )
{
element ??= Selected;
if ( element == null || Document == null ) return;
if ( string.IsNullOrEmpty( element.ParentId ) ) return; // refuse copying root
var byId = new Dictionary<string, SuiElement>();
foreach ( var el in Document.Elements )
if ( !string.IsNullOrEmpty( el.Id ) ) byId[el.Id] = el;
var payload = new SuiClipboardPayload();
payload.Root = CloneSubtreeForClipboard( element, byId, payload.All );
SuiClipboard.Set( payload );
}
/// <summary>Copy + delete (Cut). Refuses to cut root.</summary>
public void CutElement( SuiElement element = null )
{
element ??= Selected;
if ( element == null || Document == null ) return;
if ( string.IsNullOrEmpty( element.ParentId ) ) return;
CopyElement( element );
DeleteElement( element );
}
/// <summary>
/// Paste the clipboard subtree under <paramref name="parent"/> (defaults to
/// the current selection if it's a container, otherwise the selection's parent,
/// otherwise root). Selects the pasted root after the operation.
/// </summary>
public SuiElement PasteElement( SuiElement parent = null )
{
if ( Document == null || !SuiClipboard.HasContent ) return null;
parent ??= ResolvePasteParent();
if ( parent == null ) return null;
var cmd = new SuiPasteElementCommand( SuiClipboard.Get(), parent.Id );
Execute( cmd );
if ( !string.IsNullOrEmpty( cmd.ResultingElementId ) )
{
var pasted = Document.GetElement( cmd.ResultingElementId );
if ( pasted != null ) SetSelected( pasted );
return pasted;
}
return null;
}
/// <summary>True if there's anything to paste — used by menus to enable/disable.</summary>
public bool CanPaste => SuiClipboard.HasContent && Document != null;
private SuiElement ResolvePasteParent()
{
if ( Selected == null ) return Document.GetRoot();
// If selected is a container, paste INTO it.
if ( IsContainer( Selected.Type ) ) return Selected;
// Else paste as sibling (= same parent).
var parent = Document.GetElement( Selected.ParentId );
return parent ?? Document.GetRoot();
}
private static SuiElement CloneSubtreeForClipboard(
SuiElement source,
Dictionary<string, SuiElement> byId,
List<SuiElement> output )
{
var clone = source.Clone();
clone.Children = new List<string>(); // will be repopulated below with cloned ids
output.Add( clone );
foreach ( var childId in source.Children ?? new List<string>() )
{
if ( !byId.TryGetValue( childId, out var child ) ) continue;
var clonedChild = CloneSubtreeForClipboard( child, byId, output );
clonedChild.ParentId = clone.Id; // preserves intra-subtree linkage
clone.Children.Add( clonedChild.Id );
}
return clone;
}
/// <summary>
/// Reparent an element to a new container at a specific child index.
/// Refuses to move root and refuses to create cycles.
/// </summary>
public void ReparentElement( SuiElement element, SuiElement newParent, int insertIndex )
{
if ( element == null || newParent == null ) return;
if ( string.IsNullOrEmpty( element.ParentId ) ) return; // refuse root
Execute( new SuiReparentElementCommand( element.Id, newParent.Id, insertIndex ) );
}
public void MoveElement( SuiElement element, float newX, float newY )
{
if ( element == null ) return;
Execute( new SuiMoveElementCommand( element.Id, newX, newY ) );
NotifySelectionDataMaybeChanged( element );
}
/// <summary>
/// Change anchor while preserving the element's visual position. UMG / UI
/// Builder convention: switching anchor only changes the reference point;
/// the element should NOT jump on screen. The command captures the current
/// rect under the old anchor and re-derives X/Y/W/H under the new one.
/// </summary>
public void SetAnchor( SuiElement element, SuiAnchor newAnchor )
{
if ( element?.Layout == null ) return;
if ( element.Layout.Anchor == newAnchor ) return;
Execute( new SuiSetAnchorCommand( element.Id, newAnchor ) );
NotifySelectionDataMaybeChanged( element );
}
/// <summary>
/// Re-fire SelectionChanged when an external mutation (canvas drag, anchor
/// swap) changed properties of the currently-selected element. The Details
/// panel uses SelectionChanged to rebuild itself, so this keeps its row
/// values in sync without doing a full Refresh on every property edit
/// (which would yank the scroll position).
/// </summary>
private void NotifySelectionDataMaybeChanged( SuiElement element )
{
if ( element == null ) return;
if ( Selected == element || _selectedSet.Contains( element ) )
SelectionChanged?.Invoke();
}
public void ResizeElement( SuiElement element, float newWidth, float newHeight )
{
if ( element == null ) return;
Execute( new SuiResizeElementCommand( element.Id, newWidth, newHeight ) );
NotifySelectionDataMaybeChanged( element );
}
// ─────────────────────────────────────────────────────────────────────
// Multi-element alignment (V1.0)
//
// Filters: only Absolute-mode elements that share a single parent.
// Mixed parents / mixed anchors are out of scope — the bounding-box
// math assumes a common coord space. Locked elements are skipped.
// ─────────────────────────────────────────────────────────────────────
public void AlignSelection( SuiAlignElementsCommand.Mode mode )
{
var ids = CollectAlignableSelection();
if ( ids == null || ids.Count < 2 )
{
Log.Info( "[Sui] Align needs ≥2 absolute-mode elements with the same parent." );
return;
}
Execute( new SuiAlignElementsCommand( ids, mode ) );
SelectionChanged?.Invoke();
}
public void DistributeSelection( SuiDistributeElementsCommand.Axis axis )
{
var ids = CollectAlignableSelection();
if ( ids == null || ids.Count < 3 )
{
Log.Info( "[Sui] Distribute needs ≥3 absolute-mode elements with the same parent." );
return;
}
Execute( new SuiDistributeElementsCommand( ids, axis ) );
SelectionChanged?.Invoke();
}
/// <summary>
/// Filters the current selection down to absolute-mode elements that all
/// share the same parent and aren't locked. Returns null if no usable set.
/// </summary>
private System.Collections.Generic.List<string> CollectAlignableSelection()
{
if ( _selectedSet == null || _selectedSet.Count < 2 ) return null;
string commonParent = null;
var ids = new System.Collections.Generic.List<string>();
foreach ( var el in _selectedSet )
{
if ( el?.Layout == null ) continue;
if ( el.Layout.Mode != SuiLayoutMode.Absolute ) continue;
if ( el.Flags?.Locked == true ) continue;
if ( string.IsNullOrEmpty( el.ParentId ) ) continue; // skip root
if ( commonParent == null ) commonParent = el.ParentId;
else if ( commonParent != el.ParentId ) return null; // mixed parents
ids.Add( el.Id );
}
return ids;
}
/// <summary>
/// Generic property setter — see <see cref="SuiSetPropertyCommand{T}"/> for usage.
/// </summary>
public void SetProperty<T>(
SuiElement element,
Func<SuiElement, T> getter,
Action<SuiElement, T> setter,
T newValue,
string description )
{
if ( element == null ) return;
Execute( new SuiSetPropertyCommand<T>( element.Id, getter, setter, newValue, description ) );
}
}
using System.Collections.Generic;
using System.Linq;
using SboxUiDesigner.Runtime;
namespace SboxUiDesigner.EditorUi.Commands;
/// <summary>
/// Align multiple absolute-mode elements to a common edge or center.
///
/// Bounding box is computed from each element's Layout.X/Y/Width/Height
/// in their parent's coordinate space. For V1 this means alignment is only
/// meaningful when all selected elements share the same parent AND same
/// anchor — otherwise the X/Y values aren't comparable. The controller is
/// expected to filter the input set accordingly.
///
/// Single undo entry covers the move of every element.
/// </summary>
public sealed class SuiAlignElementsCommand : ISuiCommand
{
public enum Mode
{
Left,
HCenter,
Right,
Top,
VCenter,
Bottom,
}
private readonly List<string> _ids;
private readonly Mode _mode;
// Per-element undo state, captured during Apply.
private readonly List<(string Id, float OldX, float OldY)> _saved = new();
public string Description => $"Align {_mode}";
public SuiAlignElementsCommand( IEnumerable<string> elementIds, Mode mode )
{
_ids = elementIds.ToList();
_mode = mode;
}
public void Apply( SuiDocument doc )
{
if ( doc == null || _ids.Count < 2 ) return;
_saved.Clear();
var elements = _ids.Select( doc.GetElement ).Where( e => e?.Layout != null && e.Layout.Mode == SuiLayoutMode.Absolute ).ToList();
if ( elements.Count < 2 ) return;
// Bounding box — min/max of each element's own X/Y/Right/Bottom in its
// parent-local coord space (per anchor). We restrict to single-parent
// upstream so this is well-defined.
var minX = elements.Min( e => e.Layout.X );
var maxRight = elements.Max( e => e.Layout.X + e.Layout.Width );
var minY = elements.Min( e => e.Layout.Y );
var maxBottom = elements.Max( e => e.Layout.Y + e.Layout.Height );
float centerX = (minX + maxRight) * 0.5f;
float centerY = (minY + maxBottom) * 0.5f;
foreach ( var el in elements )
{
_saved.Add( (el.Id, el.Layout.X, el.Layout.Y) );
switch ( _mode )
{
case Mode.Left: el.Layout.X = minX; break;
case Mode.HCenter: el.Layout.X = centerX - el.Layout.Width * 0.5f; break;
case Mode.Right: el.Layout.X = maxRight - el.Layout.Width; break;
case Mode.Top: el.Layout.Y = minY; break;
case Mode.VCenter: el.Layout.Y = centerY - el.Layout.Height * 0.5f; break;
case Mode.Bottom: el.Layout.Y = maxBottom - el.Layout.Height; break;
}
}
}
public void Undo( SuiDocument doc )
{
if ( doc == null ) return;
foreach ( var s in _saved )
{
var el = doc.GetElement( s.Id );
if ( el?.Layout == null ) continue;
el.Layout.X = s.OldX;
el.Layout.Y = s.OldY;
}
_saved.Clear();
}
}
using System;
using System.Collections.Generic;
using Editor;
using Sandbox;
using SboxUiDesigner.Generation;
namespace SboxUiDesigner.EditorUi.Widgets;
/// <summary>
/// Bottom panel with 4 tabs (Animations / Bindings / Compile Results / Logs).
/// 100% custom paint — left-aligned tabs above a content area, no Editor.TabWidget.
///
/// Per user spec:
/// - panel background: #141413 (rgba 20, 20, 19)
/// - inactive tab fill: same as panel bg (no fill)
/// - active tab fill: #212120 (rgba 33, 33, 32)
/// - active tab top stripe: #103354 (rgba 16, 51, 84) — 3px line at top
///
/// Lives inside the center column (between the canvas tabs and the right edge
/// of the canvas) — sidebars on the left/right go full height down to the
/// window bottom.
/// </summary>
public sealed class SuiBottomTabsWidget : Widget
{
private SuiBottomTabStrip _tabs;
private Widget _stack;
private SuiAnimationsWidget _animations;
private SuiBindingsWidget _bindings;
private SuiCompileResultsWidget _compileResults;
private SuiLogsWidget _logs;
public SuiAnimationsWidget Animations => _animations;
public SuiBindingsWidget Bindings => _bindings;
public SuiCompileResultsWidget CompileResults => _compileResults;
public SuiLogsWidget Logs => _logs;
public SuiBottomTabsWidget( Widget parent = null ) : base( parent )
{
WindowTitle = "Bottom Panel";
Name = "SuiBottomTabs";
MinimumSize = new Vector2( 400, 160 );
// Panel background — #141413 per user spec.
SetStyles( "background-color: rgb(20,20,19); border: none;" );
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
_tabs = new SuiBottomTabStrip( this );
_tabs.AddTab( "Animations" );
_tabs.AddTab( "Bindings" );
_tabs.AddTab( "Compile Results" );
_tabs.AddTab( "Logs" );
_tabs.FinishLeftAligned();
_tabs.ActiveTabChanged += OnTabChanged;
Layout.Add( _tabs );
// Content stack — single page visible at a time.
_stack = new Widget( this );
_stack.SetStyles( "background-color: rgb(33,33,32); border: none;" );
_stack.Layout = Layout.Row();
_stack.Layout.Margin = 0;
Layout.Add( _stack, 1 );
_animations = new SuiAnimationsWidget( _stack );
_bindings = new SuiBindingsWidget( _stack );
_compileResults = new SuiCompileResultsWidget( _stack );
_logs = new SuiLogsWidget( _stack );
// Make all four children fill the stack so the panel height stays
// constant when switching tabs. Without these the stack collapses
// to the natural size of whichever child is visible.
foreach ( var w in new Widget[] { _animations, _bindings, _compileResults, _logs } )
{
w.SetSizeMode( SizeMode.CanGrow, SizeMode.CanGrow );
}
_stack.Layout.Add( _animations, 1 );
_stack.Layout.Add( _bindings, 1 );
_stack.Layout.Add( _compileResults, 1 );
_stack.Layout.Add( _logs, 1 );
ApplyVisibility();
}
private void OnTabChanged( int index ) => ApplyVisibility();
private void ApplyVisibility()
{
var idx = _tabs.ActiveIndex;
if ( _animations.IsValid() ) _animations.Visible = idx == 0;
if ( _bindings.IsValid() ) _bindings.Visible = idx == 1;
if ( _compileResults.IsValid() ) _compileResults.Visible = idx == 2;
if ( _logs.IsValid() ) _logs.Visible = idx == 3;
}
public void DisplayCompileResult( SuiGenerationResult generation, SboxUiDesigner.EditorUi.SuiCompileResult compile )
{
_compileResults?.DisplayCompileResult( generation, compile );
// Auto-switch to Compile Results tab when a compile finishes.
_tabs.ActiveIndex = 2;
}
public void SetDocument( SboxUiDesigner.Runtime.SuiDocument doc )
{
_bindings?.SetDocument( doc );
}
}
/// <summary>
/// Tab strip for the bottom panel. Same shape as SuiTabStrip but uses the
/// "active = filled with body color + blue top stripe" look the user specified.
/// </summary>
internal sealed class SuiBottomTabStrip : Widget
{
private readonly List<SuiBottomTabStripItem> _tabs = new();
private int _active = 0;
public event Action<int> ActiveTabChanged;
public int ActiveIndex
{
get => _active;
set
{
if ( _active == value ) return;
_active = value;
foreach ( var t in _tabs ) t.Update();
ActiveTabChanged?.Invoke( _active );
}
}
public SuiBottomTabStrip( Widget parent = null ) : base( parent )
{
FixedHeight = 30;
SetStyles( "background-color: rgb(20,20,19); border: none;" );
Layout = Layout.Row();
Layout.Margin = 0;
Layout.Spacing = 0;
}
public void AddTab( string label )
{
var idx = _tabs.Count;
// Insert a subtle vertical 1px line between consecutive tabs (same
// look as the top-bar group separators).
if ( _tabs.Count > 0 )
{
Layout.Add( new SuiBottomTabSeparator() );
}
var tab = new SuiBottomTabStripItem( this, idx, label );
_tabs.Add( tab );
Layout.Add( tab );
}
public void FinishLeftAligned() => Layout.AddStretchCell();
internal bool IsActive( int idx ) => idx == _active;
internal void OnTabClicked( int idx ) => ActiveIndex = idx;
}
internal sealed class SuiBottomTabStripItem : Widget
{
private readonly SuiBottomTabStrip _strip;
private readonly int _index;
public string Label;
private bool _hover;
private bool _sized;
private const int FontSize = 11;
private const int PadH = 16;
public SuiBottomTabStripItem( SuiBottomTabStrip strip, int index, string label ) : base( strip )
{
_strip = strip;
_index = index;
Label = label ?? "";
Cursor = CursorShape.Finger;
FixedHeight = 30;
SetStyles( "background-color: transparent; border: none;" );
FixedWidth = PadH + (Label.Length * 8) + PadH;
}
protected override void OnPaint()
{
Paint.SetDefaultFont( FontSize );
if ( !_sized )
{
var labelW = string.IsNullOrEmpty( Label ) ? 0 : Paint.MeasureText( Label ).x;
int newW = (int)(PadH + labelW + 2 + PadH);
_sized = true;
if ( newW != FixedWidth )
{
FixedWidth = newW;
return;
}
}
var rect = LocalRect;
bool active = _strip.IsActive( _index );
if ( active )
{
// Active tab fill — #212120 (same as content body, so the tab
// reads as a flap of the body sticking up).
Paint.SetBrushAndPen( new Color( 33 / 255f, 33 / 255f, 32 / 255f ) );
Paint.DrawRect( rect );
// Top stripe — #103354 (rgba 16, 51, 84), 3px high.
Paint.SetBrushAndPen( new Color( 16 / 255f, 51 / 255f, 84 / 255f ) );
Paint.DrawRect( new Rect( 0, 0, Width, 3 ) );
}
else if ( _hover )
{
Paint.SetBrushAndPen( Color.White.WithAlpha( 0.04f ) );
Paint.DrawRect( rect );
}
// Label.
var textColor = active
? new Color( 240 / 255f, 244 / 255f, 250 / 255f )
: new Color( 165 / 255f, 170 / 255f, 178 / 255f );
Paint.SetPen( textColor );
var labelRect = new Rect( PadH, 0, Width - PadH * 2, Height );
Paint.DrawText( labelRect, Label, TextFlag.LeftCenter );
}
protected override void OnMouseEnter() { _hover = true; Update(); }
protected override void OnMouseLeave() { _hover = false; Update(); }
protected override void OnMousePress( MouseEvent e )
{
if ( e.LeftMouseButton ) _strip.OnTabClicked( _index );
}
}
/// <summary>
/// Subtle 1px vertical line between consecutive bottom-panel tabs. Same look
/// as the top-bar group separators (rgba(255,255,255,0.08), centered, 55%
/// of strip height).
/// </summary>
internal sealed class SuiBottomTabSeparator : Widget
{
public SuiBottomTabSeparator( Widget parent = null ) : base( parent )
{
FixedWidth = 11;
FixedHeight = 30;
SetStyles( "background-color: transparent; border: none;" );
}
protected override void OnPaint()
{
float lineHeight = Height * 0.55f;
float yTop = (Height - lineHeight) / 2f;
float xCenter = Width / 2f;
Paint.SetPen( Color.White.WithAlpha( 0.08f ) );
Paint.DrawLine( new Vector2( xCenter, yTop ), new Vector2( xCenter, yTop + lineHeight ) );
}
}
using Sandbox;
using System;
using System.Collections.Generic;
using ApexWorld.Spline;
using Editor;
public class SplineEditorTool : EditorTool<SplineComponent>
{
public override void OnEnabled()
{
window = new SplineToolWindow();
AddOverlay( window, TextFlag.RightBottom, 10 );
}
public override void OnUpdate()
{
window.ToolUpdate();
}
public override void OnDisabled()
{
window.OnDisabled();
}
public override void OnSelectionChanged()
{
var target = GetSelectedComponent<SplineComponent>();
window.OnSelectionChanged( target );
}
private SplineToolWindow window = null;
}
class SplineToolWindow : WidgetWindow
{
SplineComponent targetComponent;
static bool IsClosed = false;
ControlWidget inTangentControl;
ControlWidget outTangentControl;
public SplineToolWindow()
{
ContentMargins = 0;
Layout = Layout.Column();
MaximumWidth = 500;
MinimumWidth = 300;
Rebuild();
}
void Rebuild()
{
Layout.Clear( true );
Layout.Margin = 0;
Icon = IsClosed ? "" : "route";
UpdateWindowTitle();
IsGrabbable = !IsClosed;
if ( IsClosed )
{
var closedRow = Layout.AddRow();
closedRow.Add( new IconButton( "route", () => { IsClosed = false; Rebuild(); } ) { ToolTip = "Open Spline Point Editor", FixedHeight = HeaderHeight, FixedWidth = HeaderHeight, Background = Color.Transparent } );
MinimumWidth = 0;
return;
}
MinimumWidth = 400;
var headerRow = Layout.AddRow();
headerRow.AddStretchCell();
headerRow.Add( new IconButton( "info" )
{
ToolTip = "Controls to edit the spline points.\nIn addition to modifying the properties in the control sheet, you can also use the 3D Gizmos.\nClicking on the spline between points will split the spline at that position.\nHolding shift while dragging a point's position will drag out a new point.",
FixedHeight = HeaderHeight,
FixedWidth = HeaderHeight,
Background = Color.Transparent
} );
headerRow.Add( new IconButton( "close", CloseWindow ) { ToolTip = "Close Editor", FixedHeight = HeaderHeight, FixedWidth = HeaderHeight, Background = Color.Transparent } );
if ( targetComponent.IsValid() )
{
var tangentMode = this.GetSerialized().GetProperty( nameof( _selectedPointTangentMode ) );
var roll = this.GetSerialized().GetProperty( nameof( _selectedPointRoll ) );
var scale = this.GetSerialized().GetProperty( nameof( _selectedPointScale ) );
var up = this.GetSerialized().GetProperty( nameof( _selectedPointUp ) );
var controlSheet = new ControlSheet();
// TODO find out why tf this isnt working
controlSheet.AddRow( tangentMode );
controlSheet.AddRow( this.GetSerialized().GetProperty( nameof( _selectedPointPosition ) ) );
inTangentControl = controlSheet.AddRow( this.GetSerialized().GetProperty( nameof( _selectedPointIn ) ) );
outTangentControl = controlSheet.AddRow( this.GetSerialized().GetProperty( nameof( _selectedPointOut ) ) );
controlSheet.AddGroup( "Advanced", new[] { roll, scale, up } );
var row = Layout.Row();
row.Spacing = 16;
row.Margin = 8;
row.Add( new IconButton( "skip_previous", () =>
{
SelectedPointIndex = Math.Max( 0, SelectedPointIndex - 1 );
UpdateWindowTitle();
Focus();
} )
{ ToolTip = "Go to previous point " } );
row.Add( new IconButton( "skip_next", () =>
{
SelectedPointIndex = Math.Min( targetComponent.Spline.PointCount - 1, SelectedPointIndex + 1 );
UpdateWindowTitle();
Focus();
} )
{ ToolTip = "Go to next point" } );
row.Add( new IconButton( "delete", () =>
{
using ( SceneEditorSession.Active.UndoScope( "Delete Spline Point" ).WithComponentChanges( targetComponent ).Push() )
{
targetComponent.Spline.RemovePoint( SelectedPointIndex );
SelectedPointIndex = Math.Max( 0, SelectedPointIndex - 1 );
}
UpdateWindowTitle();
Focus();
} )
{ ToolTip = "Delete point" } );
row.Add( new IconButton( "add", () =>
{
using ( SceneEditorSession.Active.UndoScope( "Added Spline Point" ).WithComponentChanges( targetComponent ).Push() )
{
if ( SelectedPointIndex == targetComponent.Spline.PointCount - 1 )
{
targetComponent.Spline.InsertPoint( SelectedPointIndex + 1, _selectedPoint with { Position = _selectedPoint.Position + targetComponent.Spline.SampleAtDistance( targetComponent.Spline.GetDistanceAtPoint( SelectedPointIndex ) ).Tangent * 200 } );
}
else
{
targetComponent.Spline.AddPointAtDistance( (targetComponent.Spline.GetDistanceAtPoint( SelectedPointIndex ) + targetComponent.Spline.GetDistanceAtPoint( SelectedPointIndex + 1 )) / 2, true );
// TOOD infer tangent modes???
}
}
SelectedPointIndex++;
UpdateWindowTitle();
Focus();
} )
{ ToolTip = "Insert point after curent point.\nYou can also hold shift while dragging a point to create a new point." } );
controlSheet.AddLayout( row );
Layout.Add( controlSheet );
ToggleTangentInput();
}
Layout.Margin = 4;
}
void UpdateWindowTitle()
{
WindowTitle = IsClosed ? "" : $"Spline Point [{SelectedPointIndex}] Editor - {targetComponent?.GameObject?.Name ?? ""}";
}
void CloseWindow()
{
IsClosed = true;
// TODO internal ?
// Release();
Rebuild();
Position = Parent.Size - 32;
}
public void ToolUpdate()
{
if ( !targetComponent.IsValid() )
return;
DrawGizmos();
}
public void OnSelectionChanged( SplineComponent spline )
{
if ( targetComponent.IsValid() )
{
targetComponent.ShouldRenderGizmos = true;
}
targetComponent = spline;
targetComponent.ShouldRenderGizmos = false;
Rebuild();
}
public void OnDisabled()
{
if ( targetComponent.IsValid() )
{
targetComponent.ShouldRenderGizmos = true;
}
}
private void ToggleTangentInput()
{
if ( _selectedPoint.Mode == Spline.HandleMode.Auto || _selectedPoint.Mode == Spline.HandleMode.Linear )
{
inTangentControl.Enabled = false;
outTangentControl.Enabled = false;
}
else
{
inTangentControl.Enabled = true;
outTangentControl.Enabled = true;
}
}
int SelectedPointIndex
{
get => _selectedPointIndex;
set
{
_selectedPointIndex = value;
ToggleTangentInput();
}
}
int _selectedPointIndex = 0;
Spline.Point _selectedPoint
{
get => SelectedPointIndex > targetComponent.Spline.PointCount - 1 ? new Spline.Point() : targetComponent.Spline.GetPoint( SelectedPointIndex );
set
{
using ( SceneEditorSession.Active.UndoScope( "Added Spline Point" ).WithComponentChanges( targetComponent ).Push() )
{
targetComponent.Spline.UpdatePoint( SelectedPointIndex, value );
}
}
}
[Title( "Position" )]
Vector3 _selectedPointPosition
{
get => SelectedPointIndex > targetComponent.Spline.PointCount - 1 ? Vector3.Zero : _selectedPoint.Position;
set
{
using ( SceneEditorSession.Active.UndoScope( "Updated Spline Point" ).WithComponentChanges( targetComponent ).Push() )
{
_selectedPoint = _selectedPoint with { Position = value };
}
}
}
[Title( "In" )]
Vector3 _selectedPointIn
{
get => SelectedPointIndex > targetComponent.Spline.PointCount - 1 ? Vector3.Zero : _selectedPoint.In;
set
{
using ( SceneEditorSession.Active.UndoScope( "Updated Spline Point" ).WithComponentChanges( targetComponent ).Push() )
{
_selectedPoint = _selectedPoint with { In = value };
}
}
}
[Title( "Out" )]
Vector3 _selectedPointOut
{
get => SelectedPointIndex > targetComponent.Spline.PointCount - 1 ? Vector3.Zero : _selectedPoint.Out;
set
{
using ( SceneEditorSession.Active.UndoScope( "Updated Spline Point" ).WithComponentChanges( targetComponent ).Push() )
{
_selectedPoint = _selectedPoint with { Out = value };
}
}
}
// TODO this is temp until i can figure out why editor library refuses to create a controlsheet row for the engine type
/// <summary>
/// Describes how the spline should behave when entering/leaving a point.
/// </summary>
public enum HandleModeTemp
{
/// <summary>
/// Handle positions are calculated automatically
/// based on the location of adjacent points.
/// </summary>
[Icon( "auto_fix_high" )]
Auto,
/// <summary>
/// Handle positions are set to zero, leading to a sharp corner.
/// </summary>
[Icon( "show_chart" )]
Linear,
/// <summary>
/// The In and Out handles are user set, but are linked (mirrored).
/// </summary>
[Icon( "open_in_full" )]
Mirrored,
/// <summary>
/// The In and Out handle are user set and operate independently.
/// </summary>
[Icon( "call_split" )]
Split,
}
[Title( "Tangent Mode" )]
HandleModeTemp _selectedPointTangentMode
{
get => SelectedPointIndex > targetComponent.Spline.PointCount - 1 ? (HandleModeTemp)Spline.HandleMode.Auto : (HandleModeTemp)_selectedPoint.Mode;
set
{
using ( SceneEditorSession.Active.UndoScope( "Updated Spline Point" ).WithComponentChanges( targetComponent ).Push() )
{
_selectedPoint = _selectedPoint with { Mode = (Spline.HandleMode)value };
ToggleTangentInput();
}
}
}
[Title( "Roll (Degrees)" )]
float _selectedPointRoll
{
get => SelectedPointIndex > targetComponent.Spline.PointCount - 1 ? 0f : _selectedPoint.Roll;
set
{
using ( SceneEditorSession.Active.UndoScope( "Updated Spline Point" ).WithComponentChanges( targetComponent ).Push() )
{
_selectedPoint = _selectedPoint with { Roll = value };
}
}
}
[Title( "Up Vector" )]
Vector3 _selectedPointUp
{
get => SelectedPointIndex > targetComponent.Spline.PointCount - 1 ? Vector3.Zero : _selectedPoint.Up;
set
{
using ( SceneEditorSession.Active.UndoScope( "Updated Spline Point" ).WithComponentChanges( targetComponent ).Push() )
{
_selectedPoint = _selectedPoint with { Up = value };
}
}
}
[Title( "Scale (_, Width, Height)" )]
Vector3 _selectedPointScale
{
get => SelectedPointIndex > targetComponent.Spline.PointCount - 1 ? 0f : _selectedPoint.Scale;
set
{
using ( SceneEditorSession.Active.UndoScope( "Updated Spline Point" ).WithComponentChanges( targetComponent ).Push() )
{
_selectedPoint = _selectedPoint with { Scale = value };
}
}
}
bool _inTangentSelected = false;
bool _outTangentSelected = false;
bool _draggingOutNewPoint = false;
bool _moveInProgress = false;
List<Vector3> polyLine = new();
IDisposable _movementUndoScope = null;
void DrawGizmos()
{
using ( Gizmo.Scope( "spline_editor", targetComponent.WorldTransform ) )
{
targetComponent.Spline.ConvertToPolyline( ref polyLine );
for ( var i = 0; i < polyLine.Count - 1; i++ )
{
using ( Gizmo.Scope( "segment" + i ) )
{
using ( Gizmo.Hitbox.LineScope() )
{
Gizmo.Draw.LineThickness = 2f;
Gizmo.Hitbox.AddPotentialLine( polyLine[i], polyLine[i + 1], Gizmo.Draw.LineThickness * 2f );
Gizmo.Draw.Line( polyLine[i], polyLine[i + 1] );
if ( Gizmo.IsHovered && Gizmo.HasMouseFocus )
{
Gizmo.Draw.Color = Color.Orange;
Vector3 point_on_line;
Vector3 point_on_ray;
if ( !new Line( polyLine[i], polyLine[i + 1] ).ClosestPoint(
Gizmo.CurrentRay.ToLocal( Gizmo.Transform ), out point_on_line, out point_on_ray ) )
return;
// It would be slighlty more efficient to use Spline.Utils directly,
// but doggfoding the simplified component API ensures a user of that one would also have the ability to built a spline editor
var hoverSample = targetComponent.Spline.SampleAtClosestPosition( point_on_line );
using ( Gizmo.Scope( "hover_handle", new Transform( point_on_line,
Rotation.LookAt( hoverSample.Tangent ) ) ) )
{
using ( Gizmo.GizmoControls.PushFixedScale() )
{
Gizmo.Draw.SolidBox( BBox.FromPositionAndSize( Vector3.Zero, 2f ) );
}
}
if ( Gizmo.HasClicked && Gizmo.Pressed.This )
{
using ( SceneEditorSession.Active.UndoScope( "Added spline point" ).WithComponentChanges( targetComponent ).Push() )
{
var newPointIndex = targetComponent.Spline.AddPointAtDistance( hoverSample.Distance, true );
SelectedPointIndex = newPointIndex;
_inTangentSelected = false;
_outTangentSelected = false;
}
}
}
}
}
}
// position location
var positionGizmoLocation = _selectedPoint.Position;
if ( _inTangentSelected )
{
positionGizmoLocation += _selectedPoint.In;
}
if ( _outTangentSelected )
{
positionGizmoLocation += _selectedPoint.Out;
}
if ( !Gizmo.IsShiftPressed )
{
_draggingOutNewPoint = false;
}
using ( Gizmo.Scope( "position", new Transform( positionGizmoLocation ) ) )
{
_moveInProgress = false;
if ( Gizmo.Control.Position( "spline_control_", Vector3.Zero, out var delta ) )
{
_moveInProgress = true;
_movementUndoScope ??= SceneEditorSession.Active.UndoScope( "Moved spline point" ).WithComponentChanges( targetComponent ).Push();
if ( _inTangentSelected )
{
MoveSelectedPointInTanget( delta );
}
else if ( _outTangentSelected )
{
MoveSelectedPointOutTanget( delta );
}
else
{
if ( Gizmo.IsShiftPressed && !_draggingOutNewPoint )
{
_draggingOutNewPoint = true;
var currentPoint = targetComponent.Spline.GetPoint( SelectedPointIndex );
targetComponent.Spline.InsertPoint( SelectedPointIndex + 1, currentPoint );
SelectedPointIndex++;
}
else
{
MoveSelectedPoint( delta );
}
}
}
if ( !_moveInProgress && Gizmo.WasLeftMouseReleased )
{
_movementUndoScope?.Dispose();
}
}
for ( var i = 0; i < targetComponent.Spline.PointCount; i++ )
{
if ( !targetComponent.Spline.IsLoop || i != targetComponent.Spline.SegmentCount )
{
var splinePoint = targetComponent.Spline.GetPoint( i );
using ( Gizmo.Scope( "point_controls" + i, new Transform( splinePoint.Position ) ) )
{
Gizmo.Draw.IgnoreDepth = true;
using ( Gizmo.Scope( "position" ) )
{
using ( Gizmo.GizmoControls.PushFixedScale() )
{
Gizmo.Hitbox.DepthBias = 0.1f;
Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 2f ) );
if ( Gizmo.IsHovered || i == SelectedPointIndex &&
(!_inTangentSelected && !_outTangentSelected) )
{
Gizmo.Draw.Color = Color.Orange;
}
Gizmo.Draw.SolidBox( BBox.FromPositionAndSize( Vector3.Zero, 2f ) );
if ( Gizmo.HasClicked && Gizmo.Pressed.This )
{
SelectedPointIndex = i;
_inTangentSelected = false;
_outTangentSelected = false;
}
}
}
Gizmo.Draw.Color = Color.White;
if ( SelectedPointIndex == i )
{
Gizmo.Draw.LineThickness = 0.8f;
using ( Gizmo.Scope( "in_tangent", new Transform( splinePoint.In ) ) )
{
if ( (_selectedPointTangentMode == HandleModeTemp.Mirrored || _selectedPointTangentMode == HandleModeTemp.Auto) && (_inTangentSelected || _outTangentSelected) )
{
Gizmo.Draw.Color = Color.Orange;
}
Gizmo.Draw.Line( -splinePoint.In, Vector3.Zero );
using ( Gizmo.GizmoControls.PushFixedScale() )
{
if ( _selectedPointTangentMode != HandleModeTemp.Linear )
{
Gizmo.Hitbox.DepthBias = 0.1f;
Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 2f ) );
if ( Gizmo.IsHovered || _inTangentSelected )
{
Gizmo.Draw.Color = Color.Orange;
}
Gizmo.Draw.SolidBox( BBox.FromPositionAndSize( Vector3.Zero, 2f ) );
if ( Gizmo.HasClicked && Gizmo.Pressed.This )
{
SelectedPointIndex = i;
_outTangentSelected = false;
_inTangentSelected = true;
}
}
}
}
using ( Gizmo.Scope( "out_tangent", new Transform( splinePoint.Out ) ) )
{
if ( (_selectedPointTangentMode == HandleModeTemp.Mirrored || _selectedPointTangentMode == HandleModeTemp.Auto) && (_inTangentSelected || _outTangentSelected) )
{
Gizmo.Draw.Color = Color.Orange;
}
Gizmo.Draw.Line( -splinePoint.Out, Vector3.Zero );
using ( Gizmo.GizmoControls.PushFixedScale() )
{
if ( _selectedPointTangentMode != HandleModeTemp.Linear )
{
Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 2f ) );
if ( Gizmo.IsHovered || _outTangentSelected )
{
Gizmo.Draw.Color = Color.Orange;
}
Gizmo.Draw.SolidBox( BBox.FromPositionAndSize( Vector3.Zero, 2f ) );
if ( Gizmo.HasClicked && Gizmo.Pressed.This )
{
SelectedPointIndex = i;
_inTangentSelected = false;
_outTangentSelected = true;
}
}
}
}
}
}
}
}
}
}
private void MoveSelectedPoint( Vector3 delta )
{
var updatedPoint = _selectedPoint with { Position = _selectedPoint.Position + delta };
targetComponent.Spline.UpdatePoint( SelectedPointIndex, updatedPoint );
}
private void MoveSelectedPointInTanget( Vector3 delta )
{
var updatedPoint = _selectedPoint;
updatedPoint.In += delta;
if ( _selectedPointTangentMode == HandleModeTemp.Auto )
{
updatedPoint = _selectedPoint with { Mode = Spline.HandleMode.Mirrored };
}
if ( _selectedPointTangentMode == HandleModeTemp.Mirrored )
{
updatedPoint.Out = -updatedPoint.In;
}
targetComponent.Spline.UpdatePoint( SelectedPointIndex, updatedPoint );
}
private void MoveSelectedPointOutTanget( Vector3 delta )
{
var updatedPoint = _selectedPoint;
updatedPoint.Out += delta;
if ( _selectedPointTangentMode == HandleModeTemp.Auto )
{
updatedPoint = _selectedPoint with { Mode = Spline.HandleMode.Mirrored };
}
if ( _selectedPointTangentMode == HandleModeTemp.Mirrored )
{
updatedPoint.In = -updatedPoint.Out;
}
targetComponent.Spline.UpdatePoint( SelectedPointIndex, updatedPoint );
}
}
using Editor;
using Sandbox.Spawners;
namespace Sandbox.Spawner;
public class BaseSpawnerEditor: EditorTool<BaseSpawner>
{
public override void OnUpdate()
{
var selected = GetSelectedComponent<BaseSpawner>();
foreach ( var spawner in Scene.GetAllComponents<BaseSpawner>() )
{
if ( spawner == null || spawner.Range <= 0 ) continue;
bool isSelected = spawner == selected;
float half = spawner.Range * 0.5f;
int segments = 20;
using ( Gizmo.ObjectScope( spawner.GameObject, spawner.WorldTransform ) )
{
Gizmo.Draw.Color = isSelected
? Color.Green
: Color.Black;
Gizmo.Draw.LineThickness = 2;
Gizmo.Draw.IgnoreDepth = true;
DrawProjectedEdge( new Vector2( -half, -half ), new Vector2( half, -half ), segments, spawner );
DrawProjectedEdge( new Vector2( half, -half ), new Vector2( half, half ), segments, spawner );
DrawProjectedEdge( new Vector2( half, half ), new Vector2( -half, half ), segments, spawner );
DrawProjectedEdge( new Vector2( -half, half ), new Vector2( -half, -half ), segments, spawner );
}
}
}
private void DrawProjectedEdge( Vector2 from, Vector2 to, int segments, BaseSpawner spawner )
{
Vector3? prev = null;
for ( int i = 0; i <= segments; i++ )
{
float t = (float)i / segments;
float lx = MathX.Lerp( from.x, to.x, t );
float ly = MathX.Lerp( from.y, to.y, t );
var worldXY = spawner.WorldTransform.PointToWorld( new Vector3( lx, ly, 0 ) );
var tr = spawner.Scene.Trace
.Ray( worldXY + Vector3.Up * 10000f, worldXY + Vector3.Down * 10000f )
.WithoutTags( "player" )
.Run();
// stay in local space — ObjectScope handles the transform
var hitWorld = tr.Hit ? tr.HitPosition + Vector3.Up * 2f : worldXY;
var localHit = spawner.WorldTransform.PointToLocal( hitWorld );
if ( prev.HasValue )
Gizmo.Draw.Line( prev.Value, localHit );
prev = localHit;
}
}
}
using Editor;
public static class MyEditorMenu
{
[Menu( "Editor", "SharpTalkTTS/My Menu Option" )]
public static void OpenMyMenu()
{
EditorUtility.DisplayDialog( "It worked!", "This is being called from your library's editor code!" );
}
}
using System;
using Sandbox;
using Editor;
/// <summary>
/// Modal-style confirmation dialog with Overwrite/Cancel buttons.
/// Use ConfirmDialog.Show(title, message, onConfirm) to display.
/// </summary>
public class ConfirmDialog : DockWindow
{
private readonly string _title;
private readonly string _message;
private readonly string _detail;
private readonly Action _onConfirm;
private Vector2 _mousePos;
private ConfirmDialog( string title, string message, string detail, Action onConfirm )
{
_title = title;
_message = message;
_detail = detail;
_onConfirm = onConfirm;
Title = title;
Size = new Vector2( 420, string.IsNullOrEmpty( detail ) ? 180 : 240 );
}
/// <summary>
/// Show a confirmation dialog with Overwrite and Cancel buttons.
/// </summary>
public static void Show( string title, string message, Action onConfirm, string detail = null )
{
var dialog = new ConfirmDialog( title, message, detail, onConfirm );
dialog.Show();
}
protected override void OnPaint()
{
base.OnPaint();
var pad = 20f;
var w = Width - pad * 2;
var y = 20f;
// Title
Paint.SetDefaultFont( size: 13, weight: 700 );
Paint.SetPen( Color.White );
Paint.DrawText( new Rect( pad, y, w, 22 ), _title, TextFlag.LeftCenter );
y += 32;
// Message
Paint.SetDefaultFont( size: 10 );
Paint.SetPen( Color.White.WithAlpha( 0.85f ) );
// Word-wrap the message manually
var words = _message.Split( ' ' );
var line = "";
foreach ( var word in words )
{
var test = string.IsNullOrEmpty( line ) ? word : $"{line} {word}";
if ( test.Length * 6.5f > w && !string.IsNullOrEmpty( line ) )
{
Paint.DrawText( new Rect( pad, y, w, 16 ), line, TextFlag.LeftCenter );
y += 17;
line = word;
}
else
{
line = test;
}
}
if ( !string.IsNullOrEmpty( line ) )
{
Paint.DrawText( new Rect( pad, y, w, 16 ), line, TextFlag.LeftCenter );
y += 20;
}
// Detail (smaller, dimmer)
if ( !string.IsNullOrEmpty( _detail ) )
{
y += 4;
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.Orange.WithAlpha( 0.7f ) );
Paint.DrawText( new Rect( pad, y, w, 14 ), _detail, TextFlag.LeftCenter );
y += 22;
}
y += 8;
// Buttons row
var btnH = 32f;
var btnW = ( w - 12 ) / 2;
// Cancel button (left)
var cancelRect = new Rect( pad, y, btnW, btnH );
var cancelHovered = cancelRect.IsInside( _mousePos );
Paint.SetBrush( Color.White.WithAlpha( cancelHovered ? 0.1f : 0.04f ) );
Paint.SetPen( Color.White.WithAlpha( cancelHovered ? 0.3f : 0.15f ) );
Paint.DrawRect( cancelRect, 4 );
Paint.SetDefaultFont( size: 11, weight: 600 );
Paint.SetPen( Color.White.WithAlpha( cancelHovered ? 0.9f : 0.6f ) );
Paint.DrawText( cancelRect, "Cancel", TextFlag.Center );
// Overwrite button (right)
var overwriteRect = new Rect( pad + btnW + 12, y, btnW, btnH );
var overwriteHovered = overwriteRect.IsInside( _mousePos );
Paint.SetBrush( Color.Red.WithAlpha( overwriteHovered ? 0.25f : 0.12f ) );
Paint.SetPen( Color.Red.WithAlpha( overwriteHovered ? 0.6f : 0.3f ) );
Paint.DrawRect( overwriteRect, 4 );
Paint.SetDefaultFont( size: 11, weight: 700 );
Paint.SetPen( Color.Red.WithAlpha( overwriteHovered ? 1f : 0.8f ) );
Paint.DrawText( overwriteRect, "Overwrite", TextFlag.Center );
_cancelRect = cancelRect;
_overwriteRect = overwriteRect;
}
private Rect _cancelRect;
private Rect _overwriteRect;
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
if ( _cancelRect.IsInside( e.LocalPosition ) )
{
Close();
}
else if ( _overwriteRect.IsInside( e.LocalPosition ) )
{
Close();
_onConfirm?.Invoke();
}
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
_mousePos = e.LocalPosition;
Update();
}
}
using Sandbox;
using Editor;
namespace RedSnail.RoadTool.Editor;
/// <summary>
/// Create and manage road and road intersection.
/// </summary>
[Title("Create Road/Intersection")]
[Icon("roundabout_left")]
[Alias("intersection")]
[Group("1")]
[Order(0)]
public class IntersectionTool : EditorTool
{
public override void OnEnabled()
{
}
public override Widget CreateToolSidebar()
{
ToolSidebarWidget sidebar = new ToolSidebarWidget();
sidebar.AddTitle("Intersection", "roundabout_left");
Layout group = sidebar.AddGroup("Create");
Layout row = Layout.Row();
IconButton road = sidebar.CreateButton("Create Road", "route", null, CreateRoad, true, row);
IconButton inter = sidebar.CreateButton("Create Intersection", "roundabout_left", null, CreateIntersection, true, row);
row.Spacing = 5;
row.AddStretchCell();
group.Add(row);
sidebar.Layout.Add(group);
sidebar.Layout.AddStretchCell();
return sidebar;
}
private static void CreateRoad()
{
GameObject go = SceneEditorSession.Active.Scene.CreateObject();
go.Name = "Road";
go.AddComponent<RoadComponent>();
}
private static void CreateIntersection()
{
GameObject go = SceneEditorSession.Active.Scene.CreateObject();
go.Name = "Road Intersection";
go.AddComponent<RoadIntersectionComponent>();
}
}
using System.Collections.Generic;
using System.Linq;
using Sandbox.SecBox.Bridge.Dto;
namespace Sandbox.SecBox.Lifecycle;
// One library's manual-scan outcome, produced by BootAudit.ScanAllLibraries and
// rendered by ScanResultsWindow. Carries enough to drive the per-library card
// and to hand off into the full ReviewDialog (findings + hash).
public sealed class LibraryScanResult
{
public string PackageIdent { get; set; }
public string Folder { get; set; }
public string ContentHash { get; set; }
// Current trust decision (preserved from the store), not changed by a scan.
public Decision Decision { get; set; } = Decision.NotReviewed;
public List<Finding> Findings { get; set; } = new();
public int CriticalCount { get; set; }
public int HighCount { get; set; }
public int MediumCount { get; set; }
public int LowCount { get; set; }
// Set when the scan itself failed for this library; Findings is then empty.
public string Error { get; set; }
public int TotalFindings => Findings?.Count ?? 0;
public bool HasError => !string.IsNullOrEmpty(Error);
public Severity MaxSeverity => Findings == null || Findings.Count == 0
? Severity.Info
: Findings.Max(f => f.Severity);
}
using System;
using System.Collections.Generic;
namespace Sandbox.SecBox.UI;
// Plain-language dictionary for the Default tab of the review window.
// Lookup is exact-match on the full RuleId first, then prefix match
// (rules like critical.interop.001/002/003 share a single explanation).
public static class FindingTranslator
{
public readonly record struct Explanation(string Title, string Plain);
static readonly Dictionary<string, Explanation> Exact = new(StringComparer.OrdinalIgnoreCase)
{
["native.unmanaged-dll"] = new(
"Ships a native Windows DLL",
"This package contains a native Windows library. The scanner cannot read machine code - once loaded, it can do anything on your machine."),
["native.unix-shared-object"] = new(
"Ships a native Unix library",
"This package contains a Linux or macOS native library (.so / .dylib). The scanner cannot read machine code."),
["native.executable"] = new(
"Ships a standalone executable",
"This package contains a runnable program (.exe, .bat, .ps1, .sh, .cmd). Managed library packages should not ship executables."),
["source.suspect-using"] = new(
"Imports a risky namespace",
"A source file imports a namespace commonly used for risky operations. On its own this is just a hint - check what the code does with it."),
["source.critical-attr"] = new(
"Uses a dangerous attribute",
"A source file applies an attribute such as [DllImport] that signals a clear attack-style pattern."),
["source.critical-ident"] = new(
"Names a dangerous type",
"A source file references a dangerous type by name (Process, AssemblyLoadContext, NativeLibrary, …). Strong attack signal."),
["source.high-ident"] = new(
"Names a risky type",
"A source file references a risky type (File, Socket, Registry, …). Confirm whether it is actually used in the compiled binary."),
["source.suspicious-literal"] = new(
"Suspicious string in source",
"A source file contains a string matching the name of a native API or shell command (kernel32, powershell, cmd.exe, …)."),
["source.read-failed"] = new(
"Could not read a source file",
"The scanner failed to open a source file. This is a scanner error, not necessarily a concern."),
["source.parse-failed"] = new(
"Could not parse a source file",
"The scanner failed to parse a file as C#. The file may be invalid or use unsupported syntax."),
["il.pinned-local"] = new(
"Pinned memory in compiled code",
"The compiled code pins memory in place. Usually paired with pointer arithmetic, this allows reading and writing outside normal .NET safety."),
["il.suspicious-literal"] = new(
"Suspicious string in compiled code",
"The compiled code embeds a string matching the name of a native API or shell command."),
["il.finalizer-trick"] = new(
"Finalizer-reference exploit",
"The compiled code references object finalizers indirectly via ldftn / ldvirtftn - a known .NET sandbox-escape technique."),
["il.read-failed"] = new(
"Could not read an assembly",
"The scanner failed to load a compiled .dll for IL analysis."),
["metadata.pinvoke"] = new(
"Declares a P/Invoke",
"The compiled code declares a direct call into native code via P/Invoke. This is one of the strongest possible attack signals."),
["metadata.explicit-layout"] = new(
"Memory-aliasing type",
"The code defines a type with explicit memory layout. Often used to reinterpret bytes as another type - a building block for memory exploits."),
["metadata.read-failed"] = new(
"Could not read assembly metadata",
"The scanner could not parse the assembly's metadata tables."),
["engine.not-whitelisted"] = new(
"Calls an API outside the engine allowlist",
"The code calls a .NET API that the s&box engine does not normally permit for game-side code."),
["engine.foreign-assembly"] = new(
"References an unknown assembly",
"The code references an assembly that is not part of the s&box engine or its game-side surface."),
["pipeline.finder-threw"] = new(
"A scanner step failed",
"One of the scanner steps threw an exception while inspecting this package. Treat as a scanner error."),
["core.scan-error"] = new(
"Scan failed",
"The overall scan failed with an error."),
};
static readonly (string Prefix, Explanation Entry)[] Prefixes =
{
("critical.interop.", new(
"Calls native code directly",
"This library calls into native code (Windows DLLs, Linux .so files). Native code runs outside the .NET sandbox and can do anything on the machine.")),
("critical.process.", new(
"Launches other programs",
"This library can start other programs on your machine (cmd.exe, PowerShell, anything on PATH).")),
("critical.dynamic-code.", new(
"Loads or compiles code at runtime",
"This library loads assemblies, emits IL, or compiles scripts at runtime. What the code does cannot be seen by a static scan.")),
("critical.raw-network.", new(
"Direct network access",
"This library opens raw network sockets or makes web requests outside the engine's sandboxed networking.")),
("critical.filesystem.", new(
"Direct file access",
"This library reads and writes files outside the engine's sandboxed filesystem (Sandbox.FileSystem).")),
("critical.reflection.", new(
"Inspects or invokes code by name",
"This library uses reflection to call code by name. Often used to reach private code or bypass restrictions.")),
("critical.environment.", new(
"Reads system / environment info",
"This library reads environment variables, the registry, or other machine info (username, machine name, …).")),
};
public static Explanation Translate(string ruleId)
{
if (string.IsNullOrEmpty(ruleId))
return new("Unrecognised finding", "The scanner produced a finding without a rule identifier.");
if (Exact.TryGetValue(ruleId, out var exact))
return exact;
foreach (var (prefix, entry) in Prefixes)
if (ruleId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return entry;
return new(ruleId, "No plain-language explanation is available for this rule yet. See the Advanced tab for technical details.");
}
}
using Editor;
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
namespace Sandbox;
public static class SCFU
{
const string BaseUrl = "https://f4pl0.com/scfu/";
static readonly string[] BinaryFiles =
{
"scfu.exe",
"scfu.dll",
"scfu.runtimeconfig.json",
"Microsoft.CodeAnalysis.CSharp.dll",
"Microsoft.CodeAnalysis.dll",
};
static string CacheDir => Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.LocalApplicationData ), "scfu" );
static string ExePath => Path.Combine( CacheDir, "scfu.exe" );
[Menu( "Editor", "SCFU/Fuck", "lock" )]
public static void Fuck() => _ = RunCommand( "fuck" );
[Menu( "Editor", "SCFU/Unfuck", "lock_open" )]
public static void Unfuck() => _ = RunCommand( "unfuck" );
[Menu( "Editor", "SCFU/Configure…", "tune" )]
public static void Configure()
{
var projectRoot = Project.Current?.GetRootPath();
if ( string.IsNullOrEmpty( projectRoot ) )
{
EditorUtility.DisplayDialog( "SCFU", "No active project.", icon: "🙃" );
return;
}
var window = new ScfuConfigWindow( projectRoot );
window.Show();
}
static async Task RunCommand( string command )
{
try
{
var projectRoot = Project.Current?.GetRootPath();
if ( string.IsNullOrEmpty( projectRoot ) )
{
EditorUtility.DisplayDialog( "SCFU", "No active project.", icon: "🙃" );
return;
}
await Download();
int exitCode;
try
{
exitCode = await RunProcess( command, projectRoot );
}
finally
{
Cleanup();
}
if ( exitCode == 0 )
{
if (command == "fuck")
EditorUtility.DisplayDialog( "SCFU", $"Fucking succeeded. RESTART YOUR EDITOR!", icon:"🤫" );
else if (command == "unfuck")
EditorUtility.DisplayDialog( "SCFU", $"Unfucking succeeded. RESTART YOUR EDITOR!", icon:"🤫" );
else
EditorUtility.DisplayDialog( "SCFU", $"`{command}` succeeded.", icon:"🤫" );
}
else
EditorUtility.DisplayDialog( "SCFU", $"`{command}` failed (exit {exitCode}). See console.", icon: "😬" );
}
catch ( Exception ex )
{
Log.Warning( $"[SCFU] {ex}" );
Cleanup();
EditorUtility.DisplayDialog( "SCFU error", ex.Message, icon: "😬" );
}
}
static void Cleanup()
{
try
{
if ( Directory.Exists( CacheDir ) )
Directory.Delete( CacheDir, recursive: true );
}
catch ( Exception ex )
{
Log.Warning( $"[SCFU] Cleanup failed: {ex.Message}" );
}
}
static async Task Download()
{
Cleanup();
Directory.CreateDirectory( CacheDir );
Log.Info( $"[SCFU] Downloading binary to {CacheDir}" );
using var http = new HttpClient();
foreach ( var file in BinaryFiles )
{
var url = BaseUrl + file;
var dest = Path.Combine( CacheDir, file );
try
{
using var resp = await http.GetAsync( url, HttpCompletionOption.ResponseHeadersRead );
resp.EnsureSuccessStatusCode();
await using var src = await resp.Content.ReadAsStreamAsync();
await using var fs = File.Create( dest );
await src.CopyToAsync( fs );
Log.Info( $"[SCFU] Fetched {file}" );
}
catch ( Exception ex )
{
throw new Exception( $"Failed to download {file}: {ex.Message}", ex );
}
}
}
static async Task<int> RunProcess( string command, string projectRoot )
{
var startInfo = new ProcessStartInfo
{
FileName = ExePath,
WorkingDirectory = CacheDir,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
startInfo.ArgumentList.Add( command );
startInfo.ArgumentList.Add( projectRoot );
Log.Info( $"[SCFU] Running: scfu {command} \"{projectRoot}\"" );
using var proc = new Process { StartInfo = startInfo };
proc.OutputDataReceived += ( _, e ) => { if ( e.Data != null ) Log.Info( $"[SCFU] {e.Data}" ); };
proc.ErrorDataReceived += ( _, e ) => { if ( e.Data != null ) Log.Warning( $"[SCFU] {e.Data}" ); };
proc.Start();
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
await proc.WaitForExitAsync();
return proc.ExitCode;
}
}
using System.Collections.Generic;
using Sandbox;
using Sandbox.UI;
namespace Grains.RazorDesigner.Canvas;
public readonly struct CanvasGeometry
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private static bool _logged;
/// <summary>RootPanel.Scale — framebuffer px per CSS px. Clamped ≥ ~0.001 in <see cref="From"/>.</summary>
public float Scale { get; }
/// <summary>The RootPanel rect in framebuffer px, relative to the canvas widget's top-left.</summary>
public Rect RootBoundsFb { get; }
/// <summary>Framebuffer px per widget px. Clamped ≥ ~0.01 in <see cref="From"/>.</summary>
public float DpiScale { get; }
private CanvasGeometry( float scale, Rect rootBoundsFb, float dpiScale )
{
Scale = scale;
RootBoundsFb = rootBoundsFb;
DpiScale = dpiScale;
}
public static CanvasGeometry From( DesignerScene scene, float widgetDpiScale )
{
var scale = (scene is not null && scene.CurrentScale >= 0.001f) ? scene.CurrentScale : 1f;
var dpi = widgetDpiScale >= 0.01f ? widgetDpiScale : 1f;
var rootFb = scene?.RootBoundsFb ?? default;
if ( !_logged )
{
Log.Info( $"{LogPrefix} CanvasGeometry first built (scale={scale:F3} rootFb={rootFb} dpi={dpi:F2})" );
_logged = true;
}
return new CanvasGeometry( scale, rootFb, dpi );
}
// ---- point conversions (Vector2 → Vector2) ----
public Vector2 WidgetToFb( Vector2 p ) => p * DpiScale;
public Vector2 FbToWidget( Vector2 p ) => p / DpiScale;
public Vector2 FbToCss( Vector2 p ) => new( (p.x - RootBoundsFb.Left) / Scale, (p.y - RootBoundsFb.Top) / Scale );
public Vector2 CssToFb( Vector2 p ) => new( p.x * Scale + RootBoundsFb.Left, p.y * Scale + RootBoundsFb.Top );
public Vector2 WidgetToCss( Vector2 p ) => FbToCss( WidgetToFb( p ) );
public Vector2 CssToWidget( Vector2 p ) => FbToWidget( CssToFb( p ) );
public Rect WidgetToFb( Rect r ) => new( r.Left * DpiScale, r.Top * DpiScale, r.Width * DpiScale, r.Height * DpiScale );
public Rect FbToWidget( Rect r ) => new( r.Left / DpiScale, r.Top / DpiScale, r.Width / DpiScale, r.Height / DpiScale );
public Rect FbToCss( Rect r ) => new( (r.Left - RootBoundsFb.Left) / Scale, (r.Top - RootBoundsFb.Top) / Scale, r.Width / Scale, r.Height / Scale );
public Rect CssToFb( Rect r ) => new( r.Left * Scale + RootBoundsFb.Left, r.Top * Scale + RootBoundsFb.Top, r.Width * Scale, r.Height * Scale );
public Rect WidgetToCss( Rect r ) => FbToCss( WidgetToFb( r ) );
public Rect CssToWidget( Rect r ) => FbToWidget( CssToFb( r ) );
// ---- deltas: scale only, no origin shift (drag math) ----
public Vector2 WidgetDeltaToCss( Vector2 widgetDelta ) => widgetDelta * DpiScale / Scale;
public float ConstantCss( float fbPx ) => fbPx / Scale;
public float ConstantWidget( float widgetPx ) => widgetPx * DpiScale / Scale;
// ---- live-panel box rects (framebuffer px). Caller must ensure p is a valid Panel. ----
public Rect MarginBoxFb( Panel p ) => p.Box.RectOuter;
public Rect BorderBoxFb( Panel p ) => p.Box.Rect;
public Rect ContentBoxFb( Panel p ) => p.Box.RectInner;
public Rect? SubtreeContentBoundsFb( IEnumerable<Panel> panels )
{
if ( panels is null ) return null;
bool any = false;
float minX = float.PositiveInfinity, minY = float.PositiveInfinity;
float maxX = float.NegativeInfinity, maxY = float.NegativeInfinity;
foreach ( var p in panels )
{
if ( p is null || !p.IsValid ) continue;
var r = p.Box.Rect; // border box, framebuffer px
if ( r.Width <= 0f || r.Height <= 0f ) continue;
if ( r.Left < minX ) minX = r.Left;
if ( r.Top < minY ) minY = r.Top;
if ( r.Right > maxX ) maxX = r.Right;
if ( r.Bottom > maxY ) maxY = r.Bottom;
any = true;
}
if ( !any ) return null;
return new Rect( minX, minY, maxX - minX, maxY - minY );
}
public static (float Left, float Top, float Width, float Height) ResolveAbsoluteCss(
Rect borderBoxFb, Rect parentContentBoxFb, Margin marginFb, float scale )
{
if ( scale < 0.001f ) scale = 1f;
// Only Left/Top margins shift the CSS `left`/`top`; Width/Height are border-box dimensions, so margin.Right/Bottom don't enter.
return (
(borderBoxFb.Left - parentContentBoxFb.Left) / scale - marginFb.Left / scale,
(borderBoxFb.Top - parentContentBoxFb.Top ) / scale - marginFb.Top / scale,
borderBoxFb.Width / scale,
borderBoxFb.Height / scale );
}
}
using Editor;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Inspector;
[CustomEditor( typeof( Length ) )]
public sealed class LengthControlWidget : ControlWidget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const int ValueEditWidthSolo = 60;
private const int ValueEditWidthCompact = 32;
private const int UnitWidthSolo = 96;
private const int UnitWidthCompact = 72;
public override bool SupportsMultiEdit => true;
private LineEdit _valueEdit;
private EnumControlWidget _unitWidget;
private UnitProxy _unitProxy;
private SerializedObject _unitSerialized;
// Synchronous change events on both sides; without this guard SetValue would loop.
private bool _syncing;
private sealed class UnitProxy
{
public LengthUnit Unit { get; set; }
}
// Engine-instantiated solo ctor. Width/Height/etc inspector rows go through this.
public LengthControlWidget( SerializedProperty property ) : this( property, icon: null ) { }
public LengthControlWidget( SerializedProperty property, string icon ) : base( property )
{
Log.Info( $"{LogPrefix} LengthControlWidget ctor for {property.Name} icon={icon ?? "(none)"}" );
var compact = !string.IsNullOrEmpty( icon );
Layout = Layout.Row();
Layout.Spacing = 2;
if ( compact )
{
var iconBox = new IconBoxWidget( this, icon )
{
FixedSize = new Vector2( Theme.RowHeight, Theme.RowHeight ),
};
Layout.Add( iconBox );
}
_valueEdit = new LineEdit( this );
_valueEdit.MinimumSize = new Vector2( compact ? ValueEditWidthCompact : ValueEditWidthSolo, Theme.RowHeight );
_valueEdit.MaximumSize = new Vector2( 4096, Theme.RowHeight );
_valueEdit.SetStyles( "background-color: transparent;" );
_valueEdit.EditingStarted += OnValueEditStarted;
_valueEdit.TextEdited += OnValueEditTextEdited;
_valueEdit.EditingFinished += OnValueEditFinished;
Layout.Add( _valueEdit, 1 );
_unitProxy = new UnitProxy { Unit = LengthUnit.Auto };
_unitSerialized = EditorTypeLibrary.GetSerializedObject( _unitProxy );
var unitProp = _unitSerialized.GetProperty( nameof( UnitProxy.Unit ) );
_unitWidget = new EnumControlWidget( unitProp );
_unitWidget.MinimumWidth = compact ? UnitWidthCompact : UnitWidthSolo;
_unitWidget.MaximumWidth = compact ? UnitWidthCompact : UnitWidthSolo;
_unitSerialized.OnPropertyChanged += OnUnitProxyChanged;
Layout.Add( _unitWidget );
SyncFromProperty();
}
private sealed class IconBoxWidget : Widget
{
private readonly string _icon;
public IconBoxWidget( Widget parent, string icon ) : base( parent )
{
_icon = icon;
}
protected override void OnPaint()
{
var rect = new Rect( 0, Size );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.7f ) );
Paint.DrawIcon( rect, _icon, Theme.RowHeight - 4, TextFlag.Center );
}
}
private void SyncFromProperty()
{
if ( _syncing ) return;
_syncing = true;
try
{
var len = SerializedProperty.GetValue<Length>( Length.Auto );
if ( !_valueEdit.IsFocused )
_valueEdit.Text = len.Value.ToString( "0.###" );
// Push through SerializedProperty so EnumControlWidget repaints; direct field assignment skips the event.
var unitProp = _unitSerialized.GetProperty( nameof( UnitProxy.Unit ) );
unitProp.SetValue( len.Unit );
_valueEdit.Enabled = len.Unit != LengthUnit.Auto;
}
finally
{
_syncing = false;
}
}
private void OnUnitProxyChanged( SerializedProperty property )
{
if ( _syncing ) return;
if ( ReadOnly || !SerializedProperty.IsEditable )
return;
_syncing = true;
try
{
var current = SerializedProperty.GetValue<Length>( Length.Auto );
var newValue = new Length( current.Value, _unitProxy.Unit );
Log.Info( $"{LogPrefix} LengthControlWidget OnUnitChanged {newValue}" );
PropertyStartEdit();
SerializedProperty.SetValue( newValue );
SignalValuesChanged();
PropertyFinishEdit();
_valueEdit.Enabled = _unitProxy.Unit != LengthUnit.Auto;
}
finally
{
_syncing = false;
}
}
private void OnValueEditStarted()
{
if ( ReadOnly || !SerializedProperty.IsEditable )
return;
PropertyStartEdit();
}
private void OnValueEditTextEdited( string text )
{
if ( _syncing ) return;
if ( ReadOnly || !SerializedProperty.IsEditable )
return;
if ( !float.TryParse( text, out var parsed ) )
return;
var current = SerializedProperty.GetValue<Length>( Length.Auto );
var newValue = new Length( parsed, current.Unit );
if ( newValue == current )
return;
_syncing = true;
try
{
SerializedProperty.SetValue( newValue );
SignalValuesChanged();
}
finally
{
_syncing = false;
}
}
private void OnValueEditFinished()
{
if ( _syncing ) return;
if ( ReadOnly || !SerializedProperty.IsEditable )
{
PropertyFinishEdit();
return;
}
var current = SerializedProperty.GetValue<Length>( Length.Auto );
if ( !float.TryParse( _valueEdit.Text, out var parsed ) )
{
// Restore on bad input; don't commit (avoids spurious ValueChanged).
_valueEdit.Text = current.Value.ToString( "0.###" );
Log.Info( $"{LogPrefix} LengthControlWidget OnValueEditFinished: parse failed, restored to {current.Value}" );
PropertyFinishEdit();
return;
}
var formatted = parsed.ToString( "0.###" );
if ( _valueEdit.Text != formatted )
_valueEdit.Text = formatted;
var newValue = new Length( parsed, current.Unit );
if ( newValue != current )
{
Log.Info( $"{LogPrefix} LengthControlWidget OnValueChanged {newValue}" );
_syncing = true;
try
{
SerializedProperty.SetValue( newValue );
SignalValuesChanged();
}
finally
{
_syncing = false;
}
}
PropertyFinishEdit();
}
protected override void OnValueChanged()
{
base.OnValueChanged();
SyncFromProperty();
}
}
using System;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Inspector;
public sealed class StateRuleAppearanceProxy
{
private readonly ControlRecord _record;
private readonly Func<int> _ruleIndex; // re-resolved each access; rules list can shift.
public event Action Changed;
public StateRuleAppearanceProxy( ControlRecord record, Func<int> ruleIndex )
{
_record = record ?? throw new ArgumentNullException( nameof(record) );
_ruleIndex = ruleIndex ?? throw new ArgumentNullException( nameof(ruleIndex) );
}
private Appearance Current => _record.StateRules[_ruleIndex()].Delta;
private void Mutate( Func<Appearance, Appearance> f )
{
var i = _ruleIndex();
var rule = _record.StateRules[i];
_record.StateRules[i] = rule with { Delta = f( rule.Delta ) };
Changed?.Invoke();
}
[Group( "Typography" )] [Title( "Override" )]
[Description( "Emit per-control typography rules. Off = inherit from theme." )]
public bool OverrideTypography { get => Current.OverrideTypography; set => Mutate( a => a with { OverrideTypography = value } ); }
[Group( "Typography" )] [Title( "Font" )]
[Description( "Font family (e.g. Poppins, Roboto Mono). Empty = inherit family from theme." )]
public string FontFamily { get => Current.FontFamily; set => Mutate( a => a with { FontFamily = value } ); }
[Group( "Typography" )] [Title( "Size" )]
[Description( "Font size." )]
public Length FontSize { get => Current.FontSize; set => Mutate( a => a with { FontSize = value } ); }
[Group( "Typography" )] [Title( "Weight" )]
[Description( "CSS font-weight: 100-900 (common: 400 normal, 600 semibold, 700 bold)." )]
public int FontWeight { get => Current.FontWeight; set => Mutate( a => a with { FontWeight = value } ); }
[Group( "Typography" )] [Title( "Color" )]
[Description( "Color of the control's content text. On TextEntry this is the entered text only — placeholder hint stays muted via the theme's .textentry .placeholder rule." )]
public Color Color { get => Current.Color; set => Mutate( a => a with { Color = value } ); }
[Group( "Typography" )] [Title( "Align" )]
[Description( "Horizontal text alignment within the label." )]
public TextAlignment TextAlign { get => Current.TextAlign; set => Mutate( a => a with { TextAlign = value } ); }
[Group( "Typography" )] [Title( "Italic" )]
[Description( "Render the text in italic (font-style: italic)." )]
public bool FontStyleItalic { get => Current.FontStyleItalic; set => Mutate( a => a with { FontStyleItalic = value } ); }
[Group( "Typography" )] [Title( "Transform" )]
[Description( "CSS text-transform — uppercase, lowercase, capitalize, or none." )]
public TextTransformKind TextTransform { get => Current.TextTransform; set => Mutate( a => a with { TextTransform = value } ); }
[Group( "Typography" )] [Title( "Letter Spacing" )]
[Description( "Extra space between characters. Auto = inherit." )]
public Length LetterSpacing { get => Current.LetterSpacing; set => Mutate( a => a with { LetterSpacing = value } ); }
[Group( "Typography" )] [Title( "Line Height" )]
[Description( "Line box height. Auto = inherit. Use em or % — the engine treats a bare number as px." )]
public Length LineHeight { get => Current.LineHeight; set => Mutate( a => a with { LineHeight = value } ); }
[Group( "Background" )] [Title( "Override" )]
[Description( "Emit per-control background rules. Off = inherit from theme." )]
public bool OverrideBackground { get => Current.OverrideBackground; set => Mutate( a => a with { OverrideBackground = value } ); }
[Group( "Background" )] [Title( "Color" )]
[Description( "Background color." )]
public Color BackgroundColor { get => Current.BackgroundColor; set => Mutate( a => a with { BackgroundColor = value } ); }
[Group( "Background" )] [Title( "Image" )]
[Description( "Background image: url('asset.png'), linear-gradient(to bottom, #fff, #000), or empty. Use the button to pick an image asset." )]
[BackgroundImagePicker]
public string BackgroundImage { get => Current.BackgroundImage; set => Mutate( a => a with { BackgroundImage = value } ); }
[Group( "Background" )] [Title( "Size" )]
[Description( "CSS background-size: 'cover', 'contain', or 1–2 lengths (e.g. '100% 100%', '200px 100px'). Empty = the texture's native pixel size (which visibly rescales as you zoom the canvas)." )]
public string BackgroundSize { get => Current.BackgroundSize; set => Mutate( a => a with { BackgroundSize = value } ); }
[Group( "Background" )] [Title( "Position" )]
[Description( "CSS background-position: X then optional Y. 'center' = 50%. e.g. 'center', '50% 100%', '10px 20px'. Empty = top-left." )]
public string BackgroundPosition { get => Current.BackgroundPosition; set => Mutate( a => a with { BackgroundPosition = value } ); }
[Group( "Background" )] [Title( "Repeat" )]
[Description( "CSS background-repeat: 'no-repeat', 'repeat', 'repeat-x', 'repeat-y'. Empty = 'repeat' (a bare url() tiles by default — usually you want 'no-repeat')." )]
public string BackgroundRepeat { get => Current.BackgroundRepeat; set => Mutate( a => a with { BackgroundRepeat = value } ); }
[Group( "Border" )] [Title( "Override" )]
[Description( "Emit per-control border rules. Off = inherit from theme." )]
public bool OverrideBorder { get => Current.OverrideBorder; set => Mutate( a => a with { OverrideBorder = value } ); }
[Group( "Border" )] [Title( "Radius" )]
[Description( "Corner radius (applies to all four corners)." )]
public Length BorderRadius { get => Current.BorderRadius; set => Mutate( a => a with { BorderRadius = value } ); }
[Group( "Border" )] [Title( "Color" )]
[Description( "Border color." )]
public Color BorderColor { get => Current.BorderColor; set => Mutate( a => a with { BorderColor = value } ); }
[Group( "Border" )] [Title( "Width" )]
[Description( "Border thickness." )]
public Length BorderWidth { get => Current.BorderWidth; set => Mutate( a => a with { BorderWidth = value } ); }
[Group( "Effects" )] [Title( "Override" )]
[Description( "Emit per-control box-shadow. Off = inherit from theme." )]
public bool OverrideEffects { get => Current.OverrideEffects; set => Mutate( a => a with { OverrideEffects = value } ); }
[Group( "Effects" )] [Title( "Shadow X" )]
[Description( "Horizontal offset of the box-shadow." )]
public Length BoxShadowX { get => Current.BoxShadowX; set => Mutate( a => a with { BoxShadowX = value } ); }
[Group( "Effects" )] [Title( "Shadow Y" )]
[Description( "Vertical offset of the box-shadow." )]
public Length BoxShadowY { get => Current.BoxShadowY; set => Mutate( a => a with { BoxShadowY = value } ); }
[Group( "Effects" )] [Title( "Shadow Blur" )]
[Description( "Blur radius of the box-shadow." )]
public Length BoxShadowBlur { get => Current.BoxShadowBlur; set => Mutate( a => a with { BoxShadowBlur = value } ); }
[Group( "Effects" )] [Title( "Shadow Color" )]
[Description( "Box-shadow color." )]
public Color BoxShadowColor { get => Current.BoxShadowColor; set => Mutate( a => a with { BoxShadowColor = value } ); }
[Group( "Effects" )] [Title( "Shadow Inset" )]
[Description( "Render the shadow inside the element instead of outside." )]
public bool BoxShadowInset { get => Current.BoxShadowInset; set => Mutate( a => a with { BoxShadowInset = value } ); }
[Group( "Effects" )] [Title( "Opacity" )]
[Description( "0 = transparent, 1 = fully opaque." )] [Range( 0, 1 )] [Step( 0.01f )]
public float Opacity { get => Current.Opacity; set => Mutate( a => a with { Opacity = value } ); }
[Group( "Constraints" )] [Title( "Override" )]
[Description( "Emit per-control margin / size constraint rules." )]
public bool OverrideConstraints { get => Current.OverrideConstraints; set => Mutate( a => a with { OverrideConstraints = value } ); }
[Group( "Constraints" )] [Title( "Margin" )]
[Description( "Space between this control's edge and its siblings (per-side: top, right, bottom, left)." )]
public Edges Margin { get => Current.Margin; set => Mutate( a => a with { Margin = value } ); }
[Group( "Constraints" )] [Title( "Min Width" )]
[Description( "Floor on this control's width." )]
public Length MinWidth { get => Current.MinWidth; set => Mutate( a => a with { MinWidth = value } ); }
[Group( "Constraints" )] [Title( "Max Width" )]
[Description( "Ceiling on this control's width." )]
public Length MaxWidth { get => Current.MaxWidth; set => Mutate( a => a with { MaxWidth = value } ); }
[Group( "Constraints" )] [Title( "Min Height" )]
[Description( "Floor on this control's height." )]
public Length MinHeight { get => Current.MinHeight; set => Mutate( a => a with { MinHeight = value } ); }
[Group( "Constraints" )] [Title( "Max Height" )]
[Description( "Ceiling on this control's height." )]
public Length MaxHeight { get => Current.MaxHeight; set => Mutate( a => a with { MaxHeight = value } ); }
[Group( "Interaction" )] [Title( "Override" )]
[Description( "Emit per-control cursor / overflow rules." )]
public bool OverrideInteraction { get => Current.OverrideInteraction; set => Mutate( a => a with { OverrideInteraction = value } ); }
[Group( "Interaction" )] [Title( "Cursor" )]
[Description( "Mouse cursor when hovering this control." )]
public CursorKind Cursor { get => Current.Cursor; set => Mutate( a => a with { Cursor = value } ); }
[Group( "Interaction" )] [Title( "Overflow" )]
[Description( "How to handle children that overflow this control's box." )]
public OverflowKind Overflow { get => Current.Overflow; set => Mutate( a => a with { Overflow = value } ); }
[Group( "Interaction" )] [Title( "Z-Index" )]
[Description( "Stacking order among siblings. 0 = default (no explicit stacking)." )]
public int ZIndex { get => Current.ZIndex; set => Mutate( a => a with { ZIndex = value } ); }
[Group( "Interaction" )] [Title( "Pointer Events" )]
[Description( "When off, this control is transparent to the mouse (pointer-events: none)." )]
public bool PointerEvents { get => Current.PointerEvents; set => Mutate( a => a with { PointerEvents = value } ); }
}
using System.Collections.Generic;
namespace Grains.RazorDesigner.Projection;
public readonly record struct ProjectionResult(
IReadOnlyList<PanelOp> PanelOps,
IReadOnlyList<string> ScssLines,
IReadOnlyList<string> RazorAttributes,
string RazorInnerText )
{
public static ProjectionResult Empty { get; } = new(
System.Array.Empty<PanelOp>(),
System.Array.Empty<string>(),
System.Array.Empty<string>(),
null );
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Projection;
using Grains.RazorDesigner.Serialization.IR;
using Grains.RazorDesigner.Validation;
using Sandbox;
namespace Grains.RazorDesigner.Serialization;
public readonly record struct SaveResult(
string JsonPath,
string RazorPath,
string ScssPath,
bool RazorExported,
IReadOnlyList<ValidationDiagnostic> Diagnostics,
string CSharpPath = null,
bool CSharpExported = false );
public readonly record struct LoadResult(
DesignerDocument Document,
string ResolvedPath,
IReadOnlyList<string> Warnings,
bool Success,
bool DriftDetected = false,
bool StampMismatch = false,
bool MigratedFromLegacy = false,
string LegacyRazorPath = null );
public static class DocumentIO
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public static SaveResult Save( DesignerDocument document, string razorPath, PreviewTheme theme )
{
// Perf probe (grd-pewf): covers validate + IR write + .razor/.scss regen + disk I/O.
var probeSw = System.Diagnostics.Stopwatch.StartNew();
var className = System.IO.Path.GetFileNameWithoutExtension( razorPath );
RewriteOutOfAssetsImages( document );
IReadOnlyList<ValidationDiagnostic> diags;
bool hasError;
try
{
diags = new Validator().Validate( new RecordNode( document.RootRecord ) );
hasError = diags.Any( d => d.Severity == DiagnosticSeverity.Error );
Log.Info( $"{LogPrefix} Save: validator returned {diags.Count} diagnostic(s), hasError={hasError}" );
foreach ( var d in diags )
Log.Warning( $"{LogPrefix} Save: {d.Severity} [{d.Code}] node={d.NodeId?.ToString() ?? "<document>"} — {d.Message}" );
}
catch ( Exception ex )
{
// Validator must not throw (spec: "validator is total"), but be defensive.
Log.Warning( $"{LogPrefix} Save: validator threw unexpectedly: {ex.Message}; proceeding as if no errors" );
diags = Array.Empty<ValidationDiagnostic>();
hasError = false;
}
var probeDiskMs = 0.0;
var probeLap = System.Diagnostics.Stopwatch.StartNew();
var json = IRWriter.WriteDocument( document );
var probeJsonMs = probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
var hash = IRWriter.CanonicalHash( json );
var probeHashMs = probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
var jsonPath = razorPath + ".json";
var tmp = jsonPath + ".tmp";
System.IO.File.WriteAllText( tmp, json );
System.IO.File.Move( tmp, jsonPath, overwrite: true );
probeDiskMs += probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
Log.Info( $"{LogPrefix} Saved -> {jsonPath}" );
// Step 4: Conditional .razor + .razor.scss regeneration.
var scssPath = razorPath + ".scss";
var csPath = razorPath + ".cs";
bool razorExported = false;
bool csharpExported = false;
var probeRazorMs = 0.0;
var probeScssMs = 0.0;
var probeCsharpMs = 0.0;
if ( !hasError )
{
try
{
probeLap.Restart();
var razor = DocumentSerializer.GenerateRazorMarkup( document, hash );
probeRazorMs = probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
var scss = DocumentSerializer.GenerateSavedScss( document, className, theme );
probeScssMs = probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
System.IO.File.WriteAllText( razorPath, razor );
System.IO.File.WriteAllText( scssPath, scss );
probeDiskMs += probeLap.Elapsed.TotalMilliseconds;
razorExported = true;
Log.Info( $"{LogPrefix} Saved -> {razorPath}" );
Log.Info( $"{LogPrefix} Saved -> {scssPath}" );
}
catch ( Exception ex )
{
Log.Error( $"{LogPrefix} Razor export failed (JSON saved OK): {ex.Message}" );
}
try
{
probeLap.Restart();
var nsFallback = Project.Current?.Package?.Ident ?? "Grains";
var cs = DocumentSerializer.GenerateCSharp( document, className, nsFallback );
probeCsharpMs = probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
if ( cs is not null )
{
System.IO.File.WriteAllText( csPath, cs );
probeDiskMs += probeLap.Elapsed.TotalMilliseconds;
csharpExported = true;
Log.Info( $"{LogPrefix} Saved -> {csPath}" );
}
else
{
Log.Info( $"{LogPrefix} CSharp emit elided (empty wiring); existing {csPath} untouched if present." );
}
}
catch ( Exception ex )
{
Log.Error( $"{LogPrefix} CSharp export failed (JSON saved OK): {ex.Message}" );
}
}
else
{
var errorCount = diags.Count( d => d.Severity == DiagnosticSeverity.Error );
Log.Warning(
$"{LogPrefix} Razor export skipped — {errorCount} validation error(s); " +
$".razor.json saved. Fix the errors and re-save to regenerate .razor." );
}
var probeTotal = probeSw.Elapsed.TotalMilliseconds;
Log.Info( $"{LogPrefix} probe: Save took {probeTotal:F3}ms = IRWrite {probeJsonMs:F3} + hash {probeHashMs:F3} + razorGen {probeRazorMs:F3} + scssGen {probeScssMs:F3} + csharpGen {probeCsharpMs:F3} + disk {probeDiskMs:F3} + validate/other {probeTotal - probeJsonMs - probeHashMs - probeRazorMs - probeScssMs - probeCsharpMs - probeDiskMs:F3} (razorExported={razorExported}, csharpExported={csharpExported})" );
return new SaveResult( jsonPath, razorPath, scssPath, razorExported, diags, csPath, csharpExported );
}
private static readonly Regex IrHashStampRegex = new(
@"@\*\s*generated-from-ir-hash:\s*(?<h>[0-9a-f]+)\s*\*@",
RegexOptions.Compiled );
private static string NormalizeNewlines( string s ) =>
s is null ? null : s.Replace( "\r\n", "\n" ).Replace( "\r", "\n" );
public static LoadResult Load( string path )
{
if ( string.IsNullOrEmpty( path ) )
{
Log.Warning( $"{LogPrefix} Load: path is null or empty" );
return new LoadResult( null, path, new[] { "path is null or empty" }, Success: false );
}
var stem = path.EndsWith( ".razor.json", StringComparison.OrdinalIgnoreCase )
? path[..^5] // strip ".json" → yields "…/Foo.razor"
: path;
var jsonPath = stem + ".json"; // "…/Foo.razor.json"
Log.Info( $"{LogPrefix} Load: stem={stem} jsonPath={jsonPath}" );
if ( File.Exists( jsonPath ) )
{
DesignerDocument doc;
try
{
var jsonText = File.ReadAllText( jsonPath );
doc = IRReader.ReadDocument( jsonText );
}
catch ( Exception ex )
{
var msg = $"Failed to read .razor.json: {ex.Message}";
Log.Error( $"{LogPrefix} Load: {msg} ({jsonPath})" );
return new LoadResult( null, jsonPath, new[] { msg }, Success: false,
LegacyRazorPath: stem );
}
// Check for drift / stamp-mismatch if .razor also exists.
if ( File.Exists( stem ) )
{
var razorText = NormalizeNewlines( File.ReadAllText( stem ) );
var jsonForHash = File.ReadAllText( jsonPath );
var expectedHash = IRWriter.CanonicalHash( jsonForHash );
var regeneratedRazor = NormalizeNewlines( DocumentSerializer.GenerateRazorMarkup( doc, expectedHash ) );
if ( string.Equals( razorText, regeneratedRazor, StringComparison.Ordinal ) )
{
// .razor matches the IR byte-for-byte (modulo line endings) — silent canonical load.
Log.Info( $"{LogPrefix} Load: loaded from .razor.json (canonical, .razor matches IR)" );
return new LoadResult( doc, jsonPath, Array.Empty<string>(), Success: true,
LegacyRazorPath: stem );
}
var stampMatch = IrHashStampRegex.Match( razorText );
var stampHash = stampMatch.Success ? stampMatch.Groups["h"].Value : null;
if ( stampHash == expectedHash )
{
Log.Info( $"{LogPrefix} Load: drift detected — .razor body hand-edited (stamp intact: {stampHash})" );
return new LoadResult( doc, jsonPath, Array.Empty<string>(), Success: true,
DriftDetected: true, LegacyRazorPath: stem );
}
Log.Info( $"{LogPrefix} Load: stamp mismatch — .razor generated from a different IR " +
$"(stamp={stampHash ?? "<absent>"} expected={expectedHash})" );
return new LoadResult( doc, jsonPath, Array.Empty<string>(), Success: true,
StampMismatch: true, LegacyRazorPath: stem );
}
// .razor.json present, .razor absent — clean canonical load (no drift check possible).
Log.Info( $"{LogPrefix} Load: loaded from .razor.json (canonical, no .razor sibling)" );
return new LoadResult( doc, jsonPath, Array.Empty<string>(), Success: true,
LegacyRazorPath: stem );
}
if ( File.Exists( stem ) )
{
Log.Info( $"{LogPrefix} Load: no .razor.json found — cold-migrating from legacy .razor: {stem}" );
var (doc, diags) = LegacyRazorImporter.Import( stem );
var warnings = diags.Select( d => $"{d.Severity}: {d.Message}" ).ToList();
if ( doc == null )
{
var reason = warnings.Count > 0 ? warnings[warnings.Count - 1] : "unknown";
Log.Warning( $"{LogPrefix} Load: cold migration aborted — {reason}" );
return new LoadResult( null, stem, warnings, Success: false,
LegacyRazorPath: stem );
}
// Write the .razor.json sibling so subsequent opens go through the canonical path.
WriteJson( doc, jsonPath );
Log.Info( $"{LogPrefix} Load: cold-migrated from legacy .razor → wrote {jsonPath}" );
var migDiag = new ValidationDiagnostic(
NodeId: null,
Severity: DiagnosticSeverity.Warn,
Code: "legacy-migrated",
Message: $"Migrated from legacy .razor — created {jsonPath}" );
warnings.Add( $"{migDiag.Severity}: {migDiag.Message}" );
return new LoadResult( doc, stem, warnings, Success: true,
MigratedFromLegacy: true, LegacyRazorPath: stem );
}
Log.Warning( $"{LogPrefix} Load: file not found: {path}" );
return new LoadResult( null, path, new[] { $"file not found: {path}" }, Success: false );
}
internal static void WriteJson( DesignerDocument doc, string jsonPath )
{
var json = IRWriter.WriteDocument( doc );
var tmp = jsonPath + ".tmp";
File.WriteAllText( tmp, json );
File.Move( tmp, jsonPath, overwrite: true );
Log.Info( $"{LogPrefix} WriteJson: wrote {jsonPath}" );
}
public static void RewriteOutOfAssetsImages( DesignerDocument document )
{
var assetsRoot = Project.Current?.GetAssetsPath();
if ( string.IsNullOrEmpty( assetsRoot ) )
{
Log.Warning( $"{LogPrefix} Auto-import skipped: Project.Current has no assets path" );
return;
}
var importsDir = System.IO.Path.Combine( assetsRoot, "ImageImports" );
foreach ( var r in document.WalkAll() )
{
if ( r.Type != ControlType.Image ) continue;
if ( string.IsNullOrEmpty( r.Source ) ) continue;
// Inside-Assets paths don't escape; nothing to do.
var rel = r.Source.Replace( '\\', '/' );
if ( !rel.StartsWith( "../" ) ) continue;
var sourceAbs = System.IO.Path.GetFullPath( r.Source, assetsRoot );
if ( !System.IO.File.Exists( sourceAbs ) )
{
Log.Warning( $"{LogPrefix} Auto-import skipped (file missing): {sourceAbs}" );
continue;
}
System.IO.Directory.CreateDirectory( importsDir );
var fileName = System.IO.Path.GetFileName( sourceAbs );
var stem = System.IO.Path.GetFileNameWithoutExtension( fileName );
var ext = System.IO.Path.GetExtension( fileName );
var dest = System.IO.Path.Combine( importsDir, fileName );
var n = 1;
while ( System.IO.File.Exists( dest ) && !FilesEqual( sourceAbs, dest ) )
{
dest = System.IO.Path.Combine( importsDir, $"{stem}_{n}{ext}" );
n++;
}
if ( !System.IO.File.Exists( dest ) )
{
System.IO.File.Copy( sourceAbs, dest );
Log.Info( $"{LogPrefix} Auto-imported image: {sourceAbs} -> {dest}" );
}
else
{
Log.Info( $"{LogPrefix} Auto-import: existing identical file reused at {dest}" );
}
r.Source = $"ImageImports/{System.IO.Path.GetFileName( dest )}";
}
}
private static bool FilesEqual( string a, string b )
{
try
{
var ia = new System.IO.FileInfo( a );
var ib = new System.IO.FileInfo( b );
if ( ia.Length != ib.Length ) return false;
using var sa = ia.OpenRead();
using var sb = ib.OpenRead();
var bufA = new byte[8192];
var bufB = new byte[8192];
int read;
while ( ( read = sa.Read( bufA, 0, bufA.Length ) ) > 0 )
{
var got = sb.Read( bufB, 0, read );
if ( got != read ) return false;
for ( int i = 0; i < read; i++ )
if ( bufA[i] != bufB[i] ) return false;
}
return true;
}
catch ( System.Exception e )
{
Log.Warning( $"{LogPrefix} FilesEqual({a}, {b}) failed: {e.Message}" );
return false;
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Grains.RazorDesigner.Document;
using Sandbox;
using Sandbox.UI;
using Length = Grains.RazorDesigner.Document.Length;
namespace Grains.RazorDesigner.Templates;
public sealed class PaletteTemplateException : Exception
{
public PaletteTemplateException( string message ) : base( message ) { }
public PaletteTemplateException( string message, Exception inner ) : base( message, inner ) { }
}
public static class PaletteTemplateSerializer
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const int CurrentVersion = 1;
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
private static FieldDescriptor[] _fields;
private static FieldDescriptor[] Fields => _fields ??= BuildFieldDescriptors();
public static string Serialize( PaletteTemplate t )
{
if ( t is null ) throw new ArgumentNullException( nameof( t ) );
var doc = new JsonObject
{
["version"] = CurrentVersion,
["name"] = t.Name,
["icon"] = t.IconName,
["wrappedInContainer"] = t.WrappedInContainer,
["roots"] = WriteRecordList( t.Roots ),
};
return doc.ToJsonString( JsonOpts );
}
public static PaletteTemplate Deserialize( string json, string filePath )
{
if ( string.IsNullOrEmpty( json ) )
throw new PaletteTemplateException( "empty JSON" );
JsonNode parsed;
try
{
parsed = JsonNode.Parse( json );
}
catch ( JsonException ex )
{
throw new PaletteTemplateException( $"malformed JSON: {ex.Message}", ex );
}
if ( parsed is not JsonObject root )
throw new PaletteTemplateException( "deserialised to null" );
int version = TryReadInt( root["version"] );
if ( version != CurrentVersion )
throw new PaletteTemplateException( $"unsupported schema version {version} (expected {CurrentVersion})" );
var name = TryReadString( root["name"] );
if ( string.IsNullOrWhiteSpace( name ) )
throw new PaletteTemplateException( "name field missing or empty" );
return new PaletteTemplate(
Name: name,
IconName: TryReadString( root["icon"] ) ?? "",
WrappedInContainer: TryReadBool( root["wrappedInContainer"] ),
Roots: ReadRecordList( root["roots"] as JsonArray ),
FilePath: filePath );
}
// ---------- Walker (records) ----------
private static JsonArray WriteRecordList( IReadOnlyList<ControlRecord> records )
{
var arr = new JsonArray();
if ( records is null ) return arr;
foreach ( var r in records )
arr.Add( WriteRecord( r ) );
return arr;
}
private static JsonObject WriteRecord( ControlRecord r )
{
var o = new JsonObject();
o["type"] = r.Type.ToString();
foreach ( var f in Fields )
o[f.JsonName] = f.Write( f.Get( r ) );
if ( r.IsSlot )
{
o["isSlot"] = true;
o["slotName"] = r.SlotName ?? "";
}
if ( r.StateRules is { Count: > 0 } )
o["stateRules"] = WriteStateRuleList( r.StateRules );
o["children"] = WriteRecordList( r.Children );
return o;
}
private static List<ControlRecord> ReadRecordList( JsonArray arr )
{
var list = new List<ControlRecord>();
if ( arr is null ) return list;
foreach ( var node in arr )
{
var rec = ReadRecord( node as JsonObject );
if ( rec is null ) continue; // unknown type — already warned
list.Add( rec );
}
return list;
}
private static ControlRecord ReadRecord( JsonObject o )
{
if ( o is null ) return null;
var typeStr = TryReadString( o["type"] ) ?? "";
if ( !Enum.TryParse<ControlType>( typeStr, ignoreCase: false, out var type ) )
{
Log.Warning( $"{LogPrefix} PaletteTemplateSerializer: unknown ControlType \"{typeStr}\", skipping node" );
return null;
}
var rec = new ControlRecord { Type = type };
foreach ( var f in Fields )
{
var node = o[f.JsonName];
if ( node is null ) continue; // missing → keep ControlRecord property default
var value = f.Read( node );
if ( value is not null ) f.Set( rec, value );
}
if ( o["isSlot"] is JsonNode isSlotNode && TryReadBool( isSlotNode ) )
{
rec.IsSlot = true;
rec.SlotName = TryReadString( o["slotName"] ) ?? "";
}
RunMigrations( rec );
if ( o["stateRules"] is JsonArray stateRulesArr )
{
foreach ( var rule in ReadStateRuleList( stateRulesArr ) )
rec.StateRules.Add( rule );
}
if ( o["children"] is JsonArray children )
{
foreach ( var child in ReadRecordList( children ) )
rec.Children.Add( child );
}
return rec;
}
private static JsonArray WriteStateRuleList( IReadOnlyList<StateRule> rules )
{
var arr = new JsonArray();
if ( rules is null ) return arr;
foreach ( var rule in rules )
arr.Add( WriteStateRule( rule ) );
return arr;
}
private static JsonObject WriteStateRule( StateRule rule )
{
var o = new JsonObject();
o["state"] = rule.State.ToString();
// NthChild extras — omit when at default (Literal / 1) to match IR wire format economy.
if ( rule.State == PseudoKind.NthChild )
{
o["nthChildMode"] = rule.NthChildMode.ToString();
o["nthChildArg"] = rule.NthChildArg;
}
o["delta"] = WriteAppearance( rule.Delta );
return o;
}
private static List<StateRule> ReadStateRuleList( JsonArray arr )
{
var list = new List<StateRule>();
if ( arr is null ) return list;
foreach ( var node in arr )
{
if ( node is not JsonObject o ) continue;
var stateStr = TryReadString( o["state"] ) ?? "";
if ( !Enum.TryParse<PseudoKind>( stateStr, ignoreCase: true, out var state ) )
{
Log.Warning( $"{LogPrefix} PaletteTemplateSerializer: unknown PseudoKind \"{stateStr}\", skipping state rule" );
continue;
}
var mode = NthChildMode.Literal;
var arg = 1;
if ( o["nthChildMode"] is JsonNode modeNode )
{
var modeStr = TryReadString( modeNode ) ?? "";
if ( Enum.TryParse<NthChildMode>( modeStr, ignoreCase: true, out var parsedMode ) )
mode = parsedMode;
}
if ( o["nthChildArg"] is JsonNode argNode )
arg = TryReadInt( argNode );
var delta = o["delta"] is JsonObject deltaObj ? ReadAppearance( deltaObj ) : Appearance.Default;
list.Add( new StateRule { State = state, NthChildMode = mode, NthChildArg = arg, Delta = delta } );
}
return list;
}
private static JsonNode WriteAppearance( Appearance a )
{
var json = JsonSerializer.Serialize( a, Grains.RazorDesigner.Serialization.IR.DesignerIRJson.Options );
return JsonNode.Parse( json );
}
private static Appearance ReadAppearance( JsonObject o )
{
if ( o is null ) return Appearance.Default;
return JsonSerializer.Deserialize<Appearance>(
o.ToJsonString(),
Grains.RazorDesigner.Serialization.IR.DesignerIRJson.Options );
}
// ---------- Migrations ----------
private static void RunMigrations( ControlRecord rec )
{
// IconPanel glyph used to live in Content (pre-grd-zcq); route into IconName when empty.
if ( rec.Type == ControlType.IconPanel && string.IsNullOrEmpty( rec.IconName ) )
{
rec.IconName = rec.Content ?? "";
}
if ( rec.Type == ControlType.IconPanel )
rec.Content = "";
// TextEntry placeholder used to live in Content (pre-grd-3oa); route into Placeholder.
if ( rec.Type == ControlType.TextEntry && string.IsNullOrEmpty( rec.Placeholder ) )
{
rec.Placeholder = rec.Content ?? "";
}
// TextEntry never carries Content in v3+. Same belt-and-braces shape as IconPanel.
if ( rec.Type == ControlType.TextEntry )
rec.Content = "";
// FontWeight missing in pre-typography templates → default 400.
if ( rec.FontWeight == 0 ) rec.FontWeight = 400;
// Opacity missing in pre-Tier-2 templates → default 1 (fully opaque).
if ( rec.Opacity == 0f ) rec.Opacity = 1f;
}
// ---------- Static schema build ----------
private static FieldDescriptor[] BuildFieldDescriptors()
{
var props = typeof( ControlRecord )
.GetProperties( BindingFlags.Public | BindingFlags.Instance )
.Where( p => p.CanRead && p.CanWrite )
.Where( p => p.GetCustomAttribute<HideAttribute>() == null )
.Where( p => !typeof( Panel ).IsAssignableFrom( p.PropertyType ) )
.OrderBy( p => p.MetadataToken )
.ToArray();
var list = new List<FieldDescriptor>( props.Length );
foreach ( var p in props )
{
if ( !TryResolveConverter( p.PropertyType, out var conv ) )
{
throw new InvalidOperationException(
$"PaletteTemplateSerializer: no converter for ControlRecord.{p.Name} (type {p.PropertyType.FullName}). " +
$"Register one in TryResolveConverter." );
}
var prop = p; // closure capture
var jsonName = p.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
?? JsonNamingPolicy.CamelCase.ConvertName( p.Name );
list.Add( new FieldDescriptor
{
JsonName = jsonName,
Get = rec => prop.GetValue( rec ),
Set = ( rec, val ) => prop.SetValue( rec, val ),
Write = conv.Write,
Read = conv.Read,
} );
}
Log.Info( $"{LogPrefix} PaletteTemplateSerializer: {list.Count} ControlRecord fields mirrored." );
return list.ToArray();
}
// ---------- Converters ----------
private readonly struct Converter
{
public Converter( Func<object, JsonNode> write, Func<JsonNode, object> read )
{
Write = write;
Read = read;
}
public Func<object, JsonNode> Write { get; }
public Func<JsonNode, object> Read { get; }
}
private static bool TryResolveConverter( Type t, out Converter conv )
{
if ( t == typeof( string ) ) { conv = ConvString; return true; }
if ( t == typeof( int ) ) { conv = ConvInt; return true; }
if ( t == typeof( float ) ) { conv = ConvFloat; return true; }
if ( t == typeof( bool ) ) { conv = ConvBool; return true; }
if ( t == typeof( Length ) ) { conv = ConvLength; return true; }
if ( t == typeof( Edges ) ) { conv = ConvEdges; return true; }
if ( t == typeof( Color ) ) { conv = ConvColor; return true; }
if ( t.IsEnum ) { conv = MakeEnumConverter( t ); return true; }
conv = default;
return false;
}
private static readonly Converter ConvString = new(
write: v => JsonValue.Create( (string)v ?? "" ),
read: n => TryReadString( n ) );
private static readonly Converter ConvInt = new(
write: v => JsonValue.Create( (int)v ),
read: n => (object)TryReadInt( n ) );
private static readonly Converter ConvFloat = new(
write: v => JsonValue.Create( (float)v ),
read: n => (object)TryReadFloat( n ) );
private static readonly Converter ConvBool = new(
write: v => JsonValue.Create( (bool)v ),
read: n => (object)TryReadBool( n ) );
private static readonly Converter ConvLength = new(
write: v => JsonValue.Create( ((Length)v).ToCss() ),
read: n => Length.TryParse( TryReadString( n ) ?? "", out var l ) ? (object)l : null );
private static readonly Converter ConvEdges = new(
write: v =>
{
var e = (Edges)v;
if ( e.IsUniform ) return JsonValue.Create( e.Top.ToCss() );
return new JsonObject
{
["top"] = e.Top.ToCss(),
["right"] = e.Right.ToCss(),
["bottom"] = e.Bottom.ToCss(),
["left"] = e.Left.ToCss(),
};
},
read: n =>
{
if ( n is null ) return null;
if ( n is JsonObject o )
{
var top = ParseEdgeSide( o["top"] );
var right = ParseEdgeSide( o["right"] );
var bottom = ParseEdgeSide( o["bottom"] );
var left = ParseEdgeSide( o["left"] );
return (object)new Edges( top, right, bottom, left );
}
var s = TryReadString( n ) ?? "";
return Length.TryParse( s, out var len ) ? (object)Edges.Uniform( len ) : null;
} );
private static Length ParseEdgeSide( JsonNode n )
{
if ( n is null ) return Length.Px( 0 );
var s = TryReadString( n ) ?? "";
return Length.TryParse( s, out var len ) ? len : Length.Px( 0 );
}
private static readonly Converter ConvColor = new(
write: v => JsonValue.Create( ((Color)v).Hex ),
read: n =>
{
var s = TryReadString( n );
if ( string.IsNullOrEmpty( s ) ) return null;
var parsed = Color.Parse( s );
return parsed.HasValue ? (object)parsed.Value : null;
} );
private static Converter MakeEnumConverter( Type enumType )
{
return new Converter(
write: v => JsonValue.Create( v?.ToString() ?? "" ),
read: n =>
{
var s = TryReadString( n );
if ( string.IsNullOrEmpty( s ) ) return null;
return Enum.TryParse( enumType, s, ignoreCase: true, out var v ) ? v : null;
} );
}
private static string TryReadString( JsonNode n )
{
if ( n is null ) return null;
try { return n.GetValue<string>(); } catch { return n.ToString(); }
}
private static int TryReadInt( JsonNode n )
{
if ( n is null ) return 0;
try { return n.GetValue<int>(); }
catch
{
try { return (int)n.GetValue<double>(); }
catch { return int.TryParse( n.ToString(), out var v ) ? v : 0; }
}
}
private static float TryReadFloat( JsonNode n )
{
if ( n is null ) return 0f;
try { return n.GetValue<float>(); }
catch
{
try { return (float)n.GetValue<double>(); }
catch { return float.TryParse( n.ToString(), out var v ) ? v : 0f; }
}
}
private static bool TryReadBool( JsonNode n )
{
if ( n is null ) return false;
try { return n.GetValue<bool>(); } catch { return false; }
}
private sealed class FieldDescriptor
{
public string JsonName;
public Func<ControlRecord, object> Get;
public Action<ControlRecord, object> Set;
public Func<object, JsonNode> Write;
public Func<JsonNode, object> Read;
}
}
using System.Text.Json.Serialization;
namespace Grains.RazorDesigner.Document;
public sealed record FieldPayload : Payload
{
[JsonIgnore]
public override ControlType Kind => ControlType.Field;
}