Search the source of every open source package.
290 results
using System;
namespace SboxMcp.Registry;
public enum ToolCategory
{
Scene,
GameObject,
Component,
Prefab,
Asset,
ModelDoc,
AnimGraph,
ShaderGraph,
ActionGraph,
Code,
Editor,
Retargeter,
Cloud,
Imported
}
/// <summary>
/// Marks a static method as an MCP tool. The registry reflects the method's
/// parameters into a JSON Schema and exposes it via tools/list.
/// </summary>
[AttributeUsage( AttributeTargets.Method )]
public sealed class McpToolAttribute : Attribute
{
public string Name { get; }
public string Description { get; }
public ToolCategory Category { get; }
/// <summary>Write tools are subject to the permission gate (approve-writes / read-only modes).</summary>
public bool Writes { get; init; }
/// <summary>
/// Optional requirement key (e.g. an integration's library ident). The host
/// resolves it via ToolRegistry.RequirementResolver; unresolved tools are
/// hidden from clients and shown disabled in the tool browser.
/// </summary>
public string Requires { get; init; }
/// <summary>
/// Ships disabled; the user must enable it in the tool browser. Used for
/// tools with external effects (e.g. downloading cloud assets).
/// </summary>
public bool DisabledByDefault { get; init; }
public McpToolAttribute( string name, string description, ToolCategory category )
{
Name = name;
Description = description;
Category = category;
}
}
/// <summary>
/// Optional description for a tool parameter, surfaced in the JSON Schema.
/// </summary>
[AttributeUsage( AttributeTargets.Parameter )]
public sealed class DescAttribute : Attribute
{
public string Text { get; }
public DescAttribute( string text ) { Text = text; }
}
/// <summary>
/// Thrown when tool arguments are missing or cannot be bound; surfaced to the
/// MCP client as an isError tool result.
/// </summary>
public sealed class ToolArgumentException : Exception
{
public ToolArgumentException( string message, Exception inner = null ) : base( message, inner ) { }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using SboxMcp.Server;
namespace SboxMcp.Registry;
/// <summary>
/// A discovered [McpTool] method, with its generated descriptor and an
/// argument-binding invoker.
/// </summary>
public sealed class RegisteredTool
{
public McpToolAttribute Meta { get; }
public MethodInfo Method { get; }
public McpToolDescriptor Descriptor { get; }
/// <summary>
/// Why this tool cannot run right now ("Disabled", "Not Installed", ...),
/// or null when it is available. Evaluated live so user toggles and
/// integrations installed mid-session apply without a restart.
/// </summary>
public string UnavailableReason
{
get
{
if ( ToolRegistry.DisabledResolver?.Invoke( this ) ?? Meta.DisabledByDefault )
return "Disabled";
return Meta.Requires is null ? null : ToolRegistry.RequirementResolver?.Invoke( Meta.Requires );
}
}
public bool IsAvailable => UnavailableReason is null;
internal RegisteredTool( McpToolAttribute meta, MethodInfo method )
{
Meta = meta;
Method = method;
Descriptor = new McpToolDescriptor( meta.Name, BuildDescription( meta ), SchemaGenerator.ForMethod( method ) );
}
static string BuildDescription( McpToolAttribute meta ) =>
meta.Writes ? $"{meta.Description} (modifies project state)" : meta.Description;
/// <summary>
/// Binds JSON arguments to the method's parameters by name and invokes it.
/// Throws ToolArgumentException on missing/unbindable arguments.
/// </summary>
public object Invoke( JsonElement? args )
{
var parameters = Method.GetParameters();
var bound = new object[parameters.Length];
for ( var i = 0; i < parameters.Length; i++ )
{
var p = parameters[i];
// JsonElement params accept explicit null (e.g. to clear a reference
// property); for typed params null falls through to the default
if ( args is { ValueKind: JsonValueKind.Object } a && a.TryGetProperty( p.Name, out var value )
&& (value.ValueKind != JsonValueKind.Null || p.ParameterType == typeof( JsonElement )) )
{
try
{
bound[i] = p.ParameterType == typeof( JsonElement )
? value.Clone()
: value.Deserialize( p.ParameterType, ToolRegistry.BindOptions );
}
catch ( Exception e ) when ( e is JsonException or NotSupportedException )
{
throw new ToolArgumentException(
$"Argument '{p.Name}' could not be read as {p.ParameterType.Name}: {e.Message}", e );
}
}
else if ( p.HasDefaultValue )
{
bound[i] = p.DefaultValue;
}
else
{
throw new ToolArgumentException( $"Missing required argument '{p.Name}'" );
}
}
try
{
return Method.Invoke( null, bound );
}
catch ( TargetInvocationException e ) when ( e.InnerException is not null )
{
throw e.InnerException;
}
}
}
/// <summary>
/// Discovers [McpTool] static methods and serves them to the MCP server.
/// </summary>
public sealed class ToolRegistry
{
/// <summary>
/// Maps a tool's Requires key to an unavailability reason (short, e.g.
/// "Not Installed") or null when the requirement is satisfied. Null
/// resolver = everything available.
/// </summary>
public static Func<string, string> RequirementResolver { get; set; }
/// <summary>
/// Whether the user has disabled this tool. Null resolver = only
/// DisabledByDefault applies.
/// </summary>
public static Func<RegisteredTool, bool> DisabledResolver { get; set; }
internal static readonly JsonSerializerOptions BindOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};
static readonly JsonSerializerOptions ResultOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
readonly List<RegisteredTool> _tools = new();
readonly Dictionary<string, RegisteredTool> _byName = new( StringComparer.Ordinal );
public IReadOnlyList<RegisteredTool> Tools => _tools;
public void AddAssembly( Assembly assembly )
{
var methods = assembly.GetTypes()
.Where( t => t.IsClass )
.SelectMany( t => t.GetMethods( BindingFlags.Public | BindingFlags.Static ) )
.Select( m => (Method: m, Meta: m.GetCustomAttribute<McpToolAttribute>()) )
.Where( x => x.Meta is not null )
.OrderBy( x => x.Meta.Name, StringComparer.Ordinal );
foreach ( var (method, meta) in methods )
{
if ( _byName.ContainsKey( meta.Name ) )
continue;
var tool = new RegisteredTool( meta, method );
_tools.Add( tool );
_byName[meta.Name] = tool;
}
}
public RegisteredTool Find( string name ) => _byName.GetValueOrDefault( name );
/// <summary>
/// Registers an arbitrary public static method (from another library) as a
/// tool. Returns null when the name is already taken.
/// </summary>
public RegisteredTool AddImported( string name, string description, ToolCategory category, MethodInfo method )
{
if ( _byName.ContainsKey( name ) )
return null;
var meta = new McpToolAttribute( name, description, category ) { Writes = true };
var tool = new RegisteredTool( meta, method );
_tools.Add( tool );
_byName[name] = tool;
return tool;
}
public void Remove( string name )
{
if ( _byName.Remove( name, out var tool ) )
_tools.Remove( tool );
}
/// <summary>
/// Converts a tool's return value to the text sent back to the client.
/// </summary>
public static string FormatResult( object result ) => result switch
{
null => """{ "ok": true }""",
string s => s,
_ => JsonSerializer.Serialize( result, ResultOptions )
};
}
using System;
using Editor;
using Sandbox;
using SboxMcp.Registry;
using static SboxMcp.Tools.ToolHelpers;
namespace SboxMcp.Tools;
public static class PrefabTools
{
[McpTool( "prefab_instantiate", "Instantiates a prefab into the active scene.", ToolCategory.Prefab, Writes = true )]
public static object Instantiate(
[Desc( "Prefab asset path, e.g. 'prefabs/door.prefab'" )] string prefabPath,
[Desc( "World position [x, y, z]" )] float[] position = null )
{
var session = RequireSession();
var prefabFile = ResourceLibrary.Get<PrefabFile>( prefabPath )
?? throw new InvalidOperationException( $"No prefab at '{prefabPath}' - use asset_search with assetType 'prefab'" );
var prefabScene = SceneUtility.GetPrefabScene( prefabFile )
?? throw new InvalidOperationException( $"Prefab '{prefabPath}' could not be loaded" );
using var undo = session.UndoScope( $"MCP: instantiate {prefabPath}" ).WithGameObjectCreations().Push();
var transform = position is null
? global::Transform.Zero
: new Transform( ToVector3( position, "position" ) );
var instance = prefabScene.Clone( transform );
return Describe( instance );
}
[McpTool( "prefab_create_from_gameobject", "Turns a GameObject (and its children) into a reusable .prefab asset; the original becomes an instance of it.", ToolCategory.Prefab, Writes = true )]
public static object CreateFromGameObject(
[Desc( "GameObject id or unique name" )] string gameObject,
[Desc( "Output path ending in .prefab, e.g. 'prefabs/door.prefab'" )] string prefabPath )
{
if ( !prefabPath.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) )
throw new ArgumentException( "prefabPath must end in .prefab" );
var session = RequireSession();
var go = FindGameObject( gameObject );
var absolute = AssetTools.ResolveNewAssetPath( prefabPath );
if ( System.IO.File.Exists( absolute ) )
throw new InvalidOperationException( $"'{prefabPath}' already exists" );
System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( absolute ) );
using var undo = session.UndoScope( $"MCP: create prefab {prefabPath}" )
.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();
EditorUtility.Prefabs.ConvertGameObjectToPrefab( go, absolute );
return new { created = prefabPath, instanceId = go.Id };
}
[McpTool( "prefab_break_instance", "Unlinks a prefab instance so it becomes plain GameObjects.", ToolCategory.Prefab, Writes = true )]
public static object BreakInstance( [Desc( "GameObject id or unique name of the prefab instance root" )] string gameObject )
{
var session = RequireSession();
var go = FindGameObject( gameObject );
if ( !go.IsPrefabInstance )
throw new InvalidOperationException( $"'{go.Name}' is not a prefab instance" );
using var undo = session.UndoScope( "MCP: break prefab instance" )
.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();
go.BreakFromPrefab();
return Describe( go );
}
[McpTool( "prefab_update_from_prefab", "Re-syncs a prefab instance from its source prefab file.", ToolCategory.Prefab, Writes = true )]
public static object UpdateFromPrefab( [Desc( "GameObject id or unique name of the prefab instance root" )] string gameObject )
{
var session = RequireSession();
var go = FindGameObject( gameObject );
if ( !go.IsPrefabInstance )
throw new InvalidOperationException( $"'{go.Name}' is not a prefab instance" );
using var undo = session.UndoScope( "MCP: update from prefab" )
.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();
go.UpdateFromPrefab();
return Describe( go );
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Editor;
using Sandbox;
using SboxMcp.Integration;
namespace SboxMcp.UI;
/// <summary>
/// Pick public static methods from installed libraries (and other loaded
/// code) to expose as MCP tools. Searchable; libraries are listed separately
/// from everything else. Choices apply immediately and persist.
/// </summary>
public class ImportToolsDialog : Dialog
{
readonly LineEdit _search;
readonly ScrollArea _scroll;
public ImportToolsDialog( Widget parent ) : base( parent )
{
Window.WindowTitle = "Import Tools From Library";
Window.SetWindowIcon( "library_add" );
Window.SetModal( true, true );
Window.MinimumWidth = 560;
Window.MinimumHeight = 480;
Layout = Layout.Column();
Layout.Margin = 16;
Layout.Spacing = 8;
var hint = Layout.Add( new Label(
"Expose public static methods from installed libraries as MCP tools. "
+ "Imported tools persist, re-bind every session, and are write-gated by approvals.", this ) );
hint.SetStyles( $"color: {Theme.TextLight.Hex}; font-size: 11px;" );
hint.WordWrap = true;
_search = Layout.Add( new LineEdit( this ) { PlaceholderText = "Search methods, types or libraries..." } );
_search.TextEdited += _ => Rebuild();
_scroll = new ScrollArea( this );
_scroll.Canvas = new Widget( _scroll );
_scroll.Canvas.Layout = Layout.Column();
_scroll.Canvas.Layout.Spacing = 2;
_scroll.Canvas.Layout.Margin = 4;
_scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
_scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;
Layout.Add( _scroll, 1 );
var buttons = Layout.AddRow();
buttons.AddStretchCell();
var done = buttons.Add( new Button.Primary( "Done" ) { Icon = "check" } );
done.Clicked = Close; // Dialog.Close closes the host window (Destroy leaves it black)
Rebuild();
}
void Rebuild()
{
var canvas = _scroll.Canvas;
canvas.Layout.Clear( true );
var query = _search.Text;
var candidates = ToolImporter.CandidateAssemblies().ToList();
AddSection( canvas, "Libraries", "extension",
candidates.Where( ToolImporter.IsLibraryAssembly ).ToList(), query );
AddSection( canvas, "Project & Other", "folder",
candidates.Where( a => !ToolImporter.IsLibraryAssembly( a ) ).ToList(), query );
canvas.Layout.AddStretchCell();
}
void AddSection( Widget canvas, string title, string icon, List<Assembly> assemblies, string query )
{
var header = canvas.Layout.Add( new Label( title, canvas ) );
header.SetStyles( $"color: {Theme.Blue.Hex}; font-size: 12px; font-weight: 700; margin-top: 8px;" );
var any = false;
foreach ( var assembly in assemblies )
{
var methods = ToolImporter.CandidateMethods( assembly )
.Where( m => Matches( assembly, m, query ) )
.Take( 60 )
.ToList();
if ( methods.Count == 0 )
continue;
any = true;
var name = canvas.Layout.Add( new Label( ToolImporter.FriendlyName( assembly ), canvas ) );
name.SetStyles( $"color: {Theme.Text.Hex}; font-size: 11px; font-weight: 600; margin-top: 4px; margin-left: 6px;" );
foreach ( var method in methods )
{
var parameters = string.Join( ", ", method.GetParameters().Select( p => p.Name ) );
var check = canvas.Layout.Add( new Checkbox( $"{method.DeclaringType?.Name}.{method.Name}({parameters})", canvas )
{
Value = ToolImporter.IsImported( method )
} );
check.ToolTip = method.DeclaringType?.FullName;
var captured = method;
check.Clicked = () =>
{
if ( check.Value )
ToolImporter.Import( captured );
else
ToolImporter.Unimport( captured );
};
}
}
if ( !any )
{
var empty = canvas.Layout.Add( new Label(
string.IsNullOrWhiteSpace( query ) ? "Nothing importable found." : "No matches.", canvas ) );
empty.SetStyles( $"color: {Theme.TextLight.Hex}; font-size: 11px; margin-left: 6px;" );
}
}
static bool Matches( Assembly assembly, MethodInfo method, string query )
{
if ( string.IsNullOrWhiteSpace( query ) )
return true;
return method.Name.Contains( query, StringComparison.OrdinalIgnoreCase )
|| (method.DeclaringType?.Name.Contains( query, StringComparison.OrdinalIgnoreCase ) ?? false)
|| ToolImporter.FriendlyName( assembly ).Contains( query, StringComparison.OrdinalIgnoreCase );
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
using SboxMcp.Integration;
using SboxMcp.Registry;
namespace SboxMcp.UI;
/// <summary>
/// Searchable, category-filterable browser of every tool the server exposes.
/// Doubles as documentation.
/// </summary>
public class ToolsPage : Widget
{
readonly LineEdit _search;
readonly List<CategoryChip> _chips = new();
readonly ScrollArea _scroll;
int _builtSignature = -1;
public ToolsPage( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 12;
Layout.Spacing = 8;
var searchRow = Layout.AddRow();
searchRow.Spacing = 6;
_search = searchRow.Add( new LineEdit( this ) { PlaceholderText = "Search tools..." }, 1 );
_search.TextEdited += _ => Rebuild();
var import = searchRow.Add( new Button( "Import Tools", "library_add" ) );
import.ToolTip = "Expose public static methods from other installed libraries as MCP tools";
import.Clicked = () => new ImportToolsDialog( this ).Show();
// FlowRow wraps the chips to new lines on narrow docks instead of
// letting them overlap
var chipFlow = Layout.Add( new FlowRow( this ) );
foreach ( var category in Enum.GetValues<ToolCategory>() )
{
var chip = new CategoryChip( category, chipFlow, clickable: true );
chip.OnToggled = Rebuild;
_chips.Add( chip );
chipFlow.AddItem( chip );
}
_scroll = new ScrollArea( this );
_scroll.Canvas = new Widget( _scroll );
_scroll.Canvas.Layout = Layout.Column();
_scroll.Canvas.Layout.Spacing = 2;
_scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
_scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;
Layout.Add( _scroll, 1 );
Rebuild();
}
/// <summary>
/// The dock restores before McpHost initializes, so the registry is empty
/// at construction time - poll until tools appear.
/// </summary>
public void Tick()
{
var sig = Signature();
if ( sig == _builtSignature )
return;
Rebuild();
}
static int Signature()
{
var tools = McpHost.Registry?.Tools;
return tools is null ? 0 : tools.Count * 1000 + tools.Count( t => t.IsAvailable );
}
void Rebuild()
{
_builtSignature = Signature();
var canvas = _scroll.Canvas;
canvas.Layout.Clear( true );
var query = _search.Text;
var enabled = _chips.Where( c => c.Toggled ).Select( c => c.Category ).ToHashSet();
var tools = (McpHost.Registry?.Tools ?? (IReadOnlyList<RegisteredTool>)Array.Empty<RegisteredTool>())
.Where( t => enabled.Contains( t.Meta.Category ) )
.Where( t => string.IsNullOrWhiteSpace( query )
|| t.Meta.Name.Contains( query, StringComparison.OrdinalIgnoreCase )
|| t.Meta.Description.Contains( query, StringComparison.OrdinalIgnoreCase ) )
.ToList();
var count = canvas.Layout.Add( new Label( $"{tools.Count} tools", canvas ) );
count.SetStyles( $"color: {Palette.TextDim.Hex}; font-size: 10px;" );
foreach ( var tool in tools )
canvas.Layout.Add( new ToolRow( tool, canvas ) );
canvas.Layout.AddStretchCell();
}
}
/// <summary>
/// One tool entry: name (mono), write badge, wrapped description.
/// </summary>
public class ToolRow : Widget
{
const float ToggleWidth = 40;
readonly RegisteredTool _tool;
public ToolRow( RegisteredTool tool, Widget parent ) : base( parent )
{
_tool = tool;
FixedHeight = 40;
ToolTip = tool.Meta.Description + "\n\nClick the toggle to enable/disable this tool.";
}
bool UserDisabled => McpSettings.GetToolDisabledOverride( _tool.Meta.Name ) ?? _tool.Meta.DisabledByDefault;
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
// the toggle lives in the right strip of the row
if ( e.LocalPosition.x < LocalRect.Right - ToggleWidth )
return;
McpSettings.SetToolDisabled( _tool.Meta.Name, !UserDisabled );
Update();
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.ClearPen();
var unavailable = _tool.UnavailableReason;
var disabled = unavailable is not null;
var accent = Palette.For( _tool.Meta.Category );
if ( disabled )
accent = accent.WithAlpha( 0.35f );
if ( Paint.HasMouseOver && !disabled )
{
Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
Paint.DrawRect( LocalRect, 5 );
}
// category color tick
Paint.SetBrush( accent );
Paint.DrawRect( new Rect( LocalRect.Left + 2, LocalRect.Top + 8, 3, LocalRect.Height - 16 ), 1.5f );
// name
Paint.SetPen( disabled ? Palette.TextDim.WithAlpha( 0.6f ) : Palette.TextBright );
Paint.SetFont( "Consolas", 8, 600 );
var nameWidth = Paint.MeasureText( _tool.Meta.Name ).x;
Paint.DrawText( new Rect( LocalRect.Left + 14, LocalRect.Top + 4, nameWidth + 4, 14 ), _tool.Meta.Name, TextFlag.LeftCenter );
var badgeLeft = LocalRect.Left + 20 + nameWidth;
// writes badge
if ( _tool.Meta.Writes && !disabled )
{
var badge = new Rect( badgeLeft, LocalRect.Top + 5, 44, 13 );
Paint.SetBrush( Palette.Error.WithAlpha( 0.18f ) );
Paint.DrawRect( badge, 6 );
Paint.SetPen( Palette.Error );
Paint.SetDefaultFont( 6, 700 );
Paint.DrawText( badge, "WRITES", TextFlag.Center );
}
// unavailable badge, e.g. "Not Installed"
if ( disabled )
{
Paint.SetDefaultFont( 6, 700 );
var badgeWidth = Paint.MeasureText( unavailable ).x + 12;
var badge = new Rect( badgeLeft, LocalRect.Top + 5, badgeWidth, 13 );
Paint.SetBrush( Palette.TextDim.WithAlpha( 0.15f ) );
Paint.DrawRect( badge, 6 );
Paint.SetPen( Palette.TextDim );
Paint.DrawText( badge, unavailable, TextFlag.Center );
}
// description
Paint.SetPen( disabled ? Palette.TextDim.WithAlpha( 0.5f ) : Palette.TextDim );
Paint.SetDefaultFont( 7 );
Paint.DrawText( new Rect( LocalRect.Left + 14, LocalRect.Top + 20, LocalRect.Width - ToggleWidth - 20, 14 ),
_tool.Meta.Description, TextFlag.LeftCenter | TextFlag.SingleLine );
// enable/disable toggle (persisted per tool)
var off = UserDisabled;
Paint.SetPen( off ? Palette.TextDim : Theme.Green );
Paint.DrawIcon( new Rect( LocalRect.Right - ToggleWidth, LocalRect.Top, ToggleWidth - 8, LocalRect.Height ),
off ? "toggle_off" : "toggle_on", 22, TextFlag.Center );
}
}
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Editor;
using Sandbox;
namespace HammerTextureBrowser;
[Dock( "Editor", "OG Texture Browser", "texture" )]
public sealed class HammerTextureBrowserDock : Widget
{
private static readonly Regex QuotedMaterialProperty = new( "\"(?<key>[^\"]+)\"\\s+\"(?<value>[^\"]+)\"", RegexOptions.Compiled | RegexOptions.CultureInvariant );
private static readonly string[] PrimaryTextureKeys =
[
"TextureColor",
"TextureBaseColor",
"TextureAlbedo",
"AlbedoTexture",
"BaseTexture",
"BaseColorTexture",
"g_tColor",
"g_tBaseColor",
"g_tAlbedo"
];
private readonly HammerTextureList TextureList;
private readonly ComboBox SizeCombo;
private readonly LineEdit FilterEdit;
private readonly ComboBox KeywordsCombo;
private readonly Checkbox OnlyUsedTextures;
private readonly Label SelectedTextureLabel;
private readonly Label TextureSizeLabel;
private readonly Label CountLabel;
private readonly Button MarkButton;
private readonly Button ReplaceButton;
private readonly Button ReloadButton;
private readonly Button OpenSourceButton;
private List<Asset> AllMaterials = new();
private Asset SelectedMaterial;
private string KeywordFilter = string.Empty;
private int PreviewSize = 128;
public HammerTextureBrowserDock( Widget parent ) : base( parent )
{
MinimumSize = new( 420, 320 );
WindowTitle = "OG Texture Browser";
SetWindowIcon( "texture" );
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
TextureList = Layout.Add( new HammerTextureList( this ), 1 );
TextureList.OnTextureSelected = SelectMaterial;
TextureList.OnTextureActivated = SelectMaterial;
TextureList.OnOpenInEditor = OpenMaterialSource;
TextureList.OnOpenInAssetBrowser = OpenInAssetBrowser;
var bottom = Layout.Add( new Widget( this ) );
bottom.Layout = Layout.Column();
bottom.Layout.Margin = 4;
bottom.Layout.Spacing = 3;
bottom.FixedHeight = Theme.RowHeight * 2 + 12;
bottom.OnPaintOverride = () =>
{
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( bottom.LocalRect );
return false;
};
var topRow = bottom.Layout.AddRow();
topRow.Spacing = 5;
var sizeBlock = topRow.Add( new Widget( this ) );
sizeBlock.FixedWidth = 138;
sizeBlock.MinimumWidth = 138;
sizeBlock.MaximumWidth = 138;
sizeBlock.Layout = Layout.Row();
sizeBlock.Layout.Margin = 0;
sizeBlock.Layout.Spacing = 5;
AddSmallLabel( sizeBlock.Layout, "Size:" );
SizeCombo = sizeBlock.Layout.Add( new ComboBox( sizeBlock ) );
SizeCombo.FixedWidth = 88;
SizeCombo.MinimumWidth = 88;
SizeCombo.MaximumWidth = 88;
SizeCombo.AddItem( "32x32", null, () => SetPreviewSize( 32 ) );
SizeCombo.AddItem( "64x64", null, () => SetPreviewSize( 64 ) );
SizeCombo.AddItem( "128x128", null, () => SetPreviewSize( 128 ) );
SizeCombo.AddItem( "256x256", null, () => SetPreviewSize( 256 ) );
SizeCombo.AddItem( "1:1", null, () => SetPreviewSize( 128 ) );
SizeCombo.CurrentIndex = 2;
StyleBottomInput( SizeCombo, 24 );
AddSmallLabel( topRow, "Filter:", 58 );
FilterEdit = topRow.Add( new LineEdit( this ) );
FilterEdit.FixedWidth = 176;
FilterEdit.MinimumWidth = 176;
FilterEdit.MaximumWidth = 176;
FilterEdit.PlaceholderText = "texture name";
FilterEdit.TextChanged += _ => RefreshList();
StyleBottomInput( FilterEdit );
SelectedTextureLabel = topRow.Add( new Label( this ) );
SelectedTextureLabel.MinimumWidth = 180;
SelectedTextureLabel.Alignment = TextFlag.LeftCenter;
SelectedTextureLabel.Text = "";
OpenSourceButton = topRow.Add( new Button( "Open Source" ) );
OpenSourceButton.FixedWidth = 96;
OpenSourceButton.Clicked = OpenSelectedSource;
var bottomRow = bottom.Layout.AddRow();
bottomRow.Spacing = 5;
OnlyUsedTextures = bottomRow.Add( new Checkbox( "Only used textures" ) );
OnlyUsedTextures.FixedWidth = 138;
OnlyUsedTextures.StateChanged += _ => RefreshList();
AddSmallLabel( bottomRow, "Keywords:", 58 );
KeywordsCombo = bottomRow.Add( new ComboBox( this ) );
KeywordsCombo.FixedWidth = 176;
KeywordsCombo.MinimumWidth = 176;
KeywordsCombo.MaximumWidth = 176;
KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );
StyleBottomInput( KeywordsCombo, 24 );
MarkButton = bottomRow.Add( new Button( "Mark" ) );
MarkButton.FixedWidth = 76;
MarkButton.Clicked = MarkSelectedMaterial;
ReplaceButton = bottomRow.Add( new Button( "Replace" ) );
ReplaceButton.FixedWidth = 76;
ReplaceButton.Clicked = ReplaceSelectedMaterial;
ReloadButton = bottomRow.Add( new Button( "Reload" ) );
ReloadButton.FixedWidth = 76;
ReloadButton.Clicked = Reload;
TextureSizeLabel = bottomRow.Add( new Label( this ) );
TextureSizeLabel.MinimumWidth = 0;
TextureSizeLabel.MaximumWidth = 68;
TextureSizeLabel.Alignment = TextFlag.RightCenter;
bottomRow.AddStretchCell( 1 );
CountLabel = bottomRow.Add( new Label( this ) );
CountLabel.MinimumWidth = 0;
CountLabel.MaximumWidth = 96;
CountLabel.Alignment = TextFlag.RightCenter;
ReloadMaterials();
SetPreviewSize( PreviewSize );
RefreshList();
UpdateSelectedMaterialUi();
}
private void Reload()
{
ReloadMaterials();
RefreshList();
UpdateSelectedMaterialUi();
}
private static void AddSmallLabel( Layout row, string text, int fixedWidth = 0 )
{
var label = row.Add( new Label( text ) );
label.Alignment = fixedWidth > 0 ? TextFlag.RightCenter : TextFlag.LeftCenter;
label.FixedWidth = fixedWidth > 0 ? fixedWidth : text.Length * 6 + 4;
}
private static void StyleBottomInput( Widget widget, int fixedHeight = 22 )
{
widget.FixedHeight = fixedHeight;
widget.SetStyles(
"background-color: #2d3136; " +
"border: 1px solid #555c64; " +
"border-radius: 2px; " +
"color: #f2f2f2; " +
"padding-top: 0px; " +
"padding-bottom: 0px; " +
"padding-left: 5px; " +
"padding-right: 5px;" );
}
private void ReloadMaterials()
{
var menuPath = EditorUtility.Projects.GetAll()
.FirstOrDefault( x => x.Config.Ident == "menu" )
?.GetAssetsPath()
.NormalizeFilename( false );
AllMaterials = AssetSystem.All
.Where( IsBrowsableMaterial )
.Where( x => !IsMenuAsset( x, menuPath ) )
.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
.ToList();
RebuildKeywords();
}
private static bool IsBrowsableMaterial( Asset asset )
{
if ( asset is null )
return false;
if ( asset.AssetType != AssetType.Material )
return false;
if ( asset.AbsolutePath?.Contains( ".sbox/cloud/", StringComparison.OrdinalIgnoreCase ) ?? false )
return false;
return true;
}
private static bool IsMenuAsset( Asset asset, string menuPath )
{
if ( string.IsNullOrEmpty( menuPath ) )
return false;
return asset.AbsolutePath?.NormalizeFilename( false ).StartsWith( menuPath, StringComparison.OrdinalIgnoreCase ) ?? false;
}
private void RebuildKeywords()
{
var tags = AllMaterials
.SelectMany( x => x.Tags )
.Where( x => !string.IsNullOrWhiteSpace( x ) )
.Distinct( StringComparer.OrdinalIgnoreCase )
.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
.ToList();
KeywordsCombo.Clear();
KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );
foreach ( var tag in tags )
{
var tagValue = tag;
KeywordsCombo.AddItem( tagValue, null, () => SetKeywordFilter( tagValue ) );
}
KeywordsCombo.CurrentIndex = 0;
}
private void SetKeywordFilter( string tag )
{
KeywordFilter = tag ?? string.Empty;
RefreshList();
}
private void SetPreviewSize( int size )
{
PreviewSize = size;
TextureList.SetPreviewSize( PreviewSize );
RefreshList();
}
private void RefreshList()
{
if ( TextureList is null )
return;
IEnumerable<Asset> materials = AllMaterials;
var query = FilterEdit?.Text ?? string.Empty;
if ( !string.IsNullOrWhiteSpace( query ) )
{
var parts = query.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
materials = materials.Where( x => MatchesQuery( x, parts ) );
}
if ( !string.IsNullOrWhiteSpace( KeywordFilter ) )
{
materials = materials.Where( x => x.Tags.Any( tag => tag.Equals( KeywordFilter, StringComparison.OrdinalIgnoreCase ) ) );
}
if ( OnlyUsedTextures?.Value ?? false )
{
var used = HammerMaterialSelection.GetUsedMaterialKeys();
materials = materials.Where( x => used.Contains( x.RelativePath ) || used.Contains( x.AbsolutePath ) || used.Contains( TextureDisplayName( x ) ) );
}
var entries = materials
.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
.Select( x => new HammerTextureEntry( x ) )
.ToList();
TextureList.SetItems( entries );
if ( CountLabel is not null )
{
CountLabel.Text = $"{entries.Count:n0} textures";
CountLabel.Update();
}
if ( SelectedMaterial is not null && entries.All( x => x.Asset != SelectedMaterial ) )
TextureList.UnselectAll();
}
private static bool MatchesQuery( Asset asset, IEnumerable<string> parts )
{
var name = TextureDisplayName( asset );
var type = asset.AssetType?.FriendlyName ?? string.Empty;
IEnumerable<string> tags = asset.Tags;
foreach ( var part in parts )
{
var search = part;
var negated = false;
if ( search.StartsWith( "-" ) )
{
search = search[1..];
negated = true;
}
var matched =
name.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
type.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
tags.Any( x => x.Contains( search, StringComparison.OrdinalIgnoreCase ) );
if ( !negated && !matched )
return false;
if ( negated && matched )
return false;
}
return true;
}
private void SelectMaterial( Asset material )
{
if ( material?.AssetType != AssetType.Material )
return;
SelectedMaterial = material;
EditorUtility.InspectorObject = material;
HammerMaterialSelection.SetCurrentMaterial( material );
UpdateSelectedMaterialUi();
}
private void UpdateSelectedMaterialUi()
{
var hasMaterial = SelectedMaterial is not null;
SelectedTextureLabel.Text = hasMaterial ? TextureDisplayName( SelectedMaterial ) : "";
SelectedTextureLabel.ToolTip = SelectedMaterial?.RelativePath ?? string.Empty;
TextureSizeLabel.Text = GetTextureSizeText( SelectedMaterial );
TextureSizeLabel.ToolTip = SelectedMaterial?.AbsolutePath ?? string.Empty;
MarkButton.Enabled = hasMaterial;
ReplaceButton.Enabled = hasMaterial;
OpenSourceButton.Enabled = hasMaterial;
SelectedTextureLabel.Update();
TextureSizeLabel.Update();
MarkButton.Update();
ReplaceButton.Update();
OpenSourceButton.Update();
}
private static string GetTextureSizeText( Asset asset )
{
if ( asset is null )
return "";
var texturePath = GetPrimaryMaterialTexturePath( asset );
if ( string.IsNullOrWhiteSpace( texturePath ) )
return "";
try
{
var texture = Texture.Load( texturePath );
if ( texture is null || !texture.IsValid() || texture.Width <= 0 || texture.Height <= 0 )
return "";
return $"{texture.Width}x{texture.Height}";
}
catch
{
return "";
}
}
private static string GetPrimaryMaterialTexturePath( Asset asset )
{
if ( string.IsNullOrWhiteSpace( asset?.AbsolutePath ) || !File.Exists( asset.AbsolutePath ) )
return null;
try
{
var properties = QuotedMaterialProperty.Matches( File.ReadAllText( asset.AbsolutePath ) )
.Select( x => (Key: x.Groups["key"].Value, Value: NormalizeTexturePath( x.Groups["value"].Value )) )
.Where( x => IsTexturePath( x.Value ) )
.ToList();
foreach ( var key in PrimaryTextureKeys )
{
var match = properties.FirstOrDefault( x => x.Key.Equals( key, StringComparison.OrdinalIgnoreCase ) );
if ( !string.IsNullOrWhiteSpace( match.Value ) )
return match.Value;
}
var colorMatch = properties.FirstOrDefault( x =>
(x.Key.Contains( "color", StringComparison.OrdinalIgnoreCase ) ||
x.Key.Contains( "albedo", StringComparison.OrdinalIgnoreCase ) ||
x.Key.Contains( "base", StringComparison.OrdinalIgnoreCase )) &&
!x.Key.Contains( "tint", StringComparison.OrdinalIgnoreCase ) );
if ( !string.IsNullOrWhiteSpace( colorMatch.Value ) )
return colorMatch.Value;
return properties.FirstOrDefault().Value;
}
catch
{
return null;
}
}
private static string NormalizeTexturePath( string path )
{
return path?.Trim().Replace( '\\', '/' );
}
private static bool IsTexturePath( string path )
{
var extension = Path.GetExtension( path );
return extension.Equals( ".vtex", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".tga", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".png", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".jpg", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".jpeg", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".tif", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".tiff", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".psd", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".exr", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".hdr", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".bmp", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".webp", StringComparison.OrdinalIgnoreCase );
}
private void MarkSelectedMaterial()
{
if ( SelectedMaterial is null )
return;
HammerMaterialSelection.SelectFacesUsingMaterial( SelectedMaterial );
}
private void ReplaceSelectedMaterial()
{
if ( SelectedMaterial is null )
return;
HammerMaterialSelection.AssignAssetToSelection( SelectedMaterial );
}
private void OpenSelectedSource()
{
OpenMaterialSource( SelectedMaterial );
}
private void OpenMaterialSource( Asset asset )
{
if ( asset is null )
return;
if ( asset.CanOpenInEditor )
{
asset.OpenInEditor();
return;
}
EditorUtility.OpenFileFolder( asset.AbsolutePath );
}
private void OpenInAssetBrowser( Asset asset )
{
if ( asset is null )
return;
LocalAssetBrowser.OpenTo( asset, true );
}
internal static string TextureDisplayName( Asset asset )
{
var path = asset?.RelativePath ?? asset?.Name ?? "";
path = path.NormalizeFilename( false, false );
if ( path.StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) )
path = path["materials/".Length..];
var extension = Path.GetExtension( path );
if ( !string.IsNullOrEmpty( extension ) )
path = path[..^extension.Length];
return path;
}
}
internal sealed class HammerTextureList : ListView
{
private int PreviewSize = 128;
public Action<Asset> OnTextureSelected;
public Action<Asset> OnTextureActivated;
public Action<Asset> OnOpenInEditor;
public Action<Asset> OnOpenInAssetBrowser;
public HammerTextureList( Widget parent ) : base( parent )
{
MultiSelect = false;
FocusMode = FocusMode.TabOrClick;
Margin = 2;
ItemSpacing = 2;
ItemPaint = PaintTextureItem;
ItemSelected = item => SelectTexture( item, false );
ItemActivated = item => SelectTexture( item, true );
ItemDrag = StartTextureDrag;
ItemContextMenu = OpenTextureContextMenu;
ItemScrollEnter = item => (item as HammerTextureEntry)?.OnScrollEnter();
ItemScrollExit = item => (item as HammerTextureEntry)?.OnScrollExit();
OnPaintOverride = PaintBackground;
}
public void SetPreviewSize( int previewSize )
{
PreviewSize = previewSize;
ItemSize = new Vector2( PreviewSize + 4, PreviewSize + 18 );
Update();
}
public void SetItems( IEnumerable<HammerTextureEntry> entries )
{
base.SetItems( entries.Cast<object>() );
Update();
}
private bool PaintBackground()
{
Paint.ClearPen();
Paint.SetBrush( Color.Black );
Paint.DrawRect( LocalRect );
return false;
}
private void SelectTexture( object item, bool activated )
{
if ( item is not HammerTextureEntry entry )
return;
if ( activated )
OnTextureActivated?.Invoke( entry.Asset );
else
OnTextureSelected?.Invoke( entry.Asset );
}
private bool StartTextureDrag( object item )
{
if ( item is not HammerTextureEntry entry || entry.Asset is null )
return false;
SelectItem( entry );
SelectTexture( entry, false );
var drag = new Drag( this );
drag.Data.Object = entry.Asset;
drag.Data.Text = entry.Asset.RelativePath;
drag.Data.Url = new Uri( "file:///" + entry.Asset.AbsolutePath );
foreach ( var selected in SelectedItems.OfType<HammerTextureEntry>() )
{
if ( selected == entry || selected.Asset is null )
continue;
drag.Data.Text += "\n" + selected.Asset.RelativePath;
}
drag.Execute();
return true;
}
private void OpenTextureContextMenu( object item )
{
if ( item is not HammerTextureEntry entry )
return;
SelectItem( entry );
SelectTexture( entry, false );
var menu = new ContextMenu( this );
menu.AddOption( "Open in Editor", "edit", () => OnOpenInEditor?.Invoke( entry.Asset ) )
.Enabled = entry.Asset is not null;
menu.AddOption( "Open in Asset Browser", "search", () => OnOpenInAssetBrowser?.Invoke( entry.Asset ) )
.Enabled = entry.Asset is not null;
menu.OpenAtCursor( false );
}
private void PaintTextureItem( VirtualWidget item )
{
if ( item.Object is not HammerTextureEntry entry )
return;
var rect = item.Rect.Shrink( 1 );
var active = Paint.HasPressed;
var selected = Paint.HasSelected || Paint.HasPressed;
var hover = !selected && Paint.HasMouseOver;
Paint.ClearPen();
Paint.SetBrush( Color.Black );
Paint.DrawRect( rect );
if ( selected )
{
Paint.SetPen( Theme.Primary, 2 );
Paint.ClearBrush();
Paint.DrawRect( rect.Grow( -1 ) );
}
else if ( hover )
{
Paint.SetPen( Theme.ControlBackground.Lighten( 0.35f ), 1 );
Paint.ClearBrush();
Paint.DrawRect( rect.Grow( -1 ) );
}
var previewRect = rect;
previewRect.Width = PreviewSize;
previewRect.Height = PreviewSize;
previewRect.Left += MathF.Max( 0, (rect.Width - PreviewSize) * 0.5f );
entry.DrawPreview( previewRect, active );
var nameRect = previewRect;
nameRect.Top = previewRect.Bottom;
nameRect.Height = 12;
Paint.ClearPen();
Paint.SetBrush( selected ? Theme.Primary.Lighten( active ? -0.1f : 0.05f ) : new Color( 0.0f, 0.05f, 0.85f ) );
Paint.DrawRect( nameRect );
Paint.SetDefaultFont( 6 );
Paint.SetPen( Color.White );
var label = Paint.GetElidedText( entry.DisplayName, nameRect.Width - 4, ElideMode.Middle );
Paint.DrawText( nameRect.Shrink( 2, 0 ), label, TextFlag.LeftCenter | TextFlag.SingleLine );
}
}
internal sealed class HammerTextureEntry
{
public Asset Asset { get; }
public string DisplayName { get; }
public HammerTextureEntry( Asset asset )
{
Asset = asset;
DisplayName = HammerTextureBrowserDock.TextureDisplayName( asset );
}
public void OnScrollEnter()
{
if ( Asset is null || Asset.HasCachedThumbnail )
return;
EditorEvent.Register( this );
Asset.GetAssetThumb( true );
}
public void OnScrollExit()
{
if ( Asset is null )
return;
Asset.CancelThumbBuild();
EditorEvent.Unregister( this );
}
public void DrawPreview( Rect rect, bool active )
{
Paint.ClearPen();
Paint.SetBrush( active ? Color.Black.Lighten( 0.08f ) : Color.Black );
Paint.DrawRect( rect );
var thumb = Asset?.GetAssetThumb( true ) ?? AssetType.Material?.Icon128;
if ( thumb is null )
return;
var drawRect = rect;
var scale = MathF.Min( rect.Width / thumb.Width, rect.Height / thumb.Height );
if ( scale > 0 && float.IsFinite( scale ) )
{
drawRect.Width = MathF.Min( rect.Width, thumb.Width * scale );
drawRect.Height = MathF.Min( rect.Height, thumb.Height * scale );
drawRect.Left = rect.Left + (rect.Width - drawRect.Width) * 0.5f;
drawRect.Top = rect.Top + (rect.Height - drawRect.Height) * 0.5f;
}
Paint.BilinearFiltering = true;
Paint.Draw( drawRect, thumb );
Paint.BilinearFiltering = false;
}
}
internal static class HammerMaterialSelection
{
private const BindingFlags StaticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
private static Type HammerType;
private static bool HasResolvedHammerType;
public static void SetCurrentMaterial( Asset asset ) => InvokeHammerAssetMethod( "SetCurrentMaterial", asset );
public static void SelectFacesUsingMaterial( Asset asset ) => InvokeHammerAssetMethod( "SelectFacesUsingMaterial", asset );
public static void AssignAssetToSelection( Asset asset ) => InvokeHammerAssetMethod( "AssignAssetToSelection", asset );
public static HashSet<string> GetUsedMaterialKeys()
{
var keys = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
try
{
var activeMap = GetHammerType()?.GetProperty( "ActiveMap", StaticFlags )?.GetValue( null );
if ( activeMap is null )
return keys;
var world = activeMap.GetType().GetProperty( "World", InstanceFlags )?.GetValue( activeMap );
if ( world is null )
return keys;
CollectUsedMaterialKeys( world, keys );
}
catch
{
// Hammer throws when no map is open; in that case "Only used textures" should simply show none.
return keys;
}
return keys;
}
private static void CollectUsedMaterialKeys( object node, HashSet<string> keys )
{
if ( node is null )
return;
var materialMethod = node.GetType().GetMethod( "GetFaceMaterialAssets", InstanceFlags );
if ( materialMethod is not null && materialMethod.Invoke( node, null ) is IEnumerable materials )
{
foreach ( var item in materials )
{
if ( item is not Asset asset )
continue;
keys.Add( asset.RelativePath );
keys.Add( asset.AbsolutePath );
keys.Add( HammerTextureBrowserDock.TextureDisplayName( asset ) );
}
}
var children = node.GetType().GetProperty( "Children", InstanceFlags )?.GetValue( node ) as IEnumerable;
if ( children is null )
return;
foreach ( var child in children )
{
CollectUsedMaterialKeys( child, keys );
}
}
private static void InvokeHammerAssetMethod( string methodName, Asset asset )
{
if ( asset is null )
return;
var method = GetHammerType()?.GetMethod( methodName, StaticFlags, binder: null, types: new[] { typeof( Asset ) }, modifiers: null );
if ( method is null )
return;
try
{
method.Invoke( null, new object[] { asset } );
}
catch
{
// This dock still works as a browser if Hammer is not the active editor surface.
}
}
private static Type GetHammerType()
{
if ( HasResolvedHammerType )
return HammerType;
HasResolvedHammerType = true;
foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
{
HammerType = assembly.GetType( "Editor.MapEditor.Hammer" );
if ( HammerType is not null )
break;
}
return HammerType;
}
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
// ═══════════════════════════════════════════════════════════════════════════
// debug_draw_* / debug_clear — visualize debug primitives in the scene.
//
// Ported from the Claude Bridge for Unity's debug_draw_* family. s&box has no
// bridge debug-viz; this fills the gap so a raycast hit / physics_overlap
// volume / trigger_zone bounds / NPC sight cone / patrol path can be SEEN
// (and screenshot-verified) instead of reasoned about blind.
//
// ONE component, dual render path:
// • EDIT scene → Gizmo.Draw.* inside ClaudeDebugDraw.DrawGizmos()
// • PLAY scene → Game.ActiveScene.DebugOverlay.* re-emitted each OnUpdate()
// A single NotSaved holder GameObject ("__ClaudeDebugDraw") stores the prim
// list; the draw handlers append, debug_clear destroys it.
//
// APIs reflected live on this SDK (describe_type, 2026-06-18):
// Gizmo.Draw: Line(a,b) · Arrow(from,to,len,width) · LineBBox(bbox) ·
// LineSphere(Sphere,rings) · Color/LineThickness/IgnoreDepth
// Scene.DebugOverlay (DebugOverlaySystem):
// Line(from,to,color,dur,tx,overlay) · Box(BBox,color,dur,tx,overlay) ·
// Sphere(Sphere,color,dur,tx,overlay)
//
// Must work WHILE playing → these are NOT added to _sceneMutatingCommands.
// ═══════════════════════════════════════════════════════════════════════════
public enum DebugDrawKind { Line, Ray, Box, Sphere }
public sealed class DebugDrawPrim
{
public DebugDrawKind Kind;
public Vector3 A; // line/ray start · box/sphere center
public Vector3 B; // line/ray end
public Vector3 Size; // box full extents
public float Radius; // sphere
public Color Color = Color.Yellow;
public float Thickness = 2f;
}
/// <summary>
/// Holds bridge-issued debug primitives and renders them in both the editor
/// (DrawGizmos) and play mode (DebugOverlay). One per scene, NotSaved.
/// </summary>
public sealed class ClaudeDebugDraw : Component
{
public List<DebugDrawPrim> Prims { get; set; } = new();
protected override void DrawGizmos()
{
if ( Prims == null ) return;
foreach ( var p in Prims )
{
Gizmo.Draw.Color = p.Color;
Gizmo.Draw.LineThickness = p.Thickness;
Gizmo.Draw.IgnoreDepth = true;
switch ( p.Kind )
{
case DebugDrawKind.Line: Gizmo.Draw.Line( p.A, p.B ); break;
case DebugDrawKind.Ray: Gizmo.Draw.Arrow( p.A, p.B, 8f, 3f ); break;
case DebugDrawKind.Box: Gizmo.Draw.LineBBox( new BBox( p.A - p.Size * 0.5f, p.A + p.Size * 0.5f ) ); break;
case DebugDrawKind.Sphere: Gizmo.Draw.LineSphere( new Sphere( p.A, p.Radius ), 16 ); break;
}
}
}
protected override void OnUpdate()
{
if ( !Game.IsPlaying || Prims == null ) return;
var ov = Scene?.DebugOverlay;
if ( ov == null ) return;
const float dur = 0.1f; // refreshed every frame while in the list
var tx = global::Transform.Zero; // identity → world-space coords (Transform is global-namespace, not Sandbox.*)
foreach ( var p in Prims )
{
switch ( p.Kind )
{
case DebugDrawKind.Line:
case DebugDrawKind.Ray:
ov.Line( p.A, p.B, p.Color, dur, tx, true );
break;
case DebugDrawKind.Box:
ov.Box( new BBox( p.A - p.Size * 0.5f, p.A + p.Size * 0.5f ), p.Color, dur, tx, true );
break;
case DebugDrawKind.Sphere:
ov.Sphere( new Sphere( p.A, p.Radius ), p.Color, dur, tx, true );
break;
}
}
}
}
internal static class DebugDrawHelpers
{
static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
// ponytail: one global holder per session, recreated if invalidated by a
// scene change / hotload. Debug viz is inherently global, so a single
// instance is correct — no per-call scene scan needed.
static ClaudeDebugDraw _holder;
public static Scene CurrentScene()
=> Game.IsPlaying ? Game.ActiveScene : SceneEditorSession.Active?.Scene;
public static ClaudeDebugDraw EnsureHolder()
{
var scene = CurrentScene();
if ( scene == null ) return null;
if ( _holder.IsValid() && _holder.Scene == scene ) return _holder;
var go = scene.CreateObject( true );
go.Name = "__ClaudeDebugDraw";
go.Flags = GameObjectFlags.NotSaved;
_holder = go.AddComponent<ClaudeDebugDraw>();
return _holder;
}
public static int ClearHolder()
{
int n = 0;
// cached holder — reliable for the common same-scene case
if ( _holder.IsValid() )
{
n += _holder.Prims?.Count ?? 0;
_holder.GameObject?.Destroy();
}
// plus any holders orphaned by an edit↔play scene switch (the static ref
// only tracks the most recent scene's holder)
var scene = CurrentScene();
if ( scene != null )
{
foreach ( var c in scene.GetAllComponents<ClaudeDebugDraw>().ToList() )
{
if ( c == _holder ) continue;
n += c.Prims?.Count ?? 0;
c.GameObject?.Destroy();
}
}
_holder = null;
return n;
}
public static bool TryVec( JsonElement p, string key, out Vector3 v )
{
v = Vector3.Zero;
if ( !p.TryGetProperty( key, out var e ) ) return false;
switch ( e.ValueKind )
{
case JsonValueKind.String:
var s = e.GetString().Split( ',' );
if ( s.Length < 3 ) return false;
v = new Vector3( F( s[0] ), F( s[1] ), F( s[2] ) );
return true;
case JsonValueKind.Array:
if ( e.GetArrayLength() < 3 ) return false;
v = new Vector3( (float)e[0].GetDouble(), (float)e[1].GetDouble(), (float)e[2].GetDouble() );
return true;
case JsonValueKind.Object:
v = new Vector3(
(float)e.GetProperty( "x" ).GetDouble(),
(float)e.GetProperty( "y" ).GetDouble(),
(float)e.GetProperty( "z" ).GetDouble() );
return true;
default:
return false;
}
}
public static Color Col( JsonElement p, string key, Color def )
{
if ( !p.TryGetProperty( key, out var e ) || e.ValueKind != JsonValueKind.String ) return def;
var s = e.GetString().Split( ',' );
if ( s.Length < 3 ) return def;
float a = s.Length >= 4 ? F( s[3] ) : 1f;
return new Color( F( s[0] ), F( s[1] ), F( s[2] ), a );
}
public static float Flt( JsonElement p, string key, float def )
=> p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.Number ? (float)e.GetDouble() : def;
static float F( string s ) => float.Parse( s.Trim(), Inv );
}
// ── handlers ────────────────────────────────────────────────────────────────
public class DebugDrawLineHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "from", out var a ) || !DebugDrawHelpers.TryVec( p, "to", out var b ) )
return Task.FromResult<object>( new { error = "from and to are required (\"x,y,z\")" } );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Line, A = a, B = b,
Color = DebugDrawHelpers.Col( p, "color", Color.Yellow ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "line", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_line failed: {ex.Message}" } ); }
}
}
public class DebugDrawRayHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "origin", out var o ) || !DebugDrawHelpers.TryVec( p, "direction", out var d ) )
return Task.FromResult<object>( new { error = "origin and direction are required (\"x,y,z\")" } );
float len = DebugDrawHelpers.Flt( p, "length", 64f );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Ray, A = o, B = o + d.Normal * len,
Color = DebugDrawHelpers.Col( p, "color", Color.Yellow ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "ray", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_ray failed: {ex.Message}" } ); }
}
}
public class DebugDrawBoxHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "center", out var c ) )
return Task.FromResult<object>( new { error = "center is required (\"x,y,z\")" } );
Vector3 size = DebugDrawHelpers.TryVec( p, "size", out var sz ) ? sz : new Vector3( 32f, 32f, 32f );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Box, A = c, Size = size,
Color = DebugDrawHelpers.Col( p, "color", Color.Green ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "box", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_box failed: {ex.Message}" } ); }
}
}
public class DebugDrawSphereHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "center", out var c ) )
return Task.FromResult<object>( new { error = "center is required (\"x,y,z\")" } );
float r = DebugDrawHelpers.Flt( p, "radius", 32f );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Sphere, A = c, Radius = r,
Color = DebugDrawHelpers.Col( p, "color", Color.Red ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "sphere", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_sphere failed: {ex.Message}" } ); }
}
}
public class DebugClearHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
int removed = DebugDrawHelpers.ClearHolder();
return Task.FromResult<object>( new { cleared = true, removed } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_clear failed: {ex.Message}" } ); }
}
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
// ═══════════════════════════════════════════════════════════════════════════
// NPC Brains — Feature Wave #3 (Phase 1 + simulate_npc_perception)
//
// Compiles into the SAME editor assembly as MyEditorMenu.cs, so it can use the
// shared helpers there directly: ClaudeBridge.TryResolveProjectPath /
// SanitizeIdentifier / ParseVector3, SceneToolHelpers.*, and the IBridgeHandler
// interface. These handlers run in the UNSANDBOXED editor (System.Math/MathF/IO
// are all fine here).
//
// The C# *strings these handlers generate* run in the SANDBOX (the game). That
// generated code is deliberately restricted to APIs already proven to compile in
// the sandbox by the existing create_npc_controller / create_networked_player
// generators: Component, [Property], [Sync], GetOrAddComponent<NavMeshAgent>(),
// NavMeshAgent.MoveTo(Vector3), IsProxy, TimeSince, Vector3.Dot/.Normal/
// .DistanceBetween, Scene.GetAllComponents<T>(), scene.Trace.Ray(a,b).Run(),
// MathX.Clamp. MathX preferred in generated code; System.Math/MathF also compile on the current SDK (verified 2026-06-09). Array.Clone() still blocked.
//
// Tools in this file:
// create_npc_brain (code-gen; scene-mutating)
// place_patrol_route (scene-mutating)
// assign_patrol_route (scene-mutating)
// create_npc_spawner (code-gen; scene-mutating)
// simulate_npc_perception (READ-ONLY; not scene-mutating)
//
// Register(...) lines + _sceneMutatingCommands additions are wired by the main
// agent in MyEditorMenu.cs (see this wave's summary) to avoid a merge conflict.
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Shared helpers for the NPC-brain generators. Kept internal to this file so it
/// does not collide with anything in MyEditorMenu.cs.
/// </summary>
internal static class NpcBrainHelpers
{
/// <summary>
/// Read an optional float param, falling back to <paramref name="fallback"/>.
/// Tolerates the value arriving as a JSON number OR a numeric string.
/// </summary>
public static float Float( JsonElement p, string key, float fallback )
{
if ( !p.TryGetProperty( key, out var e ) ) return fallback;
if ( e.ValueKind == JsonValueKind.Number && e.TryGetSingle( out var f ) ) return f;
if ( e.ValueKind == JsonValueKind.String && float.TryParse( e.GetString(), out var fs ) ) return fs;
return fallback;
}
public static int Int( JsonElement p, string key, int fallback )
{
if ( !p.TryGetProperty( key, out var e ) ) return fallback;
if ( e.ValueKind == JsonValueKind.Number && e.TryGetInt32( out var i ) ) return i;
if ( e.ValueKind == JsonValueKind.String && int.TryParse( e.GetString(), out var iss ) ) return iss;
return fallback;
}
public static bool Bool( JsonElement p, string key, bool fallback )
{
if ( !p.TryGetProperty( key, out var e ) ) return fallback;
if ( e.ValueKind == JsonValueKind.True ) return true;
if ( e.ValueKind == JsonValueKind.False ) return false;
if ( e.ValueKind == JsonValueKind.String && bool.TryParse( e.GetString(), out var b ) ) return b;
return fallback;
}
public static string Str( JsonElement p, string key, string fallback )
{
if ( p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.String )
{
var s = e.GetString();
if ( !string.IsNullOrWhiteSpace( s ) ) return s;
}
return fallback;
}
/// <summary>
/// Format a float as an invariant-culture C# literal with an 'f' suffix, e.g.
/// 130 -> "130f", 0.25 -> "0.25f". Invariant culture matters so a comma-decimal
/// locale on the editor machine cannot emit "0,25f" and break compilation.
/// </summary>
public static string F( float v )
{
var s = v.ToString( "0.0###", System.Globalization.CultureInfo.InvariantCulture );
return s + "f";
}
/// <summary>
/// Escape a user string for safe embedding inside a C# double-quoted verbatim
/// string ( @"" ), where the only escape needed is doubling the quote char.
/// TargetTag is also identifier-ish but tags can legitimately contain symbols,
/// so we keep it a string literal rather than sanitizing it to an identifier.
/// </summary>
public static string EscVerbatim( string raw ) => ( raw ?? "" ).Replace( "\"", "\"\"" );
/// <summary>
/// cos( fovDegrees / 2 ) computed in the EDITOR (MathF is legal here). Baked as
/// the default of the generated CosFovThreshold property so the sandbox brain
/// never needs trig. Clamped to a sane FOV range first.
/// </summary>
public static float CosHalfFov( float fovDegrees )
{
var fov = Math.Clamp( fovDegrees, 1f, 360f );
var halfRad = ( fov * 0.5f ) * ( MathF.PI / 180f );
return MathF.Cos( halfRad );
}
/// <summary>
/// Resolve the component on <paramref name="go"/> that exposes a property named
/// <paramref name="property"/>, and SET that property to <paramref name="value"/>.
/// Preferred match is a component literally named "NpcBrain"; otherwise the first
/// component whose TypeLibrary description has that property. Returns the matched
/// component (so the caller can report its name), or null if none matched.
///
/// We deliberately do the find+set inside one method so this file never has to
/// name the reflection types (TypeDescription / PropertyDescription) — the rest
/// of the addon always uses `var` for them, which means their namespace is not
/// guaranteed to be importable here. Keeping it all behind `var` mirrors the
/// proven SetPrefabRefHandler pattern exactly.
/// </summary>
public static Component SetComponentProperty( GameObject go, string property, object value )
{
Component fallbackComp = null;
// Pass 1: prefer an NpcBrain. Pass 2: any component exposing the property.
foreach ( var c in go.Components.GetAll() )
{
var td = Game.TypeLibrary.GetType( c.GetType().Name );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == property );
if ( pd == null ) continue;
if ( c.GetType().Name.Equals( "NpcBrain", StringComparison.OrdinalIgnoreCase ) )
{
pd.SetValue( c, value );
return c;
}
fallbackComp = fallbackComp ?? c;
}
if ( fallbackComp != null )
{
var td = Game.TypeLibrary.GetType( fallbackComp.GetType().Name );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == property );
pd?.SetValue( fallbackComp, value );
}
return fallbackComp;
}
/// <summary>
/// Find the "perception brain" component on <paramref name="go"/> — the component
/// simulate_npc_perception should read SightRange/FovDegrees/EyeHeight/TargetTag from.
///
/// Why not just match the type name "NpcBrain": a custom-named brain (e.g. BigfootBrain,
/// generated via create_npc_brain with name="BigfootBrain") exposes the same perception
/// [Property] surface but a different type name, so a literal name match silently falls
/// back to spec defaults. We match by CAPABILITY instead:
/// 1. a component literally named "NpcBrain" (the default), else
/// 2. a component whose TypeLibrary description exposes BOTH SightRange and FovDegrees
/// (the perception contract), else
/// 3. a component whose type name ends with "Brain".
/// Returns null if none match (caller then uses defaults / explicit overrides).
/// </summary>
public static Component FindPerceptionBrain( GameObject go )
{
if ( go == null ) return null;
Component byProps = null;
Component byName = null;
foreach ( var c in go.Components.GetAll() )
{
var typeName = c.GetType().Name;
// 1. Exact "NpcBrain" wins immediately (the generated default).
if ( typeName.Equals( "NpcBrain", StringComparison.OrdinalIgnoreCase ) )
return c;
// 2. Capability match: exposes the perception property contract.
if ( byProps == null )
{
var td = Game.TypeLibrary.GetType( typeName );
if ( td != null
&& td.Properties.Any( pp => pp.Name == "SightRange" )
&& td.Properties.Any( pp => pp.Name == "FovDegrees" ) )
{
byProps = c;
}
}
// 3. Name heuristic: "...Brain".
if ( byName == null && typeName.EndsWith( "Brain", StringComparison.OrdinalIgnoreCase ) )
byName = c;
}
return byProps ?? byName;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 1. create_npc_brain (code-gen; scene-mutating)
// Generates an NpcBrain Component: a finite-state machine (Idle/Patrol/
// Wander/Chase/Search/Flee/Ambush) driven by occlusion-aware perception
// (FOV cone + range + LOS trace + hearing) with last-known-position memory.
// ═══════════════════════════════════════════════════════════════════════════
public class CreateNpcBrainHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var name = NpcBrainHelpers.Str( p, "name", "NpcBrain" );
var directory = NpcBrainHelpers.Str( p, "directory", "Code" );
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
if ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out var fullPath, out var pathErr ) )
return Task.FromResult<object>( new { error = pathErr } );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
var className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );
// ── Preset → defaults. The generated file is identical shape; the preset
// only changes [Property] defaults (StartState, CanFlee).
var behavior = NpcBrainHelpers.Str( p, "behavior", "hunter" ).ToLowerInvariant();
string startState;
bool presetCanFlee;
switch ( behavior )
{
case "patrol": startState = "Patrol"; presetCanFlee = false; break;
case "guard": startState = "Ambush"; presetCanFlee = false; break;
case "swarm": startState = "Wander"; presetCanFlee = false; break;
case "skittish": startState = "Patrol"; presetCanFlee = true; break;
case "hunter":
default: behavior = "hunter"; startState = "Patrol"; presetCanFlee = false; break;
}
// ── Tunables (params override preset/spec defaults). ──
var moveSpeed = NpcBrainHelpers.Float( p, "moveSpeed", 130f );
var chaseSpeed = NpcBrainHelpers.Float( p, "chaseSpeed", 200f );
var sightRange = NpcBrainHelpers.Float( p, "sightRange", 1500f );
var fovDegrees = NpcBrainHelpers.Float( p, "fovDegrees", 110f );
var eyeHeight = NpcBrainHelpers.Float( p, "eyeHeight", 64f );
var hearingRadius = NpcBrainHelpers.Float( p, "hearingRadius", 600f );
var giveUpTime = NpcBrainHelpers.Float( p, "giveUpTime", 6f );
var searchRadius = NpcBrainHelpers.Float( p, "searchRadius", 400f );
var waypointStop = NpcBrainHelpers.Float( p, "waypointStopDistance", 80f );
var canFlee = NpcBrainHelpers.Bool( p, "canFlee", presetCanFlee );
var fleeHealth = NpcBrainHelpers.Float( p, "fleeHealthFrac", 0.25f );
var networked = NpcBrainHelpers.Bool( p, "networked", true );
var targetTag = NpcBrainHelpers.Str( p, "targetTag", "player" );
// Citizen locomotion animation: when on (default), the generated brain caches a
// SkinnedModelRenderer + CitizenAnimationHelper in OnStart and drives walk/run/idle
// from the NavMeshAgent each frame (so the NPC and every spawner clone animate
// instead of sliding in bind pose). Proven approach ported from BigfootBrain.cs.
var animate = NpcBrainHelpers.Bool( p, "animate", true );
var cosFov = NpcBrainHelpers.CosHalfFov( fovDegrees );
var code = BuildSource(
className, startState, networked, animate,
NpcBrainHelpers.EscVerbatim( targetTag ),
moveSpeed, chaseSpeed, sightRange, fovDegrees, cosFov, eyeHeight,
hearingRadius, giveUpTime, searchRadius, waypointStop, canFlee, fleeHealth );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
File.WriteAllText( fullPath, code );
var states = new[] { "Idle", "Patrol", "Wander", "Chase", "Search", "Flee", "Ambush" };
var props = new[]
{
"StartState","MoveSpeed","ChaseSpeed","SightRange","FovDegrees","CosFovThreshold",
"EyeHeight","HearingRadius","TargetTag","GiveUpTime","SearchRadius","WaypointStopDistance",
"PingPong","CanFlee","FleeHealthFrac","CurrentHealthFrac","Waypoints","CurrentState"
};
return Task.FromResult<object>( new
{
created = true,
path = $"{directory}/{fileName}",
className,
behavior,
networked,
animate,
statesIncluded = states,
propertyNames = props,
note = "NavMeshAgent is added automatically via GetOrAddComponent in OnStart. " +
"Requires bake_navmesh + a navmesh-walkable scene for movement. " +
"Assign a patrol route with place_patrol_route + assign_patrol_route. " +
"Verify perception in EDIT mode with simulate_npc_perception; verify chase/search by entering play mode " +
"(get_runtime_property CurrentState + timed screenshot_from). " +
( animate
? "Locomotion animation ON: caches a SkinnedModelRenderer + CitizenAnimationHelper in OnStart and drives walk/run/idle from the NavMeshAgent each frame — attach this brain to a GameObject with a Citizen (or any SkinnedModel) renderer (on it or a child) and it animates while moving instead of sliding. Spawner clones inherit it (each runs its own OnStart). Pass animate:false to disable. "
: "Locomotion animation OFF (animate:false): the NPC slides in bind pose; drive a CitizenAnimationHelper yourself if you want walk/run anims. " ) +
( networked
? "Networked: host-authoritative (if(IsProxy)return) + [Sync] CurrentState — needs a host session; a no-session solo playtest makes everything a proxy so the brain won't think (use networked:false to iterate solo)."
: "Solo/edit build: no IsProxy guard, so it ticks in a single-machine playtest." )
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_npc_brain failed: {ex.Message}" } );
}
}
/// <summary>
/// Build the NpcBrain component source. Everything here must be SANDBOX-LEGAL.
/// Movement uses only the confirmed NavMeshAgent.MoveTo(Vector3); perception
/// uses only Vector3.Dot/.Normal + scene.Trace.Ray(a,b).Run() + Scene.GetAllComponents.
/// FOV uses a baked cosine threshold (no trig in the sandbox).
/// When <paramref name="animate"/> is true the generated brain also caches a
/// CitizenAnimationHelper (off a SkinnedModelRenderer) and feeds it the NavMeshAgent
/// velocity each frame — sandbox-legal locomotion ported from BigfootBrain.cs (uses
/// Sandbox.Citizen + MathX, never System.Math).
/// </summary>
private static string BuildSource(
string className, string startState, bool networked, bool animate, string targetTagLiteral,
float moveSpeed, float chaseSpeed, float sightRange, float fovDegrees, float cosFov,
float eyeHeight, float hearingRadius, float giveUpTime, float searchRadius,
float waypointStop, bool canFlee, float fleeHealth )
{
string F( float v ) => NpcBrainHelpers.F( v );
// Host-authority guard line (networked) vs none (solo). The [Sync] on
// CurrentState lets proxies read the host's state for client-side animation.
var proxyGuard = networked ? "\t\tif ( IsProxy ) return; // host-authoritative — only the host thinks\n" : "";
var stateAttr = networked ? "[Sync] " : "";
var headerNote = networked
? "// Host-authoritative AI brain. Only the host runs the FSM; CurrentState is [Sync]'d\n// so proxy clients can animate the NPC. Needs an active network session (a no-session\n// solo playtest makes everything a proxy — generate with networked:false to iterate solo).\n"
: "// Solo / edit-scene AI brain (no networking guard). Ticks in a single-machine playtest.\n";
// ── Citizen locomotion animation (ported verbatim from the proven BigfootBrain.cs).
// Everything here is sandbox-legal: Sandbox.Citizen + GetOrAddComponent + the
// NavMeshAgent's own Velocity/WishVelocity, no System.Math. When animate:false these
// fragments are empty strings, so the generated brain is byte-for-byte the old one.
var animUsing = animate ? "using Sandbox.Citizen;\n" : "";
var animFields = animate
? "\n\t// Citizen locomotion. Drives the anim helper from the agent's velocity each frame so the\n" +
"\t// NPC walks/runs/idles instead of sliding in bind pose. Cached off the SkinnedModelRenderer\n" +
"\t// in OnStart (works for the source NPC AND its spawner clones — they each run OnStart).\n" +
"\tprivate CitizenAnimationHelper _anim;\n" +
"\tprivate SkinnedModelRenderer _renderer;\n"
: "";
// OnStart wiring. Wiring _anim.Target avoids a WithWishVelocity NRE (see SBOX_KNOWLEDGE.md).
var animOnStart = animate
? "\n\t\t// Locomotion animation. Find the SkinnedModelRenderer (this GO or a child), then\n" +
"\t\t// get-or-add a CitizenAnimationHelper and wire its Target — the helper NREs in\n" +
"\t\t// WithWishVelocity if Target is null. A Citizen .vmdl already has the locomotion\n" +
"\t\t// anim-graph, so once fed velocity it walks/runs/idles on its own.\n" +
"\t\t_renderer = GetComponent<SkinnedModelRenderer>() ?? GetComponentInChildren<SkinnedModelRenderer>();\n" +
"\t\tif ( _renderer.IsValid() )\n" +
"\t\t{\n" +
"\t\t\t_anim = GetOrAddComponent<CitizenAnimationHelper>();\n" +
"\t\t\t_anim.Target = _renderer;\n" +
"\t\t}\n"
: "";
// Per-frame drive call (placed at the end of OnUpdate) + the method body.
var animUpdateCall = animate ? "\t\tDriveAnimation();\n" : "";
var animMethod = animate
? "\n\t// ── Locomotion animation ────────────────────────────────────────────────────\n" +
"\t/// <summary>Feed the Citizen anim helper from the NavMeshAgent each frame so the NPC\n" +
"\t/// plays walk/run/idle instead of sliding in bind pose. WithVelocity drives the\n" +
"\t/// locomotion blend; WithWishVelocity drives lean/start-stop; IsGrounded keeps it out\n" +
"\t/// of the fall pose. Glance toward the chased target, else toward travel direction.</summary>\n" +
"\tprivate void DriveAnimation()\n" +
"\t{\n" +
"\t\tif ( _anim == null || !_anim.IsValid() ) return;\n" +
"\n" +
"\t\tvar velocity = _agent.Velocity;\n" +
"\t\t_anim.WithVelocity( velocity );\n" +
"\t\t_anim.WithWishVelocity( _agent.WishVelocity );\n" +
"\t\t_anim.IsGrounded = true;\n" +
"\n" +
"\t\tVector3 lookDir;\n" +
"\t\tif ( CurrentState == BrainState.Chase && _target.IsValid() )\n" +
"\t\t\tlookDir = ( _target.WorldPosition - WorldPosition ).WithZ( 0f );\n" +
"\t\telse\n" +
"\t\t\tlookDir = velocity.WithZ( 0f );\n" +
"\n" +
"\t\tif ( lookDir.Length > 1f )\n" +
"\t\t\t_anim.WithLook( lookDir.Normal, 1f, 0.6f, 0.2f );\n" +
"\t}\n"
: "";
return
$@"using Sandbox;
{animUsing}using System;
using System.Collections.Generic;
using System.Linq;
{headerNote}public sealed class {className} : Component
{{
public enum BrainState {{ Idle, Patrol, Wander, Chase, Search, Flee, Ambush }}
// ── Tunables (all [Property] so the bridge can set_property / tune later) ──
[Property] public BrainState StartState {{ get; set; }} = BrainState.{startState};
[Property] public float MoveSpeed {{ get; set; }} = {F( moveSpeed )};
[Property] public float ChaseSpeed {{ get; set; }} = {F( chaseSpeed )};
// Perception
[Property] public float SightRange {{ get; set; }} = {F( sightRange )};
// FovDegrees is the human-readable full cone angle. The actual gate compares a
// dot product against CosFovThreshold = cos(FovDegrees/2), which is baked here so
// the sandbox needs no trig. If you change FovDegrees at runtime, also update
// CosFovThreshold (tune_npc_perception / set_property), or call SetFov(...) below.
[Property] public float FovDegrees {{ get; set; }} = {F( fovDegrees )};
[Property] public float CosFovThreshold {{ get; set; }} = {F( cosFov )};
[Property] public float EyeHeight {{ get; set; }} = {F( eyeHeight )};
[Property] public float HearingRadius {{ get; set; }} = {F( hearingRadius )};
[Property] public string TargetTag {{ get; set; }} = ""{targetTagLiteral}"";
// Memory / timing
[Property] public float GiveUpTime {{ get; set; }} = {F( giveUpTime )};
[Property] public float SearchRadius {{ get; set; }} = {F( searchRadius )};
[Property] public float WaypointStopDistance {{ get; set; }} = {F( waypointStop )};
[Property] public bool PingPong {{ get; set; }} = false;
// Flee (health source is generic: the game sets CurrentHealthFrac 0..1, or
// override ShouldFlee() in a partial/subclass — no hard coupling to any HP comp).
[Property] public bool CanFlee {{ get; set; }} = {( canFlee ? "true" : "false" )};
[Property] public float FleeHealthFrac {{ get; set; }} = {F( fleeHealth )};
[Property] public float CurrentHealthFrac {{ get; set; }} = 1f;
// Patrol route (placed + wired by assign_patrol_route, or hand-set in editor).
[Property] public List<GameObject> Waypoints {{ get; set; }} = new();
// ── Runtime state ──
{stateAttr}public BrainState CurrentState {{ get; private set; }}
private GameObject _target;
private Vector3 _lastKnownPos;
private TimeSince _timeSinceSeen;
private Vector3 _wanderTarget;
private TimeSince _timeSinceWanderPick;
private int _waypointIndex;
private int _waypointDir = 1;
private NavMeshAgent _agent;
{animFields}
protected override void OnStart()
{{
_agent = GetOrAddComponent<NavMeshAgent>();
{animOnStart} CurrentState = StartState;
_timeSinceSeen = 999f;
_lastKnownPos = WorldPosition;
_wanderTarget = WorldPosition;
}}
protected override void OnUpdate()
{{
{proxyGuard} if ( _agent == null ) return;
Perceive();
Think();
Act();
{animUpdateCall} }}
{animMethod}
/// <summary>Recompute the FOV cosine from a degree value at runtime (no trig in
/// the sandbox: cos(x) via the half-angle identity from a normalized sweep is
/// overkill, so we keep it simple — set both together).</summary>
public void SetFov( float degrees, float cosThreshold )
{{
FovDegrees = degrees;
CosFovThreshold = cosThreshold;
}}
// ── Perception ────────────────────────────────────────────────────────────
private void Perceive()
{{
var eye = WorldPosition + Vector3.Up * EyeHeight;
var best = FindVisibleTarget( eye, out var sawSomething );
if ( best.IsValid() )
{{
_target = best;
_lastKnownPos = best.WorldPosition;
_timeSinceSeen = 0f;
return;
}}
// Passive hearing: a candidate within HearingRadius is ""heard"" (sets a
// last-known position to investigate) but is NOT treated as seen — so the
// NPC investigates rather than instantly aggroing.
var heard = FindNearestCandidate( WorldPosition, HearingRadius );
if ( heard.IsValid() )
_lastKnownPos = heard.WorldPosition;
// keep _target ref while it grows stale; _timeSinceSeen advances on its own.
}}
/// <summary>Pick the nearest candidate that passes range + FOV cone + LOS.</summary>
private GameObject FindVisibleTarget( Vector3 eye, out bool any )
{{
any = false;
GameObject bestGo = null;
float bestDist = float.MaxValue;
foreach ( var cand in Candidates() )
{{
var to = cand.WorldPosition - eye;
float dist = to.Length;
if ( dist > SightRange ) continue;
if ( dist < 0.01f ) continue;
var dir = to.Normal;
// FOV cone gate (cheap): dot >= cos(half-fov). No trig needed.
if ( Vector3.Dot( WorldRotation.Forward, dir ) < CosFovThreshold ) continue;
// Occlusion trace from the eye to the candidate. IgnoreGameObjectHierarchy
// excludes the NPC's own colliders so it can't ""see"" itself. Clear when the
// ray hits the candidate directly, hits nothing, or the first hit is
// essentially at the candidate (a child collider) — a distance test that
// needs no extra API. Anything blocking earlier (a tree/wall) fails LOS.
var tr = Scene.Trace.Ray( eye, cand.WorldPosition ).IgnoreGameObjectHierarchy( GameObject ).Run();
bool clear = !tr.Hit || tr.GameObject == cand || tr.Distance >= dist - 8f;
if ( !clear ) continue;
any = true;
if ( dist < bestDist ) {{ bestDist = dist; bestGo = cand; }}
}}
return bestGo;
}}
private GameObject FindNearestCandidate( Vector3 from, float maxDist )
{{
GameObject best = null;
float bestDist = maxDist;
foreach ( var cand in Candidates() )
{{
float d = Vector3.DistanceBetween( from, cand.WorldPosition );
if ( d <= bestDist ) {{ bestDist = d; best = cand; }}
}}
return best;
}}
/// <summary>Candidate targets = GameObjects tagged TargetTag, excluding self.
/// Uses Scene.GetAllComponents to enumerate, then filters by tag.</summary>
private IEnumerable<GameObject> Candidates()
{{
foreach ( var c in Scene.GetAllComponents<Collider>() )
{{
var go = c.GameObject;
if ( go == null || go == GameObject ) continue;
if ( !go.Tags.Has( TargetTag ) ) continue;
yield return go;
}}
}}
// ── Transition table ────────────────────────────────────────────────────
private void Think()
{{
bool canSee = _target.IsValid() && _timeSinceSeen < 0.1f;
if ( CanFlee && ShouldFlee() ) {{ CurrentState = BrainState.Flee; return; }}
switch ( CurrentState )
{{
case BrainState.Idle:
case BrainState.Patrol:
case BrainState.Wander:
case BrainState.Ambush:
if ( canSee ) CurrentState = BrainState.Chase;
break;
case BrainState.Chase:
if ( !canSee && _timeSinceSeen > 0.25f ) CurrentState = BrainState.Search;
break;
case BrainState.Search:
if ( canSee ) CurrentState = BrainState.Chase;
else if ( _timeSinceSeen > GiveUpTime ) {{ _target = null; CurrentState = StartState; }}
break;
case BrainState.Flee:
if ( !ShouldFlee() ) CurrentState = StartState;
break;
}}
}}
// ── Action per state ──────────────────────────────────────────────────────
private void Act()
{{
// Apply the desired locomotion speed (chase is faster). NavMeshAgent.MaxSpeed
// is the agent's speed cap (verified in the navmesh docs).
_agent.MaxSpeed = ( CurrentState == BrainState.Chase || CurrentState == BrainState.Flee ) ? ChaseSpeed : MoveSpeed;
switch ( CurrentState )
{{
case BrainState.Idle:
case BrainState.Ambush:
// Stand still and watch (perception still runs every tick).
_agent.Stop();
break;
case BrainState.Patrol:
PatrolStep();
break;
case BrainState.Wander:
WanderStep( WorldPosition, SearchRadius );
break;
case BrainState.Chase:
if ( _target.IsValid() )
_agent.MoveTo( _target.WorldPosition );
break;
case BrainState.Search:
if ( Vector3.DistanceBetween( WorldPosition, _lastKnownPos ) > WaypointStopDistance )
_agent.MoveTo( _lastKnownPos );
else
WanderStep( _lastKnownPos, SearchRadius );
break;
case BrainState.Flee:
FleeStep();
break;
}}
}}
private void PatrolStep()
{{
if ( Waypoints == null || Waypoints.Count == 0 ) return;
_waypointIndex = (int)MathX.Clamp( _waypointIndex, 0, Waypoints.Count - 1 );
var wp = Waypoints[_waypointIndex];
if ( !wp.IsValid() ) {{ AdvanceWaypoint(); return; }}
if ( Vector3.DistanceBetween( WorldPosition, wp.WorldPosition ) <= WaypointStopDistance )
AdvanceWaypoint();
else
_agent.MoveTo( wp.WorldPosition );
}}
private void AdvanceWaypoint()
{{
if ( Waypoints == null || Waypoints.Count <= 1 ) return;
if ( PingPong )
{{
if ( _waypointIndex + _waypointDir >= Waypoints.Count || _waypointIndex + _waypointDir < 0 )
_waypointDir = -_waypointDir;
_waypointIndex += _waypointDir;
}}
else
{{
_waypointIndex = ( _waypointIndex + 1 ) % Waypoints.Count;
}}
}}
private void WanderStep( Vector3 home, float radius )
{{
bool reached = Vector3.DistanceBetween( WorldPosition, _wanderTarget ) <= WaypointStopDistance;
if ( reached || _timeSinceWanderPick > 4f )
{{
// Pick a fresh point near home. Uses only confirmed APIs (Random.Shared
// + Vector3). The agent paths toward the nearest reachable point, so an
// occasional off-mesh pick is harmless. (For strictly-on-mesh wander,
// swap to Scene.NavMesh.GetRandomPoint(home, radius) once its return type
// is confirmed via describe_type.)
var off = new Vector3(
Random.Shared.Float( -radius, radius ),
Random.Shared.Float( -radius, radius ),
0f );
_wanderTarget = home + off;
_timeSinceWanderPick = 0f;
}}
_agent.MoveTo( _wanderTarget );
}}
private void FleeStep()
{{
// Move directly away from the last-known threat position.
var away = ( WorldPosition - _lastKnownPos ).Normal;
if ( away.Length < 0.01f ) away = WorldRotation.Forward;
_agent.MoveTo( WorldPosition + away * MathX.Clamp( SearchRadius, 100f, 2000f ) );
}}
/// <summary>Generic flee predicate. Driven by CurrentHealthFrac (the game sets
/// it 0..1). Override in a subclass/partial for game-specific logic (e.g. a
/// bomb-timer panic in RUN, or a camper-HP check in Sasquatched).</summary>
public bool ShouldFlee()
{{
return CanFlee && CurrentHealthFrac <= FleeHealthFrac;
}}
// ── Noise hook (pure C#; the game calls this where a noise happens) ─────────
// Example: NpcBrain.ReportNoise(flashlightPos, 800f) when a camper clicks a
// flashlight, or a gunshot in RUN. NPCs within radius investigate (Search).
public static void ReportNoise( Scene scene, Vector3 pos, float radius )
{{
if ( scene == null ) return;
foreach ( var brain in scene.GetAllComponents<{className}>() )
brain.HearNoise( pos, radius );
}}
public void HearNoise( Vector3 pos, float radius )
{{
if ( Vector3.DistanceBetween( WorldPosition, pos ) > radius ) return;
_lastKnownPos = pos;
if ( CurrentState != BrainState.Chase )
CurrentState = BrainState.Search;
}}
}}
";
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 2. place_patrol_route (scene-mutating)
// Create N waypoint empties (tagged), grouped under a parent route object,
// optionally snapped to the ground so they sit on the navmesh.
// ═══════════════════════════════════════════════════════════════════════════
public class PlacePatrolRouteHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
if ( !p.TryGetProperty( "points", out var pts ) || pts.ValueKind != JsonValueKind.Array )
return Task.FromResult<object>( new { error = "points (Vector3[]) is required" } );
var rawPoints = new List<Vector3>();
foreach ( var e in pts.EnumerateArray() )
rawPoints.Add( ClaudeBridge.ParseVector3( e ) );
if ( rawPoints.Count < 2 )
return Task.FromResult<object>( new { error = "Provide at least 2 points for a patrol route" } );
var routeName = NpcBrainHelpers.Str( p, "name", "PatrolRoute" );
var tag = NpcBrainHelpers.Str( p, "tag", "waypoint" );
var snap = NpcBrainHelpers.Bool( p, "snapToGround", true );
try
{
// Resolve or create the route parent.
GameObject route = null;
if ( p.TryGetProperty( "parentId", out var pid ) && Guid.TryParse( pid.GetString(), out var parentGuid ) )
route = scene.Directory.FindByGuid( parentGuid );
if ( route == null )
{
route = scene.CreateObject( true );
route.Name = routeName;
// Place the parent at the centroid for a tidy hierarchy + easy framing.
var centroid = Vector3.Zero;
foreach ( var pt in rawPoints ) centroid += pt;
route.WorldPosition = centroid / rawPoints.Count;
}
var waypointIds = new List<string>( rawPoints.Count );
int i = 0;
foreach ( var pt in rawPoints )
{
var pos = pt;
if ( snap )
{
try
{
var tr = scene.Trace.Ray( pos + Vector3.Up * 2000f, pos + Vector3.Down * 20000f ).Run();
if ( tr.Hit ) pos = new Vector3( pos.x, pos.y, tr.HitPosition.z );
}
catch { /* keep the raw point on trace failure */ }
}
var wp = scene.CreateObject( true );
wp.Name = $"{routeName}_WP{i}";
wp.WorldPosition = pos;
wp.Tags.Add( tag );
wp.SetParent( route, keepWorldPosition: true );
waypointIds.Add( wp.Id.ToString() );
i++;
}
return Task.FromResult<object>( new
{
placed = true,
routeId = route.Id.ToString(),
routeName = route.Name,
waypointIds,
count = waypointIds.Count,
snappedToGround = snap,
note = "Wire these into an NpcBrain with assign_patrol_route (pass routeId or waypointIds). " +
"Validate connectivity with get_navmesh_path between consecutive waypoints (catches a point in a wall)."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"place_patrol_route failed: {ex.Message}" } );
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 3. assign_patrol_route (scene-mutating)
// Wire a placed route (or an arbitrary GUID list) into a List<GameObject>
// property (default "Waypoints") on a target NPC's component. This is the
// list-of-GameObject-refs case plain set_property can't express.
// ═══════════════════════════════════════════════════════════════════════════
public class AssignPatrolRouteHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
if ( !p.TryGetProperty( "npcId", out var npcEl ) || !Guid.TryParse( npcEl.GetString(), out var npcGuid ) )
return Task.FromResult<object>( new { error = "npcId (GameObject GUID holding the NpcBrain) is required" } );
var npc = scene.Directory.FindByGuid( npcGuid );
if ( npc == null )
return Task.FromResult<object>( new { error = $"NPC GameObject not found: {npcEl.GetString()}" } );
var property = NpcBrainHelpers.Str( p, "property", "Waypoints" );
try
{
// ── Gather the ordered waypoint GameObjects: explicit waypointIds win,
// else the children (hierarchy order) of routeId.
var waypoints = new List<GameObject>();
if ( p.TryGetProperty( "waypointIds", out var wpArr ) && wpArr.ValueKind == JsonValueKind.Array )
{
foreach ( var e in wpArr.EnumerateArray() )
if ( Guid.TryParse( e.GetString(), out var g ) )
{
var go = scene.Directory.FindByGuid( g );
if ( go != null ) waypoints.Add( go );
}
}
else if ( p.TryGetProperty( "routeId", out var routeEl ) && Guid.TryParse( routeEl.GetString(), out var routeGuid ) )
{
var route = scene.Directory.FindByGuid( routeGuid );
if ( route == null )
return Task.FromResult<object>( new { error = $"Route GameObject not found: {routeEl.GetString()}" } );
foreach ( var child in route.Children )
waypoints.Add( child );
}
else
{
return Task.FromResult<object>( new { error = "Provide waypointIds (GUID[]) or routeId (route parent GUID)" } );
}
if ( waypoints.Count == 0 )
return Task.FromResult<object>( new { error = "No valid waypoints resolved from the given ids/route" } );
// ── Resolve the component + property and set the List<GameObject>.
// SetValue accepts a List<GameObject>; we hand it the concrete list
// (matches how the editor serializes [Property] lists of refs).
var comp = NpcBrainHelpers.SetComponentProperty( npc, property, waypoints );
if ( comp == null )
return Task.FromResult<object>( new { error = $"No component on the NPC exposes a '{property}' property (expected an NpcBrain with a List<GameObject> {property})" } );
return Task.FromResult<object>( new
{
assigned = true,
npcId = npcEl.GetString(),
component = comp.GetType().Name,
property,
count = waypoints.Count,
note = "List<GameObject> refs may read back as handles/GUIDs via get_property — trust this count, or confirm patrol in play mode."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"assign_patrol_route failed: {ex.Message}" } );
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 4. create_npc_spawner (code-gen; scene-mutating)
// Generate a spawner Component that clones an NPC prefab over time / in
// escalating waves at spawn points, capped by maxAlive. Host-authoritative
// when networked (NetworkSpawn, guarded).
// ═══════════════════════════════════════════════════════════════════════════
public class CreateNpcSpawnerHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var name = NpcBrainHelpers.Str( p, "name", "NpcSpawner" );
var directory = NpcBrainHelpers.Str( p, "directory", "Code" );
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
if ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out var fullPath, out var pathErr ) )
return Task.FromResult<object>( new { error = pathErr } );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
var className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );
var mode = NpcBrainHelpers.Str( p, "mode", "waves" ).ToLowerInvariant();
if ( mode != "continuous" && mode != "waves" && mode != "burst" ) mode = "waves";
var modeEnum = mode == "continuous" ? "Continuous" : ( mode == "burst" ? "Burst" : "Waves" );
var count = NpcBrainHelpers.Int( p, "count", 5 );
var interval = NpcBrainHelpers.Float( p, "interval", 8f );
var waveCount = NpcBrainHelpers.Int( p, "waveCount", 3 );
var waveGrowth = NpcBrainHelpers.Float( p, "waveGrowth", 1f );
var radius = NpcBrainHelpers.Float( p, "radius", 200f );
var maxAlive = NpcBrainHelpers.Int( p, "maxAlive", 12 );
var networked = NpcBrainHelpers.Bool( p, "networked", true );
var code = BuildSpawnerSource( className, modeEnum, networked,
count, interval, waveCount, waveGrowth, radius, maxAlive );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
File.WriteAllText( fullPath, code );
var props = new[]
{
"NpcPrefab","SpawnPoints","Mode","Count","Interval","WaveCount",
"WaveGrowth","Radius","MaxAlive","AutoStart"
};
return Task.FromResult<object>( new
{
created = true,
path = $"{directory}/{fileName}",
className,
mode,
networked,
propertyNames = props,
note = "Set NpcPrefab via set_prefab_ref. Add spawn points by reusing place_patrol_route (a route of empties) then " +
"assign_patrol_route with property=\"SpawnPoints\", or set SpawnPoints by hand. " +
( networked
? "Networked spawns use NetworkSpawn() and are host-only (guarded) — needs a host session."
: "Solo build: plain Clone() (no NetworkSpawn)." ) +
" Verify by watching GameObject count over time in play mode (get_scene_hierarchy deltas)."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_npc_spawner failed: {ex.Message}" } );
}
}
private static string BuildSpawnerSource(
string className, string modeEnum, bool networked,
int count, float interval, int waveCount, float waveGrowth, float radius, int maxAlive )
{
string F( float v ) => NpcBrainHelpers.F( v );
var proxyGuard = networked ? "\t\tif ( IsProxy ) return; // host spawns authoritatively\n" : "";
var headerNote = networked
? "// Host-authoritative spawner. Only the host spawns (NetworkSpawn so clients see the\n// NPCs). Needs an active network session.\n"
: "// Solo / edit-scene spawner (plain Clone, no networking).\n";
// Spawn idiom: clone the prefab, place it, and (networked) NetworkSpawn in a
// try/catch — the verified solo-safe idiom (NetworkSpawn throws with no session).
var spawnBody = networked
?
@" var go = NpcPrefab.Clone( pos );
try { go.NetworkSpawn(); } catch { /* no session — fall back to a local object */ }
_alive.Add( go );"
:
@" var go = NpcPrefab.Clone( pos );
_alive.Add( go );";
return
$@"using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
{headerNote}public sealed class {className} : Component
{{
public enum SpawnMode {{ Continuous, Waves, Burst }}
[Property] public GameObject NpcPrefab {{ get; set; }}
[Property] public List<GameObject> SpawnPoints {{ get; set; }} = new();
[Property] public SpawnMode Mode {{ get; set; }} = SpawnMode.{modeEnum};
[Property] public int Count {{ get; set; }} = {count}; // per-wave (Waves) or total (Burst/Continuous batch)
[Property] public float Interval {{ get; set; }} = {F( interval )}; // seconds between spawns (Continuous) or waves (Waves)
[Property] public int WaveCount {{ get; set; }} = {waveCount};
[Property] public float WaveGrowth {{ get; set; }} = {F( waveGrowth )}; // multiply Count each wave (>1 = escalating)
[Property] public float Radius {{ get; set; }} = {F( radius )}; // random scatter around a spawn point
[Property] public int MaxAlive {{ get; set; }} = {maxAlive}; // concurrency cap
[Property] public bool AutoStart {{ get; set; }} = true;
private readonly List<GameObject> _alive = new();
private TimeSince _timeSinceSpawn;
private int _wavesDone;
private float _currentWaveCount;
private bool _started;
protected override void OnStart()
{{
_currentWaveCount = Count;
_timeSinceSpawn = Interval; // fire promptly on the first eligible tick
if ( AutoStart ) _started = true;
}}
protected override void OnUpdate()
{{
{proxyGuard} if ( !_started || NpcPrefab == null ) return;
// Drop dead/destroyed NPCs from the live list so MaxAlive is accurate.
_alive.RemoveAll( g => !g.IsValid() );
switch ( Mode )
{{
case SpawnMode.Burst:
SpawnBatch( (int)_currentWaveCount );
_started = false; // one-shot
break;
case SpawnMode.Continuous:
if ( _timeSinceSpawn >= Interval )
{{
_timeSinceSpawn = 0f;
TrySpawnOne();
}}
break;
case SpawnMode.Waves:
if ( _wavesDone >= WaveCount ) {{ _started = false; break; }}
if ( _timeSinceSpawn >= Interval )
{{
_timeSinceSpawn = 0f;
SpawnBatch( (int)_currentWaveCount );
_wavesDone++;
_currentWaveCount = MathX.Clamp( _currentWaveCount * WaveGrowth, 1f, 9999f );
}}
break;
}}
}}
private void SpawnBatch( int n )
{{
for ( int i = 0; i < n; i++ )
if ( !TrySpawnOne() ) break;
}}
private bool TrySpawnOne()
{{
if ( _alive.Count >= MaxAlive ) return false;
var pos = PickSpawnPos();
{spawnBody}
return true;
}}
private Vector3 PickSpawnPos()
{{
var basePos = WorldPosition;
if ( SpawnPoints != null && SpawnPoints.Count > 0 )
{{
var pick = SpawnPoints[Random.Shared.Next( 0, SpawnPoints.Count )];
if ( pick.IsValid() ) basePos = pick.WorldPosition;
}}
var off = new Vector3(
Random.Shared.Float( -Radius, Radius ),
Random.Shared.Float( -Radius, Radius ),
0f );
return basePos + off;
}}
}}
";
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 5. simulate_npc_perception (READ-ONLY — NOT scene-mutating)
// Run the EXACT LOS check an NpcBrain would, in edit mode, without play.
// FOV cone (dot vs CosFovThreshold) + range + occlusion trace. Reports the
// result AND why — the keystone edit-mode verifier for the perception layer.
// ═══════════════════════════════════════════════════════════════════════════
public class SimulateNpcPerceptionHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
if ( !p.TryGetProperty( "npcId", out var npcEl ) || !Guid.TryParse( npcEl.GetString(), out var npcGuid ) )
return Task.FromResult<object>( new { error = "npcId (GameObject GUID with an NpcBrain) is required" } );
var npc = scene.Directory.FindByGuid( npcGuid );
if ( npc == null )
return Task.FromResult<object>( new { error = $"NPC GameObject not found: {npcEl.GetString()}" } );
try
{
// ── Read perception params from the NPC's brain if present, else fall back
// to spec defaults / explicit overrides in the call. Matches the brain by
// CAPABILITY (exposes SightRange+FovDegrees) or a "...Brain" type name — NOT
// just the literal type name "NpcBrain" — so a custom-named brain
// (e.g. BigfootBrain) is read instead of silently using defaults.
var brain = NpcBrainHelpers.FindPerceptionBrain( npc );
// `var` (never name TypeDescription) — its namespace isn't guaranteed importable here.
var brainTd = brain != null ? Game.TypeLibrary.GetType( brain.GetType().Name ) : null;
float ReadBrainFloat( string name, float fallback )
{
if ( brain == null || brainTd == null ) return fallback;
var pd = brainTd.Properties.FirstOrDefault( x => x.Name == name );
if ( pd == null ) return fallback;
try
{
var v = pd.GetValue( brain );
if ( v is float f ) return f;
if ( v != null && float.TryParse( v.ToString(), out var fp ) ) return fp;
}
catch { }
return fallback;
}
string ReadBrainString( string name, string fallback )
{
if ( brain == null || brainTd == null ) return fallback;
var pd = brainTd.Properties.FirstOrDefault( x => x.Name == name );
try { return pd?.GetValue( brain )?.ToString() ?? fallback; } catch { return fallback; }
}
// Explicit overrides take precedence over brain-read values.
float sightRange = NpcBrainHelpers.Float( p, "sightRange", ReadBrainFloat( "SightRange", 1500f ) );
float fovDegrees = NpcBrainHelpers.Float( p, "fovDegrees", ReadBrainFloat( "FovDegrees", 110f ) );
float eyeHeight = NpcBrainHelpers.Float( p, "eyeHeight", ReadBrainFloat( "EyeHeight", 64f ) );
string targetTag = NpcBrainHelpers.Str( p, "targetTag", ReadBrainString( "TargetTag", "player" ) );
// Use the brain's baked CosFovThreshold if available (keeps this query in
// lockstep with the generated component); else compute it here.
float cosFov = ReadBrainFloat( "CosFovThreshold", float.NaN );
if ( float.IsNaN( cosFov ) ) cosFov = NpcBrainHelpers.CosHalfFov( fovDegrees );
// ── Resolve the target point: explicit targetId or a raw point.
GameObject targetGo = null;
Vector3 targetPos;
if ( p.TryGetProperty( "targetId", out var tEl ) && Guid.TryParse( tEl.GetString(), out var tGuid ) )
{
targetGo = scene.Directory.FindByGuid( tGuid );
if ( targetGo == null )
return Task.FromResult<object>( new { error = $"Target GameObject not found: {tEl.GetString()}" } );
targetPos = targetGo.WorldPosition;
}
else if ( p.TryGetProperty( "point", out var ptEl ) )
{
targetPos = ClaudeBridge.ParseVector3( ptEl );
}
else
{
return Task.FromResult<object>( new { error = "Provide targetId (GameObject GUID) or point (Vector3)" } );
}
var eye = npc.WorldPosition + Vector3.Up * eyeHeight;
var to = targetPos - eye;
float distance = to.Length;
// Degenerate: target is essentially at the eye.
if ( distance < 0.01f )
{
return Task.FromResult<object>( new
{
canSee = true, inRange = true, inFov = true, losBlocked = false,
distance, angleDeg = 0.0,
eye = new { eye.x, eye.y, eye.z },
note = "Target coincides with the NPC eye position."
} );
}
var dir = to.Normal;
float dot = Vector3.Dot( npc.WorldRotation.Forward, dir );
// angle (degrees) for human-readable output. MathF is fine here (editor).
float angleDeg = MathF.Acos( Math.Clamp( dot, -1f, 1f ) ) * ( 180f / MathF.PI );
bool inRange = distance <= sightRange;
bool inFov = dot >= cosFov;
// Occlusion trace from the eye toward the target. IgnoreGameObjectHierarchy
// drops the NPC's own colliders (confirmed builder), so any hit is an
// external object. It blocks LOS only if it's clearly before the target
// (hit on the target itself, or a hit at/after the target distance, is not
// a blocker). Distance test only — no GameObject.Root needed.
bool losBlocked = false;
object blockedBy = null;
var tr = scene.Trace.Ray( eye, targetPos ).IgnoreGameObjectHierarchy( npc ).Run();
if ( tr.Hit )
{
bool hitIsTarget = ( targetGo != null && tr.GameObject == targetGo )
|| tr.Distance >= distance - 8f; // a hit at/after the target point isn't a blocker
if ( !hitIsTarget )
{
losBlocked = true;
blockedBy = new { id = tr.GameObject?.Id.ToString(), name = tr.GameObject?.Name };
}
}
bool tagMatch = targetGo == null || targetGo.Tags.Has( targetTag );
bool canSee = inRange && inFov && !losBlocked && tagMatch;
return Task.FromResult<object>( new
{
canSee,
inRange,
inFov,
losBlocked,
blockedBy,
tagMatch,
distance,
angleDeg = (double)angleDeg,
fovHalfAngleDeg = (double)( fovDegrees * 0.5f ),
sightRange,
targetTag,
eye = new { eye.x, eye.y, eye.z },
brainComponent = brain?.GetType().Name,
note = brain == null
? "No perception brain found on this GameObject — used spec defaults / call overrides for the perception params."
: $"Read perception params from the '{brain.GetType().Name}' component's own SightRange/FovDegrees/EyeHeight/TargetTag (call params override). canSee mirrors what the generated brain computes."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"simulate_npc_perception failed: {ex.Message}" } );
}
}
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
using System.Text.Json.Serialization;
namespace Grains.RazorDesigner.Document;
public sealed record CheckboxPayload : Payload
{
[JsonIgnore]
public override ControlType Kind => ControlType.Checkbox;
// Checkbox label text. Overrides Payload.Content (neutral default "").
public override string Content { get; init; } = "";
public override Length CheckboxSize { get; init; } = Length.Px( 16 );
}
using Editor;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Inspector;
[CustomEditor( typeof( Edges ) )]
public sealed class EdgesControlWidget : ControlWidget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public override bool SupportsMultiEdit => true;
private readonly EdgesProxy _proxy;
private readonly SerializedObject _proxySerialized;
// Synchronous change events on both sides; without this guard SetValue would loop.
private bool _syncing;
private sealed class EdgesProxy
{
public Length Top { get; set; } = Length.Px( 0 );
public Length Right { get; set; } = Length.Px( 0 );
public Length Bottom { get; set; } = Length.Px( 0 );
public Length Left { get; set; } = Length.Px( 0 );
}
public EdgesControlWidget( SerializedProperty property ) : base( property )
{
Log.Info( $"{LogPrefix} EdgesControlWidget ctor for {property.Name}" );
Layout = Layout.Column();
Layout.Spacing = 2;
_proxy = new EdgesProxy();
_proxySerialized = EditorTypeLibrary.GetSerializedObject( _proxy );
var topRow = Layout.Add( Layout.Row() );
topRow.Spacing = 2;
AddSide( topRow, nameof( EdgesProxy.Top ), "border_top" );
AddSide( topRow, nameof( EdgesProxy.Right ), "border_right" );
var bottomRow = Layout.Add( Layout.Row() );
bottomRow.Spacing = 2;
AddSide( bottomRow, nameof( EdgesProxy.Bottom ), "border_bottom" );
AddSide( bottomRow, nameof( EdgesProxy.Left ), "border_left" );
_proxySerialized.OnPropertyChanged += OnProxyChanged;
SyncFromProperty();
}
private void AddSide( Layout row, string propName, string icon )
{
var prop = _proxySerialized.GetProperty( propName );
var lengthWidget = new LengthControlWidget( prop, icon );
row.Add( lengthWidget, 1 );
}
protected override void PaintControl()
{
// nothing
}
private void SyncFromProperty()
{
if ( _syncing ) return;
_syncing = true;
try
{
var e = SerializedProperty.GetValue<Edges>( Edges.Zero );
_proxySerialized.GetProperty( nameof( EdgesProxy.Top ) ).SetValue( e.Top );
_proxySerialized.GetProperty( nameof( EdgesProxy.Right ) ).SetValue( e.Right );
_proxySerialized.GetProperty( nameof( EdgesProxy.Bottom ) ).SetValue( e.Bottom );
_proxySerialized.GetProperty( nameof( EdgesProxy.Left ) ).SetValue( e.Left );
}
finally
{
_syncing = false;
}
}
private void OnProxyChanged( SerializedProperty property )
{
if ( _syncing ) return;
if ( ReadOnly || !SerializedProperty.IsEditable )
return;
_syncing = true;
try
{
var newValue = new Edges( _proxy.Top, _proxy.Right, _proxy.Bottom, _proxy.Left );
Log.Info( $"{LogPrefix} EdgesControlWidget OnProxyChanged {newValue}" );
PropertyStartEdit();
SerializedProperty.SetValue( newValue );
SignalValuesChanged();
PropertyFinishEdit();
}
finally
{
_syncing = false;
}
}
protected override void OnValueChanged()
{
base.OnValueChanged();
SyncFromProperty();
}
}
using System;
using System.Collections.Generic;
using Editor;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Templates;
using Sandbox;
namespace Grains.RazorDesigner.Palette;
public class PalettePanel : Widget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const string CookiePrefix = "razordesigner.palette.";
// Click-to-add target. Window decides where the new record goes (typically active selection or root).
public event Action<ControlType> TypeAddRequested;
// Click-to-add a saved template. Window decides where to insert.
public event Action<PaletteTemplate> TemplateAddRequested;
private readonly PaletteTemplateStore _templateStore = new();
private CollapsibleSection _templatesSection;
private WrapPanel _templatesWrap;
public PaletteTemplateStore TemplateStore => _templateStore;
public PalettePanel( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
MinimumWidth = 180;
VerticalSizeMode = SizeMode.CanGrow;
var byCategory = new Dictionary<ControlCategory, List<ControlType>>();
foreach ( ControlType type in Enum.GetValues( typeof( ControlType ) ) )
{
var cat = ControlDefaults.For( type ).Category;
if ( !byCategory.TryGetValue( cat, out var list ) )
{
list = new List<ControlType>();
byCategory[cat] = list;
}
list.Add( type );
}
// Templates section (top of palette). Hidden when store is empty; rebuilt on Changed.
_templatesSection = new CollapsibleSection( this, "Templates", "bookmark" );
_templatesWrap = new WrapPanel( null )
{
MinItemWidth = 92,
ItemHeight = (int)( Theme.RowHeight + 4 ),
HSpacing = 4,
VSpacing = 4,
PaddingLeft = 4,
PaddingTop = 4,
PaddingRight = 14,
PaddingBottom = 4,
};
_templatesSection.BodyLayout.Add( _templatesWrap );
var templatesCookie = $"{CookiePrefix}templates.expanded";
_templatesSection.Expanded = EditorCookie.Get<bool>( templatesCookie, true );
_templatesSection.ExpandedChanged += expanded =>
{
EditorCookie.Set( templatesCookie, expanded );
Log.Info( $"{LogPrefix} Palette Templates {(expanded ? "expanded" : "collapsed")}" );
};
Layout.Add( _templatesSection );
_templateStore.Changed += RebuildTemplatesSection;
_templateStore.Scan(); // initial fill (also fires Changed and rebuilds the section)
foreach ( ControlCategory cat in Enum.GetValues( typeof( ControlCategory ) ) )
{
if ( !byCategory.TryGetValue( cat, out var list ) ) continue;
var section = new CollapsibleSection(
this,
ControlDefaults.CategoryDisplayName( cat ),
CategoryIcon( cat ) );
var wrap = new WrapPanel( null )
{
MinItemWidth = 92,
ItemHeight = (int)( Theme.RowHeight + 4 ),
HSpacing = 4,
VSpacing = 4,
PaddingLeft = 4,
PaddingTop = 4,
PaddingRight = 14, // clear the ScrollArea's vertical scrollbar
PaddingBottom = 4,
};
section.BodyLayout.Add( wrap );
foreach ( var t in list )
new PaletteTypeButton( wrap, this, t );
var cookieKey = $"{CookiePrefix}{cat}.expanded";
section.Expanded = EditorCookie.Get<bool>( cookieKey, DefaultExpanded( cat ) );
section.ExpandedChanged += expanded =>
{
EditorCookie.Set( cookieKey, expanded );
Log.Info( $"{LogPrefix} Palette category {cat} {(expanded ? "expanded" : "collapsed")}" );
};
Layout.Add( section );
}
Layout.AddStretchCell();
Log.Info( $"{LogPrefix} PalettePanel ctor (icon grid, {byCategory.Count} categories)" );
}
internal void NotifyTypeClicked( ControlType type )
{
Log.Info( $"{LogPrefix} PalettePanel.NotifyTypeClicked: {type}" );
TypeAddRequested?.Invoke( type );
}
internal void NotifyTemplateClicked( PaletteTemplate template )
{
Log.Info( $"{LogPrefix} PalettePanel.NotifyTemplateClicked: \"{template.Name}\"" );
TemplateAddRequested?.Invoke( template );
}
internal void RequestTemplateDelete( PaletteTemplate template )
{
var dialog = new Editor.Dialog( this );
dialog.Window.WindowTitle = "Delete template";
dialog.Window.SetWindowIcon( "delete" );
dialog.Window.SetModal( true, true );
dialog.Window.MinimumWidth = 320;
dialog.Layout = Layout.Column();
dialog.Layout.Margin = 16;
dialog.Layout.Spacing = 10;
dialog.Layout.Add( new Editor.Label( dialog )
{
Text = $"Delete template \"{template.Name}\"?",
} );
var hint = new Editor.Label( dialog )
{
Text = "Already-instantiated copies in open documents are unaffected.",
};
hint.SetStyles( "color: #888; font-size: 11px;" );
dialog.Layout.Add( hint );
var buttonRow = dialog.Layout.Add( Layout.Row() );
buttonRow.Spacing = 6;
buttonRow.AddStretchCell();
var cancel = new Editor.Button( dialog ) { Text = "Cancel", MinimumWidth = 72 };
cancel.MouseLeftPress += () => dialog.Close();
buttonRow.Add( cancel );
var del = new Editor.Button( dialog ) { Text = "Delete", MinimumWidth = 72 };
del.SetStyles( "color: #e07070;" );
del.MouseLeftPress += () =>
{
Log.Info( $"{LogPrefix} Palette delete confirmed: \"{template.Name}\"" );
_templateStore.Delete( template );
dialog.Close();
};
buttonRow.Add( del );
dialog.Window.AdjustSize();
dialog.Show();
}
private void RebuildTemplatesSection()
{
var templates = _templateStore.All;
// Hide the entire section (header + body) when there are no templates.
_templatesSection.Visible = templates.Count > 0;
using ( Editor.SuspendUpdates.For( _templatesWrap ) )
{
_templatesWrap.DestroyChildren();
foreach ( var t in templates )
new PaletteTemplateButton( _templatesWrap, this, t );
}
_templatesWrap.Relayout();
_templatesWrap.UpdateGeometry();
_templatesSection.UpdateGeometry();
UpdateGeometry();
Log.Info( $"{LogPrefix} PalettePanel.RebuildTemplatesSection: {templates.Count} tile(s), section.Visible={_templatesSection.Visible}" );
}
private static bool DefaultExpanded( ControlCategory cat ) =>
cat is ControlCategory.Layout or ControlCategory.Display or ControlCategory.Input;
private static string CategoryIcon( ControlCategory cat ) => cat switch
{
ControlCategory.Layout => "view_quilt",
ControlCategory.Display => "visibility",
ControlCategory.Input => "edit",
ControlCategory.Form => "list_alt",
_ => "category",
};
private sealed class PaletteTypeButton : Widget
{
private readonly PalettePanel _owner;
private readonly ControlType _type;
// InspectorIcon comes from the contract (engine-fidelity); drag defaults from ControlDefaults.
private readonly string _icon;
public PaletteTypeButton( Widget parent, PalettePanel owner, ControlType type ) : base( parent )
{
_owner = owner;
_type = type;
_icon = ContractScanner.Table.Get( type ).InspectorIcon;
ToolTip = type.ToString();
Cursor = CursorShape.Finger;
MouseTracking = true;
IsDraggable = true;
}
protected override void OnPaint()
{
var rect = LocalRect.Shrink( 1 );
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
var tint = ControlPresentation.IconTint( _type );
var fillAlpha = Paint.HasMouseOver ? 0.35f : 0.15f;
var borderAlpha = Paint.HasMouseOver ? 0.55f : 0.25f;
Paint.SetBrush( tint.WithAlpha( fillAlpha ) );
Paint.SetPen( tint.WithAlpha( borderAlpha ) );
Paint.DrawRect( rect, 3 );
var hoverOpacity = Paint.HasMouseOver ? 1f : 0.85f;
var iconRect = new Rect( rect.Left + 4, rect.Top, 20, rect.Height );
Paint.SetPen( tint.WithAlphaMultiplied( hoverOpacity ) );
Paint.DrawIcon( iconRect, _icon, 16, TextFlag.Center );
var textRect = rect;
textRect.Left = iconRect.Right + 2;
textRect.Right -= 4;
Paint.SetPen( Theme.Text.WithAlphaMultiplied( hoverOpacity ) );
Paint.SetDefaultFont();
Paint.DrawText( textRect, _type.ToString(), TextFlag.LeftCenter );
}
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
if ( e.LeftMouseButton )
_owner.NotifyTypeClicked( _type );
}
protected override void OnDragStart()
{
base.OnDragStart();
var drag = new Drag( this );
drag.Data.Object = _type;
drag.Data.Text = $"palette:{_type}";
drag.Execute();
Log.Info( $"{LogPrefix} PaletteTypeButton.OnDragStart: {_type}" );
}
}
private sealed class PaletteTemplateButton : Widget
{
private readonly PalettePanel _owner;
private readonly PaletteTemplate _template;
public PaletteTemplateButton( Widget parent, PalettePanel owner, PaletteTemplate template ) : base( parent )
{
_owner = owner;
_template = template;
ToolTip = template.Name;
Cursor = CursorShape.Finger;
MouseTracking = true;
IsDraggable = true;
}
protected override void OnPaint()
{
var rect = LocalRect.Shrink( 1 );
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
var tint = ControlPresentation.TemplateTint;
var fillAlpha = Paint.HasMouseOver ? 0.18f : 0.08f;
var borderAlpha = Paint.HasMouseOver ? 0.55f : 0.25f;
Paint.SetBrush( tint.WithAlpha( fillAlpha ) );
Paint.SetPen( tint.WithAlpha( borderAlpha ) );
Paint.DrawRect( rect, 3 );
var hoverOpacity = Paint.HasMouseOver ? 1f : 0.85f;
var iconRect = new Rect( rect.Left + 4, rect.Top, 20, rect.Height );
var icon = string.IsNullOrEmpty( _template.IconName ) ? "bookmark" : _template.IconName;
Paint.SetPen( tint.WithAlphaMultiplied( hoverOpacity ) );
Paint.DrawIcon( iconRect, icon, 16, TextFlag.Center );
var textRect = rect;
textRect.Left = iconRect.Right + 2;
textRect.Right -= 4;
Paint.SetPen( Theme.Text.WithAlphaMultiplied( hoverOpacity ) );
Paint.SetDefaultFont();
Paint.DrawText( textRect, _template.Name, TextFlag.LeftCenter );
}
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
if ( e.LeftMouseButton )
_owner.NotifyTemplateClicked( _template );
}
protected override void OnContextMenu( ContextMenuEvent e )
{
base.OnContextMenu( e );
var menu = new Menu( this );
menu.AddOption( "Delete…", "delete", () => _owner.RequestTemplateDelete( _template ) );
menu.OpenAtCursor();
e.Accepted = true;
}
protected override void OnDragStart()
{
base.OnDragStart();
var drag = new Drag( this );
drag.Data.Object = _template;
drag.Data.Text = $"template:{_template.Name}";
drag.Execute();
Log.Info( $"{LogPrefix} PaletteTemplateButton.OnDragStart: \"{_template.Name}\"" );
}
}
}
namespace Grains.RazorDesigner.Projection.CSharp;
public abstract record CSharpOp;
// File-level scaffold
public sealed record HeaderBanner( string ClassName, string Namespace ) : CSharpOp;
public sealed record UsingDirective( string Namespace ) : CSharpOp;
public sealed record NamespaceOpen( string Namespace ) : CSharpOp;
public sealed record ClassOpen( string ClassName, string BaseClass ) : CSharpOp;
public sealed record ClassClose() : CSharpOp;
public sealed record FieldDecl(
string Visibility, string Type, string Name, string InitialExpr,
bool IsParameter, bool IsProperty = false ) : CSharpOp;
public sealed record MethodOpen(
string Visibility, bool IsOverride, bool IsAsync,
string ReturnType, string Name, string ParameterList ) : CSharpOp;
public sealed record MethodClose() : CSharpOp;
// Body-level
public sealed record Statement( string Code ) : CSharpOp; // single `;`-terminated line
public sealed record BlockOpen( string Header ) : CSharpOp; // e.g. `if ( <cond> )` — applier writes "<header> {\n" and indents
public sealed record BlockClose() : CSharpOp;
public sealed record BlankLine() : CSharpOp;
public sealed record Comment( string Text ) : CSharpOp; // `// <text>`
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.CSharp.Projectors;
namespace Grains.RazorDesigner.Projection.CSharp;
public static class CSharpProjector
{
public static CSharpResult Project(
IReadOnlyWiring wiring,
bool documentHasAnyBindings )
{
if ( wiring.Symbols.Count == 0 && !documentHasAnyBindings )
return new CSharpResult( System.Array.Empty<CSharpOp>(), null );
var ctx = new CSharpProjectorContext( wiring );
var ops = new List<CSharpOp>( 64 );
ops.Add( new HeaderBanner( wiring.ClassName, wiring.Namespace ) );
ops.Add( new UsingDirective( "Sandbox" ) );
ops.Add( new UsingDirective( "Sandbox.UI" ) );
foreach ( var u in wiring.Usings )
ops.Add( new UsingDirective( u ) );
ops.Add( new NamespaceOpen( wiring.Namespace ) );
ops.Add( new ClassOpen( wiring.ClassName, wiring.BaseClass ) );
// Step 3: body. SymbolProjector handles grouping + sorting + per-kind dispatch.
SymbolProjector.EmitAll( wiring, ops, ctx );
ops.Add( new ClassClose() );
var source = CSharpApplier.Apply( ops ).Replace( "\r\n", "\n" );
return new CSharpResult( ops, source );
}
}
using System.Collections.Generic;
using Grains.RazorDesigner.Wiring;
namespace Grains.RazorDesigner.Projection.CSharp.Projectors;
public static class ParameterSymbolProjector
{
public static void Emit( ParameterSymbol s, List<CSharpOp> ops, CSharpProjectorContext ctx )
{
var initial = s.Initial is null ? "default" : ExpressionEmitter.Emit( s.Initial, ctx );
ops.Add( new FieldDecl(
Visibility: "public", Type: s.Type, Name: s.Name,
InitialExpr: initial, IsParameter: true ) );
}
}
namespace Grains.RazorDesigner.Projection;
public static class Escape
{
public static string Html( string s )
{
if ( string.IsNullOrEmpty( s ) ) return "";
return s
.Replace( "&", "&" )
.Replace( "<", "<" )
.Replace( ">", ">" )
.Replace( "\"", """ );
}
}
using System;
using System.Collections.Generic;
using Sandbox; // Color
namespace Grains.RazorDesigner.Projection;
public interface IReadOnlyStateRule
{
Document.PseudoKind State { get; }
Document.NthChildMode NthChildMode { get; }
int NthChildArg { get; }
IAppearance Delta { get; }
public static int CompareCanonical( IReadOnlyStateRule a, IReadOnlyStateRule b )
{
int c = ((int)a.State).CompareTo( (int)b.State );
if ( c != 0 ) return c;
c = ((int)a.NthChildMode).CompareTo( (int)b.NthChildMode );
if ( c != 0 ) return c;
return a.NthChildArg.CompareTo( b.NthChildArg );
}
}
public interface IReadOnlyNode
{
Guid Id { get; }
string Kind { get; } // == ControlType.ToString()
string ClassName { get; }
IAppearance Appearance { get; }
IPayload Payload { get; }
IReadOnlyList<IReadOnlyNode> Children { get; } // non-slot children
IReadOnlyDictionary<string, IReadOnlyList<IReadOnlyNode>> Slots { get; } // slot-name -> slot children (only SplitContainer populates)
IReadOnlyList<IReadOnlyStateRule> StateRules { get; } // per-state style deltas; canonical order not guaranteed here (the Applier sorts)
}
public interface IAppearance
{
// Layout
Document.Length Width { get; }
Document.Length Height { get; }
// Flex container
Document.FlexDirection Direction { get; }
Document.JustifyContent Justify { get; }
Document.AlignItems Align { get; }
float Gap { get; }
Document.Edges Padding { get; }
Document.FlexWrap Wrap { get; }
// Positioning (grd-7t2z)
Document.PositionKind Position { get; }
Document.Length Top { get; }
Document.Length Left { get; }
Document.Length Right { get; }
Document.Length Bottom { get; }
// Flex self
float FlexGrow { get; }
float FlexShrink { get; }
Document.Length FlexBasis { get; }
Document.AlignSelfKind AlignSelf { get; }
// Typography + OverrideTypography
bool OverrideTypography { get; }
string FontFamily { get; }
Document.Length FontSize { get; }
int FontWeight { get; }
Color Color { get; }
Document.TextAlignment TextAlign { get; }
bool FontStyleItalic { get; }
Document.TextTransformKind TextTransform { get; }
Document.Length LetterSpacing { get; }
Document.Length LineHeight { get; }
// Background + OverrideBackground
bool OverrideBackground { get; }
Color BackgroundColor { get; }
string BackgroundImage { get; }
string BackgroundSize { get; }
string BackgroundPosition { get; }
string BackgroundRepeat { get; }
// Border + OverrideBorder
bool OverrideBorder { get; }
Document.Length BorderRadius { get; }
Color BorderColor { get; }
Document.Length BorderWidth { get; }
// Effects + OverrideEffects
bool OverrideEffects { get; }
Document.Length BoxShadowX { get; }
Document.Length BoxShadowY { get; }
Document.Length BoxShadowBlur { get; }
Color BoxShadowColor { get; }
bool BoxShadowInset { get; }
float Opacity { get; }
// Constraints + OverrideConstraints
bool OverrideConstraints { get; }
Document.Edges Margin { get; }
Document.Length MinWidth { get; }
Document.Length MaxWidth { get; }
Document.Length MinHeight { get; }
Document.Length MaxHeight { get; }
// Interaction + OverrideInteraction
bool OverrideInteraction { get; }
Document.CursorKind Cursor { get; }
Document.OverflowKind Overflow { get; }
int ZIndex { get; }
bool PointerEvents { get; }
}
public interface IPayload
{
string Content { get; } // Label/Button text; Checkbox label
string Placeholder { get; } // TextEntry
string Source { get; } // Image src
string IconName { get; } // IconPanel glyph
Document.Length CheckboxSize { get; } // Checkbox box size
}
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.Appearance;
using Grains.RazorDesigner.Projection.Razor;
namespace Grains.RazorDesigner.Projection.Projectors;
[Projector( "Button" )]
public sealed class ButtonProjector : IControlProjector
{
public string Kind => "Button";
public ProjectionResult Project( IReadOnlyNode node, IAppearance a, IPayload p, ProjectionContext ctx )
{
var scss = AppearanceScss.Emit(
a,
isRoot: node.ClassName == Document.DesignerDocument.RootClassName,
isContainer: false,
childCount: 0,
isLabel: false,
isCheckbox: false,
checkboxSize: default );
var nodeId = node.Id.ToString();
var ops = new PanelOp[]
{
new SetAttribute( "data-grd-node-id", nodeId ),
new SetInnerText( p.Content ?? "" ),
};
var razorAttrs = new[] { RazorEmit.Attr( "data-grd-node-id", nodeId ) };
return new ProjectionResult(
PanelOps: ops,
ScssLines: scss,
RazorAttributes: razorAttrs,
RazorInnerText: Escape.Html( p.Content ?? "" ) );
}
}
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.Appearance;
using Grains.RazorDesigner.Projection.Razor;
namespace Grains.RazorDesigner.Projection.Projectors;
[Projector( "Field" )]
public sealed class FieldProjector : IControlProjector
{
public string Kind => "Field";
public ProjectionResult Project( IReadOnlyNode node, IAppearance a, IPayload p, ProjectionContext ctx )
{
var scss = AppearanceScss.Emit(
a,
isRoot: node.ClassName == Document.DesignerDocument.RootClassName,
isContainer: true,
childCount: node.Children.Count,
isLabel: false,
isCheckbox: false,
checkboxSize: default );
var nodeId = node.Id.ToString();
var ops = new PanelOp[]
{
new SetAttribute( "data-grd-node-id", nodeId ),
};
var razorAttrs = new[] { RazorEmit.Attr( "data-grd-node-id", nodeId ) };
return new ProjectionResult(
PanelOps: ops,
ScssLines: scss,
RazorAttributes: razorAttrs,
RazorInnerText: null );
}
}
namespace Grains.RazorDesigner.Projection.Razor;
public static class RazorEmit
{
public static string Attr( string name, string value ) => $"{name}=\"{Escape.Html( value )}\"";
}
using System;
namespace Grains.RazorDesigner.Projection.Tests;
public static class PanelOpExhaustivenessTest
{
public static (bool pass, string message) Run()
{
var ops = new PanelOp[]
{
new SetClass( "" ),
new SetStyle( "", "" ),
new SetAttribute( "", "" ),
new SetInnerText( "" ),
};
try
{
foreach ( var op in ops )
Applier.ApplyOpToScratch( op );
return (true, $"PanelOpExhaustivenessTest: {ops.Length} variants OK");
}
catch ( Exception e )
{
return (false, $"PanelOpExhaustivenessTest FAILED: {e.GetType().Name}: {e.Message}");
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Grains.RazorDesigner.Document;
namespace Grains.RazorDesigner.Serialization.IR;
public static class IRWriter
{
private const string LogPrefix = "[Grains.RazorDesigner]";
// Empty collections reused for nodes that have no slots/children/metadata.
private static readonly IReadOnlyDictionary<string, object> _emptyMetadata = new Dictionary<string, object>();
private static readonly IReadOnlyList<IRNodeEnvelope> _emptyChildren = System.Array.Empty<IRNodeEnvelope>();
private static readonly IReadOnlyDictionary<string, IRNodeEnvelope> _emptySlots = new Dictionary<string, IRNodeEnvelope>();
public static string WriteDocument( DesignerDocument doc )
{
if ( doc is null )
throw new ArgumentNullException( nameof( doc ) );
Log.Info( $"{LogPrefix} IRWriter.WriteDocument: serialising document (root children: {doc.RootRecord.Children.Count})" );
var envelope = new IRDocumentEnvelope
{
Root = ToNode( doc.RootRecord ),
Wiring = doc.Wiring ?? Grains.RazorDesigner.Wiring.WiringEnvelope.Empty,
};
var json = JsonSerializer.Serialize( envelope, DesignerIRJson.Options );
// Normalise CRLF → LF (canonical form; .gitattributes also pins LF as a backstop).
if ( json.Contains( '\r' ) )
json = json.Replace( "\r\n", "\n" ).Replace( "\r", "\n" );
Log.Info( $"{LogPrefix} IRWriter.WriteDocument: OK ({json.Length} chars)" );
return json;
}
public static string CanonicalHash( string json )
{
if ( json is null )
throw new ArgumentNullException( nameof( json ) );
var bytes = Encoding.UTF8.GetBytes( json );
var hash = SHA256.HashData( bytes );
return Convert.ToHexString( hash ).ToLowerInvariant();
}
// Recursively converts a ControlRecord to its IRNodeEnvelope representation.
private static IRNodeEnvelope ToNode( ControlRecord r )
{
Dictionary<string, IRNodeEnvelope> slotDict = null;
List<IRNodeEnvelope> childList = null;
foreach ( var child in r.Children )
{
if ( child.IsSlot )
{
slotDict ??= new Dictionary<string, IRNodeEnvelope>();
slotDict[child.SlotName] = ToNode( child );
}
else
{
childList ??= new List<IRNodeEnvelope>();
childList.Add( ToNode( child ) );
}
}
return new IRNodeEnvelope
{
Id = r.Id,
Kind = r.Type,
ClassName = r.ClassName,
Appearance = r.Appearance,
Payload = r.Payload,
Slots = slotDict is not null
? (IReadOnlyDictionary<string, IRNodeEnvelope>)slotDict
: _emptySlots,
Children = childList is not null
? (IReadOnlyList<IRNodeEnvelope>)childList
: _emptyChildren,
States = r.StateRules.Count == 0
? null
: r.StateRules
.OrderBy( rule => rule, Comparer<StateRule>.Create( StateRule.CompareCanonical ) )
.Select( rule => new IRStateEnvelope
{
State = rule.State,
NthChildMode = rule.NthChildMode,
NthChildArg = rule.NthChildArg,
Delta = rule.Delta,
} )
.ToList(),
Bindings = r.Bindings.Count == 0
? System.Array.Empty<Grains.RazorDesigner.Wiring.Binding>()
: r.Bindings.ToArray(),
CustomStyles = r.CustomStyles.Count == 0
? null
: new Dictionary<string, string>( r.CustomStyles ),
};
}
}
using System;
using System.Text.Json.Serialization;
namespace Grains.RazorDesigner.Wiring;
[JsonPolymorphic( TypeDiscriminatorPropertyName = "$type" )]
[JsonDerivedType( typeof( SetAction ), "Set" )]
[JsonDerivedType( typeof( CallAction ), "Call" )]
[JsonDerivedType( typeof( IfAction ), "If" )]
[JsonDerivedType( typeof( StateHasChangedAction ), "StateHasChanged" )]
[JsonDerivedType( typeof( LogAction ), "Log" )]
[JsonDerivedType( typeof( ReturnAction ), "Return" )]
[JsonDerivedType( typeof( InlineAction ), "Inline" )]
public abstract record Action
{
public Guid Id { get; init; } = Guid.NewGuid();
}
namespace Grains.RazorDesigner.Wiring;
public sealed record InlineAction : Action
{
public string Code { get; init; } = "";
}
namespace Grains.RazorDesigner.Wiring;
// `Target = Value;` — assignment to a Symbol field.
public sealed record SetAction : Action
{
public TargetRef Target { get; init; }
public Expression Value { get; init; }
}
using System.Collections.Generic;
namespace Grains.RazorDesigner.Wiring;
public sealed record EventBinding : Binding
{
public string Event { get; init; } = "";
public IReadOnlyList<Action> Body { get; init; } = System.Array.Empty<Action>();
}
namespace Grains.RazorDesigner.Wiring;
public sealed record VisibleBinding : Binding
{
public Expression Condition { get; init; }
}
namespace Grains.RazorDesigner.Wiring;
public enum SymbolVisibility
{
Private,
Public,
Internal,
Protected,
}
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 Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace GeneralGame.Editor;
/// <summary>
/// Helper for building asset context menus with Create Material/Texture options.
/// </summary>
public static class AssetContextMenuHelper
{
// Builds the right-click menu for a single file/asset. Shared by the tree view and the icon grid
// so both show identical options. Rename UI differs per view, so it is passed in via onRename.
public static void BuildFileMenu(Menu menu, string fullPath, Asset asset, Action onRename, Action onChanged)
{
var fileName = Path.GetFileName(fullPath);
if (asset != null)
menu.AddOption("Open in Editor", "edit", () => asset.OpenInEditor());
else
menu.AddOption("Open", "open_in_new", () => EditorUtility.OpenFolder(fullPath));
menu.AddOption("Show in Explorer", "folder_open", () => EditorUtility.OpenFileFolder(fullPath));
menu.AddSeparator();
if (asset != null)
menu.AddOption("Copy Relative Path", "content_paste_go", () => EditorUtility.Clipboard.Copy(asset.RelativePath));
menu.AddOption("Copy Absolute Path", "content_paste", () => EditorUtility.Clipboard.Copy(fullPath));
// Asset-type specific options (Create Material, Create Texture, etc.)
AddAssetTypeOptions(menu, asset);
menu.AddSeparator();
menu.AddOption("Rename", "edit", () => onRename?.Invoke());
menu.AddOption("Duplicate", "file_copy", () =>
{
DuplicateFile(fullPath);
onChanged?.Invoke();
});
menu.AddSeparator();
var parentFolder = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(parentFolder))
{
var createMenu = menu.AddMenu("Create", "add");
AssetCreator.AddOptions(createMenu, parentFolder);
menu.AddSeparator();
}
menu.AddOption("Delete", "delete", () =>
{
var confirm = new PopupWindow(
"Delete File",
$"Are you sure you want to delete '{fileName}'?",
"Cancel",
new Dictionary<string, Action>()
{
{ "Delete", () =>
{
try
{
DeleteFileWithCompiled(fullPath, asset);
onChanged?.Invoke();
}
catch (Exception ex)
{
Log.Error($"Failed to delete file: {ex.Message}");
}
}
}
}
);
confirm.Show();
});
}
// Builds the right-click menu for a folder. Shared by the tree view and the icon grid.
public static void BuildFolderMenu(Menu menu, string fullPath, string displayName, bool isRoot,
Action onOpen, Action onRename, Action onRefresh, Action onDeleted)
{
if (onOpen != null)
menu.AddOption("Open", "folder_open", () => onOpen());
menu.AddOption("Open in Explorer", "launch", () => EditorUtility.OpenFolder(fullPath));
menu.AddSeparator();
var createMenu = menu.AddMenu("Create", "add");
AssetCreator.AddOptions(createMenu, fullPath);
// Paste files copied from Windows Explorer into this folder
if (WindowsClipboard.HasFiles())
{
menu.AddOption("Paste", "content_paste", () =>
{
PasteFromClipboard(fullPath);
onRefresh?.Invoke();
});
}
menu.AddSeparator();
if (!isRoot)
menu.AddOption("Rename", "edit", () => onRename?.Invoke());
menu.AddOption("Copy Path", "content_copy", () => EditorUtility.Clipboard.Copy(fullPath));
menu.AddOption("Copy Relative Path", "content_copy", () =>
{
var relativePath = Path.GetRelativePath(Project.Current?.GetRootPath() ?? "", fullPath);
EditorUtility.Clipboard.Copy(relativePath);
});
menu.AddSeparator();
menu.AddOption("Refresh", "refresh", () => onRefresh?.Invoke());
if (!isRoot)
{
menu.AddSeparator();
menu.AddOption("Delete", "delete", () =>
{
var confirm = new PopupWindow(
"Delete Folder",
$"Are you sure you want to delete '{displayName}'?\nAll contents will be deleted.",
"Cancel",
new Dictionary<string, Action>()
{
{ "Delete", () =>
{
try
{
Directory.Delete(fullPath, recursive: true);
onDeleted?.Invoke();
}
catch (Exception ex)
{
Log.Error($"Failed to delete folder: {ex.Message}");
}
}
}
}
);
confirm.Show();
});
}
}
// Duplicates a file next to itself, finding a free "_copy" name.
public static void DuplicateFile(string filePath)
{
try
{
var directory = Path.GetDirectoryName(filePath);
var nameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
var extension = Path.GetExtension(filePath);
var newName = $"{nameWithoutExt}_copy{extension}";
var newPath = Path.Combine(directory, newName);
var counter = 1;
while (File.Exists(newPath))
{
newName = $"{nameWithoutExt}_copy{counter++}{extension}";
newPath = Path.Combine(directory, newName);
}
File.Copy(filePath, newPath);
// Register the new file so it gets compiled and recognised as an asset immediately
// (without this it shows up uncompiled until an editor restart or rename).
AssetSystem.RegisterFile(newPath);
}
catch (Exception ex)
{
Log.Error($"Failed to duplicate file: {ex.Message}");
}
}
// Registers a freshly created/copied/moved path with the asset system so it compiles right away.
// Accepts a single file or a directory (registers every file inside, recursively).
public static void RegisterNewPath(string path)
{
try
{
if (Directory.Exists(path))
{
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
if (ShouldRegister(file))
AssetSystem.RegisterFile(file);
}
}
else if (File.Exists(path) && ShouldRegister(path))
{
AssetSystem.RegisterFile(path);
}
}
catch (Exception ex)
{
Log.Warning($"Failed to register '{path}': {ex.Message}");
}
}
// Copies the files currently on the Windows clipboard into the target folder (always copy, never move).
public static void PasteFromClipboard(string targetFolder)
{
if (string.IsNullOrEmpty(targetFolder) || !Directory.Exists(targetFolder))
return;
foreach (var file in WindowsClipboard.GetFiles())
{
if (string.IsNullOrEmpty(file))
continue;
try
{
var name = Path.GetFileName(file.TrimEnd('\\', '/'));
var destPath = Path.Combine(targetFolder, name);
// Don't paste a folder into itself
if (Path.GetFullPath(file).Equals(Path.GetFullPath(destPath), StringComparison.OrdinalIgnoreCase))
destPath = MakeUniquePath(destPath);
else if (File.Exists(destPath) || Directory.Exists(destPath))
destPath = MakeUniquePath(destPath);
if (Directory.Exists(file))
CopyDirectory(file, destPath);
else if (File.Exists(file))
File.Copy(file, destPath, overwrite: false);
else
continue;
RegisterNewPath(destPath);
}
catch (Exception ex)
{
Log.Error($"Failed to paste '{file}': {ex.Message}");
}
}
}
private static string MakeUniquePath(string path)
{
if (!File.Exists(path) && !Directory.Exists(path))
return path;
var directory = Path.GetDirectoryName(path);
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
int counter = 1;
string candidate;
do
{
var suffix = counter == 1 ? "_copy" : $"_copy{counter}";
candidate = Path.Combine(directory, $"{name}{suffix}{ext}");
counter++;
}
while (File.Exists(candidate) || Directory.Exists(candidate));
return candidate;
}
private static void CopyDirectory(string sourceDir, string destDir)
{
Directory.CreateDirectory(destDir);
foreach (var file in Directory.GetFiles(sourceDir))
{
File.Copy(file, Path.Combine(destDir, Path.GetFileName(file)));
}
foreach (var dir in Directory.GetDirectories(sourceDir))
{
CopyDirectory(dir, Path.Combine(destDir, Path.GetFileName(dir)));
}
}
// True when the path lives outside the current project (e.g. dragged in from Windows Explorer).
// Such sources must be copied, never moved, so we don't delete files from their original location.
public static bool IsExternalSource(string path)
{
try
{
var root = Project.Current?.GetRootPath();
if (string.IsNullOrEmpty(root))
return false;
var full = Path.GetFullPath(path);
var rootFull = Path.GetFullPath(root);
return !full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
// ===== Backward compatibility: move an asset and optionally fix references to its old path =====
private static readonly HashSet<string> NonTextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".tga", ".psd", ".bmp", ".gif", ".dds", ".hdr",
".fbx", ".obj", ".gltf", ".glb", ".dmx", ".blend",
".wav", ".mp3", ".ogg", ".flac",
".vtex", ".vsnd", ".vmdl", ".dll", ".pdb", ".exe", ".zip", ".bin"
};
// True if this is a single registered asset being moved inside the project - the case the
// backward-compatibility reference check applies to.
public static bool IsBackwardCompatMove(IReadOnlyList<string> files, bool isCopy)
{
if (!BrowserSettings.BackwardCompatibility) return false;
if (isCopy) return false;
if (files == null || files.Count != 1) return false;
var file = files[0];
if (string.IsNullOrEmpty(file) || IsExternalSource(file) || Directory.Exists(file)) return false;
if (!File.Exists(file)) return false;
var asset = AssetSystem.FindByPath(file);
return asset != null && !asset.IsDeleted;
}
// Moves an asset, first asking (via a modal) whether to update references to its old path.
public static void MoveAssetWithReferenceCheck(string sourceFile, string targetFolder, Action onComplete)
{
var asset = AssetSystem.FindByPath(sourceFile);
if (asset == null || asset.IsDeleted)
return;
// Don't bother if it's already in the target folder
if (string.Equals(Path.GetFullPath(Path.GetDirectoryName(sourceFile)), Path.GetFullPath(targetFolder), StringComparison.OrdinalIgnoreCase))
return;
var assetsRoot = Project.Current?.GetAssetsPath();
var oldRef = asset.RelativePath?.Replace('\\', '/');
var newAbs = Path.Combine(targetFolder, Path.GetFileName(sourceFile));
var newRef = string.IsNullOrEmpty(assetsRoot)
? null
: Path.GetRelativePath(assetsRoot, newAbs).Replace('\\', '/');
void JustMove()
{
EditorUtility.MoveAssetToDirectory(asset, targetFolder);
onComplete?.Invoke();
}
// Can't compute a clean reference (asset outside the assets mount) - just move normally
if (string.IsNullOrEmpty(oldRef) || string.IsNullOrEmpty(newRef) || newRef.StartsWith(".."))
{
JustMove();
return;
}
var affected = FindFilesReferencing(oldRef);
affected.RemoveAll(f => string.Equals(Path.GetFullPath(f), Path.GetFullPath(asset.AbsolutePath), StringComparison.OrdinalIgnoreCase));
var dialog = new MoveReferencesDialog(oldRef, newRef, affected,
onJustMove: JustMove,
onMoveAndUpdate: () =>
{
EditorUtility.MoveAssetToDirectory(asset, targetFolder);
RewriteReferences(affected, oldRef, newRef);
onComplete?.Invoke();
});
dialog.Show();
}
// Matches the reference only at a path boundary, so "models/foo.vmdl" doesn't match inside
// "othermodels/foo.vmdl" or "sub/models/foo.vmdl". A trailing "_c" (compiled form) is still matched.
private static Regex BuildReferenceRegex(string reference)
{
return new Regex(@"(?<![\w./\\-])" + Regex.Escape(reference), RegexOptions.IgnoreCase);
}
// Finds every text-based project file that mentions the given reference path.
public static List<string> FindFilesReferencing(string reference)
{
var results = new List<string>();
if (string.IsNullOrEmpty(reference))
return results;
var root = Project.Current?.GetAssetsPath();
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
return results;
var regex = BuildReferenceRegex(reference);
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
if (!IsScannableTextFile(file))
continue;
try
{
var info = new FileInfo(file);
if (info.Length == 0 || info.Length > 16_000_000)
continue;
var text = File.ReadAllText(file);
if (regex.IsMatch(text))
results.Add(file);
}
catch
{
// Ignore unreadable files
}
}
return results;
}
// Rewrites the old reference to the new one in each file (covers compiled "_c" forms too, since
// they share the prefix). Re-registers the file afterwards.
public static void RewriteReferences(IEnumerable<string> files, string oldRef, string newRef)
{
var regex = BuildReferenceRegex(oldRef);
foreach (var file in files)
{
try
{
var text = File.ReadAllText(file);
var updated = regex.Replace(text, _ => newRef);
if (updated != text)
{
File.WriteAllText(file, updated);
AssetSystem.RegisterFile(file);
}
}
catch (Exception ex)
{
Log.Error($"Failed to update references in '{file}': {ex.Message}");
}
}
}
private static bool IsScannableTextFile(string file)
{
if (!ShouldRegister(file))
return false;
var ext = Path.GetExtension(file);
if (NonTextExtensions.Contains(ext))
return false;
return true;
}
private static bool ShouldRegister(string file)
{
var name = Path.GetFileName(file);
if (name.StartsWith(".")) return false;
if (name.EndsWith("_c", StringComparison.OrdinalIgnoreCase)) return false;
if (name.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) return false;
if (name.Contains(".generated", StringComparison.OrdinalIgnoreCase)) return false;
return true;
}
// Deletes a file, also removing its compiled "_c" sibling. Uses Asset.Delete when registered.
public static void DeleteFileWithCompiled(string fullPath, Asset asset)
{
if (asset != null)
{
asset.Delete();
return;
}
File.Delete(fullPath);
var compiledPath = fullPath + "_c";
if (File.Exists(compiledPath))
{
File.Delete(compiledPath);
}
}
private static readonly HashSet<string> MeshExtensions =
new(StringComparer.OrdinalIgnoreCase) { ".fbx", ".obj", ".dmx", ".gltf", ".glb" };
// Builds the right-click menu shown when several files are selected at once.
// Shared by the tree view and the icon grid so both behave identically.
public static void BuildMultiFileMenu(Menu menu, List<(string Path, Asset Asset)> items, Action onChanged)
{
if (items == null || items.Count == 0) return;
int count = items.Count;
var assets = items.Where(i => i.Asset != null).Select(i => i.Asset).ToList();
// Asset-type batch options (Create Material (N), Create Texture (N), etc.)
bool addedTypeOptions = AddMultiAssetTypeOptions(menu, assets);
if (addedTypeOptions)
menu.AddSeparator();
menu.AddOption($"Duplicate ({count})", "file_copy", () =>
{
foreach (var it in items)
DuplicateFile(it.Path);
onChanged?.Invoke();
});
menu.AddOption($"Delete ({count})", "delete", () =>
{
var confirm = new PopupWindow(
"Delete Files",
$"Are you sure you want to delete {count} item(s)?",
"Cancel",
new Dictionary<string, Action>()
{
{ "Delete", () =>
{
foreach (var it in items)
{
try
{
DeleteFileWithCompiled(it.Path, it.Asset);
}
catch (Exception ex)
{
Log.Error($"Failed to delete '{it.Path}': {ex.Message}");
}
}
onChanged?.Invoke();
}
}
}
);
confirm.Show();
});
}
// Adds asset-type specific batch options with a count suffix, e.g. "Create Material (3)".
// Each option auto-creates the result next to every source asset (no save dialog).
// Returns true if any option was added.
public static bool AddMultiAssetTypeOptions(Menu menu, List<Asset> assets)
{
if (assets == null || assets.Count == 0) return false;
var images = assets.Where(a => a.AssetType == AssetType.ImageFile).ToList();
var shaders = assets.Where(a => a.AssetType == AssetType.Shader).ToList();
var meshes = assets.Where(a => MeshExtensions.Contains(Path.GetExtension(a.AbsolutePath))).ToList();
bool added = false;
if (images.Count > 0)
{
menu.AddOption($"Create Material ({images.Count})", "image", () =>
{
foreach (var a in images) CreateMaterialFromImageAuto(a);
Log.Info($"Created {images.Count} material(s)");
});
menu.AddOption($"Create Texture ({images.Count})", "texture", () =>
{
foreach (var a in images) CreateTextureFromImageAuto(a);
});
menu.AddOption($"Create Sprite ({images.Count})", "emoji_emotions", () =>
{
foreach (var a in images) CreateSpriteFromImageAuto(a);
});
added = true;
}
if (shaders.Count > 0)
{
if (added) menu.AddSeparator();
menu.AddOption($"Create Material ({shaders.Count})", "image", () =>
{
foreach (var a in shaders) CreateMaterialFromShaderAuto(a);
});
added = true;
}
if (meshes.Count > 0)
{
if (added) menu.AddSeparator();
menu.AddOption($"Create Model ({meshes.Count})", "view_in_ar", () =>
{
foreach (var a in meshes) CreateModelFromMeshAuto(a);
});
added = true;
}
var sounds = assets.Where(a => a.AssetType == AssetType.SoundFile).ToList();
if (sounds.Count > 0)
{
if (added) menu.AddSeparator();
// One sound event per file
menu.AddOption($"Create Sound Event ({sounds.Count})", "graphic_eq", () =>
{
foreach (var a in sounds) CreateSoundEventFromAudioAuto(a);
Log.Info($"Created {sounds.Count} sound event(s)");
});
// A single sound event that randomly picks between all the selected sounds
menu.AddOption($"Create Random Sound Event ({sounds.Count})", "shuffle", () =>
{
CreateRandomSoundEventFromAudios(sounds);
});
added = true;
}
return added;
}
/// <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
if (MeshExtensions.Contains(Path.GetExtension(asset.AbsolutePath)))
{
menu.AddSeparator();
menu.AddOption("Create Model", "view_in_ar", () => CreateModelFromMesh(asset));
}
// Sound files - can create a Sound Event
if (assetType == AssetType.SoundFile)
{
menu.AddSeparator();
menu.AddOption("Create Sound Event", "graphic_eq", () => CreateSoundEventFromAudio(asset));
}
}
private static void CreateTextureFromImage(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Texture from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vtex";
fd.SelectFile($"{asset.Name}.vtex");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Texture File (*.vtex)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildVtexContent(asset));
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static void CreateMaterialFromImage(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Material from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vmat";
fd.SelectFile($"{GetMaterialBaseName(asset)}.vmat");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Material File (*.vmat)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildImageMaterialContent(asset));
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static async void CreateSpriteFromImage(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Sprite from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".sprite";
fd.SelectFile($"{asset.Name}.sprite");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Sprite File (*.sprite)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildSpriteContent(asset));
var resultAsset = AssetSystem.RegisterFile(fd.SelectedFile);
while (!resultAsset.IsCompiledAndUpToDate)
{
await Task.Delay(10);
}
}
private static void CreateMaterialFromShader(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Material from Shader..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vmat";
fd.SelectFile($"{asset.Name}.vmat");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Material File (*.vmat)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildShaderMaterialContent(asset));
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);
}
private static void CreateSoundEventFromAudio(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Sound Event..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".sound";
fd.SelectFile($"{asset.Name}.sound");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Sound Event (*.sound)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildSoundEventContent(new[] { GetVsndReference(asset) }));
AssetSystem.RegisterFile(fd.SelectedFile);
}
// ===== Batch creators: auto-name next to each source asset, skip if it already exists =====
private static void CreateMaterialFromImageAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{GetMaterialBaseName(asset)}.vmat");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildImageMaterialContent(asset));
AssetSystem.RegisterFile(destPath);
}
private static void CreateTextureFromImageAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.vtex");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildVtexContent(asset));
AssetSystem.RegisterFile(destPath);
}
private static async void CreateSpriteFromImageAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.sprite");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildSpriteContent(asset));
var resultAsset = AssetSystem.RegisterFile(destPath);
while (!resultAsset.IsCompiledAndUpToDate)
{
await Task.Delay(10);
}
}
private static void CreateMaterialFromShaderAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.vmat");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildShaderMaterialContent(asset));
AssetSystem.RegisterFile(destPath);
}
private static void CreateModelFromMeshAuto(Asset asset)
{
var destPath = Path.ChangeExtension(asset.AbsolutePath, "vmdl");
if (File.Exists(destPath))
return;
EditorUtility.CreateModelFromMeshFile(asset, destPath);
}
private static void CreateSoundEventFromAudioAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.sound");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildSoundEventContent(new[] { GetVsndReference(asset) }));
AssetSystem.RegisterFile(destPath);
}
// Creates a single sound event that randomly picks between all the given audio files.
private static void CreateRandomSoundEventFromAudios(List<Asset> assets)
{
if (assets == null || assets.Count == 0)
return;
var first = assets[0];
var directory = Path.GetDirectoryName(first.AbsolutePath);
var destPath = FindFreePath(directory, GetSoundBaseName(first), ".sound");
var references = assets.Select(GetVsndReference).ToList();
File.WriteAllText(destPath, BuildSoundEventContent(references));
AssetSystem.RegisterFile(destPath);
}
// ===== Content builders shared by single and batch creators =====
// Strips trailing texture-role suffixes (_color, _normal, ...) to get the material base name.
private static string GetMaterialBaseName(Asset asset)
{
string[] suffixes = { "color", "ao", "normal", "metallic", "rough", "diff", "diffuse", "nrm", "spec", "selfillum", "mask" };
var assetName = asset.Name;
foreach (var t in suffixes)
{
if (assetName.EndsWith($"_{t}"))
assetName = assetName.Substring(0, assetName.Length - (t.Length + 1));
}
return assetName;
}
// Builds a complex.shader material, auto-wiring sibling textures (normal/ao/rough/...) by name.
private static string BuildImageMaterialContent(Asset asset)
{
var assetName = GetMaterialBaseName(asset);
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}\"";
}
return $@"
Layer0
{{
shader ""shaders/complex.shader_c""
TextureColor ""{texColor}""
TextureAmbientOcclusion ""{texAo}""
TextureNormal ""{texNormal}""
TextureRoughness ""{texRough}""{texMetallic}{texSelfIllum}{tintMask}
}}
";
}
private static string BuildShaderMaterialContent(Asset asset)
{
var shaderPath = asset.GetCompiledFile();
return $@"
Layer0
{{
shader ""{shaderPath}""
}}
";
}
private static string BuildVtexContent(Asset asset)
{
var vtexContent = new Dictionary<string, object>
{
{ "Sequences", new object[]
{
new Dictionary<string, object>
{
{ "Source", asset.RelativePath },
{ "IsLooping", true }
}
}
}
};
return Json.Serialize(vtexContent);
}
private static string BuildSpriteContent(Asset asset)
{
var path = Path.ChangeExtension(asset.Path, Path.GetExtension(asset.AbsolutePath));
var sprite = Sprite.FromTexture(Texture.Load(path));
return sprite.Serialize().ToJsonString();
}
// Builds a .sound (SoundEvent) resource that plays a random sound from the given references.
private static string BuildSoundEventContent(IEnumerable<string> vsndReferences)
{
var content = new Dictionary<string, object>
{
{ "Volume", "1" },
{ "Pitch", "1" },
{ "SelectionMode", "Random" },
{ "Sounds", vsndReferences.ToArray() },
{ "__version", 1 }
};
return Json.Serialize(content);
}
// Sound events reference the compiled .vsnd, regardless of the source file extension.
private static string GetVsndReference(Asset asset)
{
return Path.ChangeExtension(asset.RelativePath, "vsnd").Replace('\\', '/');
}
// Strips a trailing numeric index so "footstep_01" -> "footstep" for naming a combined event.
private static string GetSoundBaseName(Asset asset)
{
var name = asset.Name;
var underscore = name.LastIndexOf('_');
if (underscore > 0 && int.TryParse(name.Substring(underscore + 1), out _))
name = name.Substring(0, underscore);
return name;
}
private static string FindFreePath(string directory, string baseName, string extension)
{
var path = Path.Combine(directory, $"{baseName}{extension}");
int counter = 1;
while (File.Exists(path))
path = Path.Combine(directory, $"{baseName}_{counter++}{extension}");
return path;
}
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
global using Dreams.UltimateLightManager;
using Sandbox;
using System.Collections.Generic;
using Saandy.Tilemapper;
namespace Saandy.Editor.Tilemapper;
/// <summary>
/// 47-tile 3x3 blob autotile brush.
/// This is the "complex" brush: full 8-neighbor mask input, but reduced to the 47 useful blob shapes.
/// It distinguishes isolated 1x1 tiles, narrow caps/lines, edges, outer corners, and inner corners.
/// </summary>
public class TileBrush3x3Complex : TileBrush
{
public override string Name { get; protected set; } = "3x3 Complex Brush";
// Lookup uses this bit order:
// 1 2 4
// 8 X 16
// 32 64 128
//
// 255 = surrounded/full -> tile 0
// 0 = isolated -> tile 46
private static readonly ushort[] TileLookup =
{
46, 46, 44, 44, 46, 46, 44, 44, 45, 45, 39, 38, 45, 45, 39, 38,
43, 43, 41, 41, 43, 43, 40, 40, 33, 33, 31, 30, 33, 33, 29, 28,
46, 46, 44, 44, 46, 46, 44, 44, 45, 45, 39, 38, 45, 45, 39, 38,
43, 43, 41, 41, 43, 43, 40, 40, 33, 33, 31, 30, 33, 33, 29, 28,
42, 42, 32, 32, 42, 42, 32, 32, 37, 37, 27, 25, 37, 37, 27, 25,
35, 35, 19, 19, 35, 35, 18, 18, 23, 23, 15, 14, 23, 23, 13, 12,
42, 42, 32, 32, 42, 42, 32, 32, 36, 36, 26, 24, 36, 36, 26, 24,
35, 35, 19, 19, 35, 35, 18, 18, 21, 21, 7, 6, 21, 21, 5, 4,
46, 46, 44, 44, 46, 46, 44, 44, 45, 45, 39, 38, 45, 45, 39, 38,
43, 43, 41, 41, 43, 43, 40, 40, 33, 33, 31, 30, 33, 33, 29, 28,
46, 46, 44, 44, 46, 46, 44, 44, 45, 45, 39, 38, 45, 45, 39, 38,
43, 43, 41, 41, 43, 43, 40, 40, 33, 33, 31, 30, 33, 33, 29, 28,
42, 42, 32, 32, 42, 42, 32, 32, 37, 37, 27, 25, 37, 37, 27, 25,
34, 34, 17, 17, 34, 34, 16, 16, 22, 22, 11, 10, 22, 22, 9, 8,
42, 42, 32, 32, 42, 42, 32, 32, 36, 36, 26, 24, 36, 36, 26, 24,
34, 34, 17, 17, 34, 34, 16, 16, 20, 20, 3, 2, 20, 20, 1, 0
};
public override ushort GetSpriteIndexToSet( TileMap tilemap, TilesetResource tileset, int x, int y )
{
int bitmask = GetBitmask( tilemap, x, y );
return TileLookup[bitmask];
}
public override (bool IsVisible, bool UseCollider) GetDefaultTileFlags( ushort spriteIndex )
{
return (true, true);
}
private int GetBitmask( TileMap tilemap, int x, int y )
{
ushort centerTilesetId = tilemap.GetTilesetIdAt( x, y );
if ( centerTilesetId == 0 )
return 0;
int mask = 0;
// The lookup table/template is authored with visual Up as +Y in tilemap cell space.
// The old version used y - 1 for Up, which vertically mirrored every top/bottom
// edge and corner piece in the 3x3 complex brush.
// Top row / visual up.
if ( IsSameTileset( tilemap, centerTilesetId, x - 1, y + 1 ) )
mask |= 1; // UpLeft
if ( IsSameTileset( tilemap, centerTilesetId, x, y + 1 ) )
mask |= 2; // Up
if ( IsSameTileset( tilemap, centerTilesetId, x + 1, y + 1 ) )
mask |= 4; // UpRight
// Middle row.
if ( IsSameTileset( tilemap, centerTilesetId, x - 1, y ) )
mask |= 8; // Left
if ( IsSameTileset( tilemap, centerTilesetId, x + 1, y ) )
mask |= 16; // Right
// Bottom row / visual down.
if ( IsSameTileset( tilemap, centerTilesetId, x - 1, y - 1 ) )
mask |= 32; // DownLeft
if ( IsSameTileset( tilemap, centerTilesetId, x, y - 1 ) )
mask |= 64; // Down
if ( IsSameTileset( tilemap, centerTilesetId, x + 1, y - 1 ) )
mask |= 128; // DownRight
return mask;
}
private static bool IsSameTileset( TileMap tilemap, ushort centerTilesetId, int x, int y )
{
return tilemap.IsSameTilesetId( centerTilesetId, tilemap.GetTilesetIdAt( x, y ) );
}
protected override IEnumerable<Vector2Int> GetAffectedTiles( Vector2Int cell )
{
// Order does not affect the update result, but keep this listed visually top-to-bottom
// to match the bitmask comments above.
yield return new Vector2Int( cell.x - 1, cell.y + 1 );
yield return new Vector2Int( cell.x, cell.y + 1 );
yield return new Vector2Int( cell.x + 1, cell.y + 1 );
yield return new Vector2Int( cell.x - 1, cell.y );
yield return new Vector2Int( cell.x + 1, cell.y );
yield return new Vector2Int( cell.x - 1, cell.y - 1 );
yield return new Vector2Int( cell.x, cell.y - 1 );
yield return new Vector2Int( cell.x + 1, cell.y - 1 );
}
}
using Sandbox;
using System.Collections.Generic;
using Saandy.Tilemapper;
namespace Saandy.Editor.Tilemapper;
public class TileBrushManual : TileBrush
{
public override string Name { get; protected set; } = "Manual Brush";
public override ushort GetSpriteIndexToSet( TileMap tilemap, TilesetResource tileset, int x, int y )
{
if ( tileset == null )
return 0;
if ( tileset.Tiles == null || tileset.Tiles.Count == 0 )
return 0;
return (ushort)System.Math.Clamp( tileset.ManualTileIndex, 0, tileset.Tiles.Count - 1 );
}
public override (bool IsVisible, bool UseCollider) GetDefaultTileFlags( ushort spriteIndex )
{
return (true, true);
}
public override (TilesetResource Tileset, ushort TileId, bool IsVisible, bool UseCollider) GetTileToSet(
TileMap tilemap,
TilesetResource tileset,
TileMap.Tile existingTile,
int x,
int y,
bool isRefresh )
{
// If another autotile brush refreshes this manual tile as an affected neighbour,
// preserve the manually painted tile id and flags. Manual tiles do not derive
// their sprite from adjacency.
if ( isRefresh && !existingTile.IsEmpty )
return (tileset, existingTile.TileId, existingTile.IsVisible, existingTile.UseCollider);
return base.GetTileToSet( tilemap, tileset, existingTile, x, y, isRefresh );
}
protected override IEnumerable<Vector2Int> GetAffectedTiles( Vector2Int cell )
{
// Manual painting only changes the cells the user actually painted.
// No surrounding autotile refresh is needed.
yield break;
}
}
using Editor;
using Editor.Assets;
using Saandy.Tilemapper;
using Sandbox;
using static Editor.Inspectors.AssetInspector;
namespace Saandy.Editor.Tilemapper;
[CanEdit( "asset:tileset" )]
public sealed class TilesetResourceEditor : Widget, IAssetInspector
{
private Asset _asset;
private TilesetResource _tileset;
private ControlSheet _sheet;
private TilesetGlobalTagsWidget _globalTagsWidget;
private TilesetRectOrderWidget _rectWidget;
public TilesetResourceEditor( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 8;
Layout.Spacing = 8;
MinimumHeight = 1000;
MinimumWidth = 600;
VerticalSizeMode = SizeMode.CanGrow;
HorizontalSizeMode = SizeMode.Flexible;
}
public void SetAssetPreview( AssetPreview preview )
{
}
public void SetAsset( Asset asset )
{
_asset = asset;
_tileset = asset?.LoadResource<TilesetResource>();
Rebuild();
}
[EditorEvent.Hotload]
private void OnHotload()
{
if ( _asset != null )
_tileset = _asset.LoadResource<TilesetResource>();
Rebuild();
}
private void Rebuild()
{
if ( Layout == null )
return;
Layout.Clear( true );
if ( _tileset == null )
{
Layout.Add( new Label( "No TilesetResource loaded.", this ) );
return;
}
var title = new Label( "Tileset Editor", this );
title.SetStyles( "font-size: 22px; font-weight: 700; margin-bottom: 8px;" );
Layout.Add( title );
var brushTemplate = TileBrushEditorTemplates.Get( _tileset.BrushType );
var brushInfo = new Label(
brushTemplate != null
? $"{brushTemplate.Title}: {brushTemplate.Description}"
: "No brush template selected.",
this
);
brushInfo.WordWrap = true;
brushInfo.SetStyles( "color: #ddd; margin-bottom: 6px;" );
Layout.Add( brushInfo );
var serializedObject = _tileset.GetSerialized();
_sheet = new ControlSheet();
_sheet.AddObject( serializedObject, prop =>
{
if ( prop.HasAttribute<HideAttribute>() )
return false;
if ( prop.Name == nameof( TilesetResource.Tiles ) )
return false;
if ( prop.Name == nameof( TilesetResource.GlobalTags ) )
return false;
return prop.HasAttribute<PropertyAttribute>();
} );
serializedObject.OnPropertyChanged += prop =>
{
if ( prop != null && prop.Name == nameof( TilesetResource.FilePath ) )
{
_tileset.ClearTextureCache();
_rectWidget?.Refresh();
MarkChanged();
return;
}
if ( prop != null && prop.Name == nameof( TilesetResource.BrushType ) )
{
MarkChanged();
Rebuild();
return;
}
MarkChanged();
};
Layout.Add( _sheet );
_globalTagsWidget = new TilesetGlobalTagsWidget( this, _tileset, MarkChanged )
{
MinimumWidth = 560
};
Layout.Add( _globalTagsWidget );
_rectWidget = new TilesetRectOrderWidget( _tileset, brushTemplate, MarkChanged )
{
MinimumWidth = 560
};
var buttonRow = Layout.Add( Layout.Row() );
buttonRow.Spacing = 6;
var generateButton = new Button( "Generate Grid Rects", "grid_on", this );
generateButton.Clicked = () =>
{
_tileset.GenerateTilesFromGrid();
MarkChanged();
_rectWidget?.Refresh();
_rectWidget?.LoadTemplateFromCurrentOrder();
};
buttonRow.Add( generateButton );
var clearButton = new Button( "Clear Tiles", "delete", this );
clearButton.Clicked = () =>
{
_tileset.ClearTiles();
MarkChanged();
_rectWidget?.Refresh();
_rectWidget?.ClearTemplate();
};
buttonRow.Add( clearButton );
var rectActionRow = Layout.Add( Layout.Row() );
rectActionRow.Spacing = 6;
var addRectButton = new Button( "Add Rect", "add", this );
addRectButton.Clicked = () => _rectWidget?.AddPendingRect();
rectActionRow.Add( addRectButton );
var deleteTileButton = new Button( "Delete Selected Rect", "delete", this );
deleteTileButton.Clicked = () => _rectWidget?.DeleteSelectedTile();
rectActionRow.Add( deleteTileButton );
if ( _rectWidget.HasTemplate )
{
var loadOrderButton = new Button( "Load Current Order", "refresh", this );
loadOrderButton.Clicked = () => _rectWidget?.LoadTemplateFromCurrentOrder();
rectActionRow.Add( loadOrderButton );
var applyOrderButton = new Button( "Apply Template Order", "done", this );
applyOrderButton.Clicked = () => _rectWidget?.ApplyTemplateOrder();
rectActionRow.Add( applyOrderButton );
}
Layout.Add( _rectWidget );
Layout.AddStretchCell();
}
private void MarkChanged()
{
if ( _tileset == null )
return;
_tileset.InternalUpdateTiles();
_tileset.StateHasChanged();
SaveAsset();
Update();
}
private void SaveAsset()
{
if ( _asset == null || _tileset == null )
return;
_asset.SaveToDisk( _tileset );
}
}
using Editor;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Canvas;
// Same shape as ShaderGraph PreviewPanel: override PreFrame to advance the scene.
public class DesignerCanvas : SceneRenderingWidget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private readonly DesignerScene _designerScene;
public DesignerCanvas( Widget parent ) : base( parent )
{
Log.Info( $"{LogPrefix} DesignerCanvas ctor" );
_designerScene = new DesignerScene();
Scene = _designerScene.Scene;
Camera = _designerScene.Camera;
HorizontalSizeMode = SizeMode.Default | SizeMode.Expand;
VerticalSizeMode = SizeMode.Default | SizeMode.Expand;
// Without AcceptDrops, OnDragHover/OnDragDrop are never invoked.
AcceptDrops = true;
MouseTracking = true;
}
public DesignerScene DesignerScene => _designerScene;
// Assigned by DesignerWindow after construction (mirrors how _viewportFrame.Canvas is wired).
public OverlayController Overlay { get; set; }
public CanvasViewportFrame ViewportFrame { get; set; }
public event System.Action<Vector2, bool, Sandbox.KeyboardModifiers> CanvasClicked;
public event System.Action<Vector2, Sandbox.KeyboardModifiers> CanvasMoved;
public event System.Action<Vector2> CanvasReleased;
public event System.Action<Vector2> CanvasPanDragged; // delta, screen px
// Fires when the cursor leaves the canvas widget. Used to clear hover-pick state.
public event System.Action CanvasHoverEnded;
public event System.Action<ControlType, Vector2> RecordDropped;
// Mirrors RecordDropped but for saved palette templates.
public event System.Action<Grains.RazorDesigner.Templates.PaletteTemplate, Vector2> TemplateDropped;
private bool _middlePanning;
private Vector2 _lastPanScreen;
private const bool ProbeFrameCost = false;
private const int ProbeWindow = 120;
private readonly System.Diagnostics.Stopwatch _probeSw = new();
private double _probeTickMs;
private double _probeUpdateMs;
private int _probeFrames;
public override void OnDragHover( DragEvent ev )
{
base.OnDragHover( ev );
if ( ev.Data.Object is ControlType || ev.Data.Object is Grains.RazorDesigner.Templates.PaletteTemplate )
{
ev.Action = DropAction.Copy;
}
}
public override void OnDragDrop( DragEvent ev )
{
base.OnDragDrop( ev );
if ( ev.Data.Object is ControlType type )
{
Log.Info( $"{LogPrefix} DesignerCanvas drop: {type} at widget ({ev.LocalPosition.x:F0}, {ev.LocalPosition.y:F0})" );
RecordDropped?.Invoke( type, ev.LocalPosition );
}
else if ( ev.Data.Object is Grains.RazorDesigner.Templates.PaletteTemplate template )
{
Log.Info( $"{LogPrefix} DesignerCanvas drop: template \"{template.Name}\" at widget ({ev.LocalPosition.x:F0}, {ev.LocalPosition.y:F0})" );
TemplateDropped?.Invoke( template, ev.LocalPosition );
}
}
protected override void OnMousePress( MouseEvent e )
{
if ( e.MiddleMouseButton )
{
// Swallow entirely — don't let base (SceneRenderingWidget) see the middle drag.
_middlePanning = true;
_lastPanScreen = e.ScreenPosition;
e.Accepted = true;
return;
}
base.OnMousePress( e );
if ( e.LeftMouseButton || e.RightMouseButton )
{
var pos = e.LocalPosition;
Log.Info( $"{LogPrefix} DesignerCanvas click at widget ({pos.x:F0}, {pos.y:F0}) right={e.RightMouseButton}" );
CanvasClicked?.Invoke( pos, e.RightMouseButton, e.KeyboardModifiers );
}
}
protected override void OnMouseMove( MouseEvent e )
{
if ( _middlePanning )
{
var s = e.ScreenPosition;
CanvasPanDragged?.Invoke( s - _lastPanScreen );
_lastPanScreen = s;
e.Accepted = true;
return;
}
base.OnMouseMove( e );
CanvasMoved?.Invoke( e.LocalPosition, e.KeyboardModifiers );
}
protected override void OnMouseReleased( MouseEvent e )
{
if ( _middlePanning )
{
_middlePanning = false;
e.Accepted = true;
return;
}
base.OnMouseReleased( e );
CanvasReleased?.Invoke( e.LocalPosition );
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
CanvasHoverEnded?.Invoke();
}
protected override void PreFrame()
{
base.PreFrame();
if ( !_designerScene.Scene.IsValid() )
return;
if ( ProbeFrameCost ) _probeSw.Restart();
using ( _designerScene.Scene.Push() )
{
// EditorTick (not GameTick): preview scene, no SceneNetworkUpdate / fixed physics.
_designerScene.Scene.EditorTick( RealTime.Now, RealTime.Delta );
}
if ( ProbeFrameCost ) { _probeTickMs += _probeSw.Elapsed.TotalMilliseconds; _probeSw.Restart(); }
// Layout in framebuffer space (Size * DpiScale); DesignerScene sets Scale = DpiScale so authoring stays in logical px.
_designerScene.Update( Size.x, Size.y, DpiScale );
Overlay?.Tick( _designerScene, DpiScale );
if ( ProbeFrameCost )
{
_probeUpdateMs += _probeSw.Elapsed.TotalMilliseconds;
if ( ++_probeFrames >= ProbeWindow )
{
Log.Info( $"{LogPrefix} probe: EditorTick avg {_probeTickMs / _probeFrames:F3}ms | Update avg {_probeUpdateMs / _probeFrames:F3}ms (over {_probeFrames} frames, canvas {Size.x:F0}x{Size.y:F0})" );
_probeTickMs = 0;
_probeUpdateMs = 0;
_probeFrames = 0;
}
}
}
protected override void OnKeyPress( KeyEvent e )
{
base.OnKeyPress( e );
if ( ViewportFrame is not { CanPanZoom: true } frame ) return;
var mods = e.KeyboardModifiers;
var ctrl = (mods & Sandbox.KeyboardModifiers.Ctrl) != 0;
var shift = (mods & Sandbox.KeyboardModifiers.Shift) != 0;
var alt = (mods & Sandbox.KeyboardModifiers.Alt) != 0;
if ( !ctrl || alt ) return; // every shortcut is Ctrl-based; Alt clears it.
switch ( e.Key )
{
case KeyCode.Num0:
if ( shift )
{
Log.Info( $"{LogPrefix} OnKeyPress Ctrl+Shift+0 → ZoomToSelection" );
ZoomToSelectionViaFrame( frame );
}
else
{
Log.Info( $"{LogPrefix} OnKeyPress Ctrl+0 → ApplyFit" );
frame.ApplyFit();
}
e.Accepted = true;
break;
case KeyCode.Num1:
if ( shift ) return;
Log.Info( $"{LogPrefix} OnKeyPress Ctrl+1 → ResetZoomOnly" );
frame.ResetZoomOnly();
e.Accepted = true;
break;
case KeyCode.Equal:
if ( shift ) return;
frame.ZoomBy( 1.25f );
e.Accepted = true;
break;
case KeyCode.Minus:
if ( shift ) return;
frame.ZoomBy( 1f / 1.25f );
e.Accepted = true;
break;
}
}
private void ZoomToSelectionViaFrame( CanvasViewportFrame frame )
{
var record = frame.Selection?.Selected;
var live = record?.LivePanel;
if ( live is null || !live.IsValid )
{
Log.Info( $"{LogPrefix} Ctrl+Shift+0: no valid selection" );
return;
}
frame.ApplyZoomToRect( live.Box.Rect, /* maxZoom */ 4f, /* padding */ 0.10f );
}
public override void OnDestroyed()
{
Log.Info( $"{LogPrefix} DesignerCanvas.OnDestroyed" );
base.OnDestroyed();
_designerScene.Dispose();
}
}
using System;
using System.Linq;
using System.Reflection;
using Sandbox;
using Sandbox.UI;
namespace Grains.RazorDesigner.Canvas;
public sealed class DesignerScene : IDisposable
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public static readonly Color ArtboardTintColor = new( 0.137f, 0.165f, 0.200f ); // ~#232a33
public static readonly Color WorkspaceClearColor = new( 0.055f, 0.067f, 0.078f ); // ~#0e1114
public static readonly Color HalvesLineColor = new( 1f, 1f, 1f, 0.22f ); // halves major-line tint
// Chrome label tints (workspace ScreenPanel runs at Scale=dpiScale so font sizes etc. are widget-px).
public static readonly Color ChromeMutedColor = new( 0.541f, 0.573f, 0.643f ); // #8a92a4
public static readonly Color ChromeBrightColor = new( 0.812f, 0.835f, 0.886f ); // #cfd5e2
public static readonly Color ChromePadBgColor = new( 0.055f, 0.067f, 0.078f, 0.95f );
public Scene Scene { get; }
public CameraComponent Camera { get; }
public ScreenPanel ScreenPanel { get; }
public Panel Root => ScreenPanel.GetPanel();
public ScreenPanel WorkspaceScreenPanel { get; }
public Panel WorkspaceRoot => WorkspaceScreenPanel.GetPanel();
// Workspace ScreenPanel was added in the pivot — needs its own isolation + reflection plumbing.
private bool _workspaceLayoutIsolated;
private bool _workspaceRootLogged;
public System.Func<string> FilenameSource { get; set; }
private Panel _backdrop;
private bool _backdropProbeLogged;
private Panel _halfVertical;
private Panel _halfHorizontal;
private bool _halvesProbeLogged;
private Panel _grid;
private float _gridLastStepCss = -1f;
private Vector2? _gridLastViewport;
private bool _gridProbeLogged;
private const float MinGridStepWidgetPx = 3f; // grid hides below this widget-px step (density clamp)
private static readonly Color GridLineColor = new( 1f, 1f, 1f, 0.045f );
public bool GridShow { get; set; }
public float GridStepCss { get; set; } = 8f;
public ArtboardFillMode ArtboardFill { get; set; } = ArtboardFillMode.Dark;
public Color ArtboardCustomColor { get; set; } = new( 0.137f, 0.165f, 0.200f );
private Panel _workspaceFill;
private Sandbox.UI.Label _filenameHeader;
private Sandbox.UI.Label _widthLabel;
private Panel _widthLabelPad;
private Sandbox.UI.Label _heightLabel;
private Panel _heightLabelPad;
private bool _workspaceChromeProbeLogged;
public Vector2? ViewportLogical { get; set; }
public PreviewTargetMode PreviewTarget { get; set; } = PreviewTargetMode.None;
public float Zoom { get; set; } = 1f;
public Vector2 PanOffsetWidgetPx { get; set; } = Vector2.Zero;
public float CurrentScale { get; private set; } = 1f;
public Rect RootBoundsFb { get; private set; }
private bool _rootLogged;
private bool _layoutIsolated;
private bool _reflectionLogged;
private MethodInfo _rootLayoutMethod;
private MethodInfo _rootBuildDescriptorsMethod;
private PropertyInfo _rootScaleProperty;
private PropertyInfo _rootPanelBoundsProperty;
public DesignerScene()
{
Log.Info( $"{LogPrefix} DesignerScene ctor (CreateEditorScene + EditorTick + two ScreenPanels)" );
Scene = Scene.CreateEditorScene();
Scene.Name = "Razor Designer";
using ( Scene.Push() )
{
var cameraGo = new GameObject( true, "camera" );
Camera = cameraGo.AddComponent<CameraComponent>();
Camera.BackgroundColor = WorkspaceClearColor;
Camera.IsMainCamera = false;
// Order matters: register Workspace first so it renders BEHIND the artboard.
var workspaceGo = new GameObject( true, "workspace-ui" );
WorkspaceScreenPanel = workspaceGo.AddComponent<ScreenPanel>();
WorkspaceScreenPanel.TargetCamera = Camera;
ForceLifecycle( WorkspaceScreenPanel );
var uiGo = new GameObject( true, "artboard-ui" );
ScreenPanel = uiGo.AddComponent<ScreenPanel>();
ScreenPanel.TargetCamera = Camera;
ForceLifecycle( ScreenPanel );
}
}
private static void ForceLifecycle( Component component )
{
var type = component.GetType();
const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
var awake = type.GetMethod( "OnAwake", flags );
var enabled = type.GetMethod( "OnEnabled", flags );
try
{
awake?.Invoke( component, null );
enabled?.Invoke( component, null );
Log.Info( $"{LogPrefix} ForceLifecycle({type.Name}) ok awake={awake != null} enabled={enabled != null}" );
}
catch ( System.Exception ex )
{
Log.Error( $"{LogPrefix} ForceLifecycle({type.Name}) threw: {ex.GetType().Name}: {ex.Message}" );
throw;
}
}
// Call BEFORE Scene.Destroy(); component needs a valid scene to tear down.
private static void ForceTeardown( Component component )
{
if ( component is null ) return;
var type = component.GetType();
const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
var disabled = type.GetMethod( "OnDisabled", flags );
var destroy = type.GetMethod( "OnDestroy", flags );
try
{
disabled?.Invoke( component, null );
destroy?.Invoke( component, null );
Log.Info( $"{LogPrefix} ForceTeardown({type.Name}) ok disabled={disabled != null} destroy={destroy != null}" );
}
catch ( System.Exception ex )
{
Log.Error( $"{LogPrefix} ForceTeardown({type.Name}) threw: {ex.GetType().Name}: {ex.Message}" );
}
}
private static bool TryIsolateRootFromMenuPump( Panel root )
{
if ( root is null ) return false;
try
{
var globalContextType = AppDomain.CurrentDomain.GetAssemblies()
.Select( a => SafeGetType( a, "Sandbox.Engine.GlobalContext" ) )
.FirstOrDefault( t => t is not null );
if ( globalContextType is null )
{
Log.Warning( $"{LogPrefix} IsolateRoot: GlobalContext type not found" );
return false;
}
var current = globalContextType
.GetProperty( "Current", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static )
?.GetValue( null );
if ( current is null )
{
Log.Warning( $"{LogPrefix} IsolateRoot: GlobalContext.Current is null" );
return false;
}
var uiSystem = current.GetType()
.GetProperty( "UISystem", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance )
?.GetValue( current );
if ( uiSystem is null )
{
Log.Warning( $"{LogPrefix} IsolateRoot: GlobalContext.UISystem is null" );
return false;
}
var removeRoot = uiSystem.GetType().GetMethod(
"RemoveRoot", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance );
if ( removeRoot is null )
{
Log.Warning( $"{LogPrefix} IsolateRoot: UISystem.RemoveRoot not found" );
return false;
}
removeRoot.Invoke( uiSystem, new object[] { root } );
Log.Info( $"{LogPrefix} IsolateRoot: removed from UISystem.RootPanels (engine layout pump will skip it)" );
return true;
}
catch ( Exception ex )
{
Log.Warning( $"{LogPrefix} IsolateRoot threw: {ex.GetType().Name}: {ex.Message}" );
return false;
}
}
private static Type SafeGetType( Assembly a, string fullName )
{
try { return a.GetType( fullName, throwOnError: false ); }
catch { return null; }
}
private void DriveLayout( Panel root, float widthPx, float heightPx, float dpiScale )
{
if ( widthPx < 1f || heightPx < 1f ) return;
if ( dpiScale < 0.01f ) dpiScale = 1.0f;
var pinned = PreviewTarget.IsPinned() ? PreviewTarget.PinnedScale() : 0f;
var (bounds, scale, _) = ComputeView( new Vector2( widthPx, heightPx ), dpiScale, ViewportLogical, pinned, Zoom, PanOffsetWidgetPx );
CurrentScale = scale;
RootBoundsFb = bounds;
var rootType = root.GetType();
EnsureReflectionResolved( rootType );
_rootPanelBoundsProperty?.SetValue( root, bounds );
_rootScaleProperty?.SetValue( root, scale );
try
{
_rootLayoutMethod?.Invoke( root, null );
_rootBuildDescriptorsMethod?.Invoke( root, new object[] { 1.0f } );
}
catch ( Exception ex )
{
Log.Error( $"{LogPrefix} DriveLayout threw: {ex.GetType().Name}: {ex.Message}" );
}
}
public static (Rect Bounds, float Scale, Vector2 ClampedPanWidgetPx) ComputeView(
Vector2 widgetSize, float dpiScale, Vector2? viewportLogical, float pinnedScale, float zoom, Vector2 panOffsetWidgetPx )
{
if ( dpiScale < 0.01f ) dpiScale = 1f;
var fbW = widgetSize.x * dpiScale;
var fbH = widgetSize.y * dpiScale;
if ( fbW < 1f || fbH < 1f ) return ( new Rect( 0, 0, MathF.Max( fbW, 1f ), MathF.Max( fbH, 1f ) ), dpiScale, Vector2.Zero );
if ( !(viewportLogical is { } vp && vp.x >= 1f && vp.y >= 1f) )
return ( new Rect( 0, 0, fbW, fbH ), dpiScale, Vector2.Zero );
var baseScale = pinnedScale > 0.001f
? pinnedScale // grd-z71: 1 logical px == pinnedScale framebuffer px
: MathF.Min( fbW / vp.x, fbH / vp.y ); // scale-to-fit on the smaller axis
if ( zoom < 0.001f ) zoom = 1f;
var scale = baseScale * zoom;
if ( scale < 0.001f ) scale = dpiScale;
var panelW = vp.x * scale;
var panelH = vp.y * scale;
var panFb = panOffsetWidgetPx * dpiScale;
var centeredX = (fbW - panelW) * 0.5f;
var centeredY = (fbH - panelH) * 0.5f;
var keepX = MathF.Min( 80f * dpiScale, MathF.Min( panelW, fbW ) );
var keepY = MathF.Min( 80f * dpiScale, MathF.Min( panelH, fbH ) );
var x = Math.Clamp( centeredX + panFb.x, keepX - panelW, fbW - keepX );
var y = Math.Clamp( centeredY + panFb.y, keepY - panelH, fbH - keepY );
var bounds = new Rect( MathF.Floor( x ), MathF.Floor( y ), MathF.Floor( panelW ), MathF.Floor( panelH ) );
return ( bounds, scale, new Vector2( x - centeredX, y - centeredY ) / dpiScale );
}
private void EnsureReflectionResolved( Type rootType )
{
_rootLayoutMethod ??= ResolveMethod( rootType, "Layout", Type.EmptyTypes );
_rootBuildDescriptorsMethod ??= ResolveMethod( rootType, "BuildDescriptors", new[] { typeof( float ) } );
_rootScaleProperty ??= ResolveProperty( rootType, "Scale", typeof( float ) );
_rootPanelBoundsProperty ??= ResolveProperty( rootType, "PanelBounds", typeof( Rect ) );
if ( !_reflectionLogged )
{
Log.Info( $"{LogPrefix} DriveLayout reflection resolved: " +
$"Layout={_rootLayoutMethod is not null} " +
$"BuildDescriptors={_rootBuildDescriptorsMethod is not null} " +
$"Scale={_rootScaleProperty is not null} " +
$"PanelBounds={_rootPanelBoundsProperty is not null}" );
_reflectionLogged = true;
}
}
// Walk up: methods we want are on RootPanel base, not concrete GameRootPanel.
private static MethodInfo ResolveMethod( Type startType, string name, Type[] paramTypes )
{
for ( var t = startType; t is not null; t = t.BaseType )
{
var method = t.GetMethod( name,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
binder: null,
types: paramTypes,
modifiers: null );
if ( method is not null ) return method;
}
return null;
}
private static PropertyInfo ResolveProperty( Type startType, string name, Type expectedType )
{
for ( var t = startType; t is not null; t = t.BaseType )
{
var prop = t.GetProperty( name,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance );
if ( prop is not null && prop.PropertyType == expectedType )
return prop;
}
return null;
}
public void Update( float widthPx, float heightPx, float dpiScale )
{
var root = Root;
var workspaceRoot = WorkspaceRoot;
if ( !root.IsValid() ) return;
if ( !_layoutIsolated )
{
_layoutIsolated = TryIsolateRootFromMenuPump( root );
}
if ( workspaceRoot.IsValid() && !_workspaceLayoutIsolated )
{
_workspaceLayoutIsolated = TryIsolateRootFromMenuPump( workspaceRoot );
}
if ( !_rootLogged )
{
Log.Info( $"{LogPrefix} root valid (width={widthPx} height={heightPx} dpiScale={dpiScale})" );
_rootLogged = true;
}
if ( workspaceRoot.IsValid() && !_workspaceRootLogged )
{
Log.Info( $"{LogPrefix} workspace root valid (width={widthPx} height={heightPx} dpiScale={dpiScale})" );
_workspaceRootLogged = true;
}
EnsureBackdrop( root );
EnsureHalves( root );
if ( workspaceRoot.IsValid() ) EnsureWorkspaceChrome( workspaceRoot );
DriveLayout( root, widthPx, heightPx, dpiScale );
if ( workspaceRoot.IsValid() ) DriveWorkspaceLayout( workspaceRoot, widthPx, heightPx, dpiScale );
EnsureGrid( root, dpiScale );
// Position chrome AFTER DriveLayout so RootBoundsFb is current for this frame.
UpdateWorkspaceChromePositions( widthPx, heightPx, dpiScale );
}
private void EnsureBackdrop( Panel root )
{
if ( _backdrop is null || !_backdrop.IsValid || _backdrop.Parent != root )
{
_backdrop = new Panel( root );
_appliedFill = (ArtboardFillMode)(-1);
_backdrop.AddClass( "designer-backdrop" );
_backdrop.Style.Position = PositionMode.Absolute;
_backdrop.Style.Left = Length.Pixels( 0 );
_backdrop.Style.Top = Length.Pixels( 0 );
_backdrop.Style.Width = Length.Percent( 100 );
_backdrop.Style.Height = Length.Percent( 100 );
_backdrop.Style.ZIndex = -1000;
_backdrop.Style.PointerEvents = PointerEvents.None;
try { root.SetChildIndex( _backdrop, 0 ); }
catch ( Exception ex ) { Log.Warning( $"{LogPrefix} EnsureBackdrop SetChildIndex threw: {ex.GetType().Name}: {ex.Message}" ); }
if ( !_backdropProbeLogged )
{
Log.Info( $"{LogPrefix} EnsureBackdrop: backdrop installed (fill={ArtboardFill.DisplayLabel()}, box-shadow DISABLED for triage, target=Root, zIndex=-1000)" );
_backdropProbeLogged = true;
}
}
ApplyArtboardFill( _backdrop );
}
private ArtboardFillMode _appliedFill = (ArtboardFillMode)(-1);
private Color _appliedCustom = Color.Black;
private void ApplyArtboardFill( Panel backdrop )
{
var mode = ArtboardFill;
var custom = ArtboardCustomColor;
if ( mode == _appliedFill && (mode != ArtboardFillMode.Custom || custom == _appliedCustom) )
return;
_appliedFill = mode;
_appliedCustom = custom;
if ( mode == ArtboardFillMode.Checker )
{
backdrop.Style.BackgroundColor = Color.White;
backdrop.Style.BackgroundImage = GetOrCreateCheckerTexture();
}
else
{
// Strip the texture if we're switching out of Checker, then apply the flat color.
backdrop.Style.BackgroundImage = null;
var color = mode == ArtboardFillMode.Custom ? custom : mode.PresetColor();
backdrop.Style.BackgroundColor = color;
}
Log.Info( $"{LogPrefix} ArtboardFill applied: {mode.DisplayLabel()}{(mode == ArtboardFillMode.Custom ? $" ({custom.Hex})" : "")}" );
}
private static Texture _checkerTexture;
private static Texture GetOrCreateCheckerTexture()
{
if ( _checkerTexture is not null && _checkerTexture.IsLoaded ) return _checkerTexture;
const int size = 32;
const int half = size / 2;
// Two-tone cool gray. Same dark family as the Dark preset, just split into two tones.
var darkRgb = new byte[] { 42, 45, 51 }; // ~#2a2d33
var lightRgb = new byte[] { 54, 58, 66 }; // ~#363a42
var data = new byte[size * size * 4];
for ( int y = 0; y < size; y++ )
for ( int x = 0; x < size; x++ )
{
var quadrant = (x < half) ^ (y < half);
var rgb = quadrant ? lightRgb : darkRgb;
int i = (y * size + x) * 4;
data[i + 0] = rgb[0];
data[i + 1] = rgb[1];
data[i + 2] = rgb[2];
data[i + 3] = 255;
}
_checkerTexture = Texture.Create( size, size, ImageFormat.RGBA8888 )
.WithName( "designer-artboard-checker" )
.WithData( data )
.Finish();
Log.Info( $"[Grains.RazorDesigner] GetOrCreateCheckerTexture: built {size}x{size} two-tone tile" );
return _checkerTexture;
}
private void EnsureHalves( Panel root )
{
EnsureHalf( ref _halfVertical, root, "designer-halves-v", isVertical: true );
EnsureHalf( ref _halfHorizontal, root, "designer-halves-h", isVertical: false );
var visible = GridShow;
var display = visible ? DisplayMode.Flex : DisplayMode.None;
if ( _halfVertical is not null && _halfVertical.IsValid ) _halfVertical.Style.Display = display;
if ( _halfHorizontal is not null && _halfHorizontal.IsValid ) _halfHorizontal.Style.Display = display;
if ( !_halvesProbeLogged )
{
Log.Info( $"{LogPrefix} EnsureHalves: halves lines installed (zIndex=-999, alpha=0.22, visible={visible})" );
_halvesProbeLogged = true;
}
}
private void EnsureHalf( ref Panel slot, Panel root, string className, bool isVertical )
{
if ( slot is not null && slot.IsValid && slot.Parent == root )
return;
slot = new Panel( root );
slot.AddClass( className );
var s = slot.Style;
s.Position = PositionMode.Absolute;
s.BackgroundColor = HalvesLineColor;
s.ZIndex = -999;
s.PointerEvents = PointerEvents.None;
if ( isVertical )
{
s.Left = Length.Percent( 50 );
s.Top = Length.Pixels( 0 );
s.Width = Length.Pixels( 1 );
s.Height = Length.Percent( 100 );
}
else
{
s.Left = Length.Pixels( 0 );
s.Top = Length.Percent( 50 );
s.Width = Length.Percent( 100 );
s.Height = Length.Pixels( 1 );
}
}
private void EnsureGrid( Panel root, float dpiScale )
{
if ( dpiScale < 0.01f ) dpiScale = 1f;
// Density clamp: stepWidget = StepCss * (fb-per-css) / (fb-per-widget) = widget-per-css * StepCss.
var stepWidget = GridStepCss * CurrentScale / dpiScale;
var viewport = ViewportLogical;
var shouldShow = GridShow
&& GridStepCss > 0.001f
&& stepWidget >= MinGridStepWidgetPx
&& viewport is { } vpCheck && vpCheck.x >= 1f && vpCheck.y >= 1f;
if ( !shouldShow )
{
if ( _grid is { IsValid: true } ) _grid.Style.Set( "display", "none" );
return;
}
// Lazy-create container (self-healing — covers Repopulate's DeleteChildren wipe).
if ( _grid is null || !_grid.IsValid || _grid.Parent != root )
{
_grid = new Panel( root );
_grid.AddClass( "designer-grid" );
var s = _grid.Style;
s.Position = PositionMode.Absolute;
s.Left = Length.Pixels( 0 );
s.Top = Length.Pixels( 0 );
s.Width = Length.Percent( 100 );
s.Height = Length.Percent( 100 );
s.PointerEvents = PointerEvents.None;
s.ZIndex = -998; // above halves (-999), below user content (0)
_gridLastStepCss = -1f; // force line rebuild on next pass
_gridLastViewport = null;
if ( !_gridProbeLogged )
{
Log.Info( $"{LogPrefix} EnsureGrid: grid container installed (zIndex=-998, thin-panel lines)" );
_gridProbeLogged = true;
}
}
// Rebuild line children only when step or viewport changes.
var vp = viewport.Value;
if ( MathF.Abs( GridStepCss - _gridLastStepCss ) > 0.01f || _gridLastViewport != vp )
{
_grid.DeleteChildren( immediate: true );
var step = GridStepCss;
var midX = vp.x * 0.5f;
var midY = vp.y * 0.5f;
int linesCreated = 0;
int halfStepsX = (int)MathF.Floor( midX / step );
float startX = midX - halfStepsX * step;
for ( float x = startX; x < vp.x - 0.5f; x += step )
{
if ( x < 0.5f ) continue;
if ( MathF.Abs( x - midX ) < 0.5f ) continue; // skip halves overlap
var line = new Panel( _grid );
var ls = line.Style;
ls.Position = PositionMode.Absolute;
ls.Left = Length.Pixels( x );
ls.Top = Length.Pixels( 0 );
ls.Width = Length.Pixels( 1 );
ls.Height = Length.Percent( 100 );
ls.BackgroundColor = GridLineColor;
ls.PointerEvents = PointerEvents.None;
linesCreated++;
}
int halfStepsY = (int)MathF.Floor( midY / step );
float startY = midY - halfStepsY * step;
for ( float y = startY; y < vp.y - 0.5f; y += step )
{
if ( y < 0.5f ) continue;
if ( MathF.Abs( y - midY ) < 0.5f ) continue; // skip halves overlap
var line = new Panel( _grid );
var ls = line.Style;
ls.Position = PositionMode.Absolute;
ls.Left = Length.Pixels( 0 );
ls.Top = Length.Pixels( y );
ls.Width = Length.Percent( 100 );
ls.Height = Length.Pixels( 1 );
ls.BackgroundColor = GridLineColor;
ls.PointerEvents = PointerEvents.None;
linesCreated++;
}
_gridLastStepCss = GridStepCss;
_gridLastViewport = vp;
Log.Info( $"{LogPrefix} EnsureGrid: rebuilt {linesCreated} line panels (step={step:F1} CSS, vp={vp.x:F0}x{vp.y:F0}, centered)" );
}
_grid.Style.Set( "display", "flex" );
}
private void DriveWorkspaceLayout( Panel root, float widthPx, float heightPx, float dpiScale )
{
if ( widthPx < 1f || heightPx < 1f ) return;
if ( dpiScale < 0.01f ) dpiScale = 1.0f;
var fbW = widthPx * dpiScale;
var fbH = heightPx * dpiScale;
var fullFb = new Rect( 0, 0, fbW, fbH );
var rootType = root.GetType();
EnsureReflectionResolved( rootType );
_rootPanelBoundsProperty?.SetValue( root, fullFb );
_rootScaleProperty?.SetValue( root, dpiScale );
try
{
_rootLayoutMethod?.Invoke( root, null );
_rootBuildDescriptorsMethod?.Invoke( root, new object[] { 1.0f } );
}
catch ( Exception ex )
{
Log.Error( $"{LogPrefix} DriveWorkspaceLayout threw: {ex.GetType().Name}: {ex.Message}" );
}
}
private void EnsureWorkspaceChrome( Panel root )
{
EnsureWorkspaceFill( root );
EnsureFilenameHeader( root );
EnsureWidthLabel( root );
EnsureHeightLabel( root );
if ( !_workspaceChromeProbeLogged )
{
Log.Info( $"{LogPrefix} EnsureWorkspaceChrome: fill + header + width + height labels installed under WorkspaceRoot" );
_workspaceChromeProbeLogged = true;
}
}
private void EnsureWorkspaceFill( Panel root )
{
if ( _workspaceFill is not null && _workspaceFill.IsValid && _workspaceFill.Parent == root ) return;
_workspaceFill = new Panel( root );
_workspaceFill.AddClass( "designer-workspace-fill" );
var s = _workspaceFill.Style;
s.Position = PositionMode.Absolute;
s.Left = Length.Pixels( 0 );
s.Top = Length.Pixels( 0 );
s.Width = Length.Percent( 100 );
s.Height = Length.Percent( 100 );
s.BackgroundColor = WorkspaceClearColor;
s.ZIndex = -2000;
s.PointerEvents = PointerEvents.None;
s.SetBackgroundImage( GetOrCreateDotTexture() );
// Put it first in the sibling list so chrome labels paint on top.
try { root.SetChildIndex( _workspaceFill, 0 ); }
catch ( Exception ex ) { Log.Warning( $"{LogPrefix} EnsureWorkspaceFill SetChildIndex threw: {ex.GetType().Name}: {ex.Message}" ); }
}
private static Texture _dotTexture;
private static Texture GetOrCreateDotTexture()
{
if ( _dotTexture is not null && _dotTexture.IsLoaded ) return _dotTexture;
const int size = 28;
const int centre = size / 2;
var data = new byte[size * size * 4]; // RGBA8888
for ( int y = 0; y < size; y++ )
for ( int x = 0; x < size; x++ )
{
var dx = x - centre;
var dy = y - centre;
var d2 = dx * dx + dy * dy;
byte alpha = 0;
if ( d2 == 0 ) alpha = 18; // ~0.07 — exact centre
else if ( d2 == 1 ) alpha = 10; // ~0.04 — 4-neighbours
else if ( d2 == 2 ) alpha = 4; // ~0.015 — diagonals
int i = (y * size + x) * 4;
data[i + 0] = 255; // R
data[i + 1] = 255; // G
data[i + 2] = 255; // B
data[i + 3] = alpha;
}
_dotTexture = Texture.Create( size, size, ImageFormat.RGBA8888 )
.WithName( "designer-workspace-dot" )
.WithData( data )
.Finish();
Log.Info( $"[Grains.RazorDesigner] GetOrCreateDotTexture: built {size}x{size} dot tile (RGBA8888, {data.Length} bytes)" );
return _dotTexture;
}
private void EnsureFilenameHeader( Panel root )
{
if ( _filenameHeader is not null && _filenameHeader.IsValid && _filenameHeader.Parent == root ) return;
_filenameHeader = root.AddChild<Sandbox.UI.Label>();
_filenameHeader.AddClass( "designer-filename-header" );
var s = _filenameHeader.Style;
s.Position = PositionMode.Absolute;
s.FontColor = ChromeBrightColor; // Sandbox.UI Label can't easily mix colors in one widget; pick the brighter tint.
s.FontSize = 11f;
s.PointerEvents = PointerEvents.None;
s.Set( "white-space", "nowrap" );
s.Set( "display", "none" ); // hidden until UpdateWorkspaceChromePositions decides to show
}
private void EnsureWidthLabel( Panel root )
{
if ( _widthLabelPad is not null && _widthLabelPad.IsValid && _widthLabelPad.Parent == root ) return;
// Pad panel gives the label a subtle dark background pad (legibility over the dot pattern).
_widthLabelPad = new Panel( root );
_widthLabelPad.AddClass( "designer-dim-pad" );
var ps = _widthLabelPad.Style;
ps.Position = PositionMode.Absolute;
ps.BackgroundColor = ChromePadBgColor;
ps.PointerEvents = PointerEvents.None;
ps.Set( "padding", "1px 6px" );
ps.Set( "border-radius", "3px" );
ps.Set( "display", "none" );
_widthLabel = _widthLabelPad.AddChild<Sandbox.UI.Label>();
_widthLabel.AddClass( "designer-width-label" );
var ls = _widthLabel.Style;
ls.FontColor = ChromeMutedColor;
ls.FontSize = 10f;
ls.PointerEvents = PointerEvents.None;
ls.Set( "white-space", "nowrap" );
}
private void EnsureHeightLabel( Panel root )
{
if ( _heightLabelPad is not null && _heightLabelPad.IsValid && _heightLabelPad.Parent == root ) return;
_heightLabelPad = new Panel( root );
_heightLabelPad.AddClass( "designer-dim-pad" );
var ps = _heightLabelPad.Style;
ps.Position = PositionMode.Absolute;
ps.BackgroundColor = ChromePadBgColor;
ps.PointerEvents = PointerEvents.None;
ps.Set( "padding", "4px 2px" );
ps.Set( "border-radius", "3px" );
ps.Set( "display", "none" );
_heightLabel = _heightLabelPad.AddChild<Sandbox.UI.Label>();
_heightLabel.AddClass( "designer-height-label" );
var ls = _heightLabel.Style;
ls.FontColor = ChromeMutedColor;
ls.FontSize = 10f;
ls.PointerEvents = PointerEvents.None;
ls.Set( "white-space", "nowrap" );
ls.Set( "text-align", "center" );
}
private string _filenameLastText;
private string _widthLastText;
private string _heightLastText;
private bool _chromeVisibleLast;
private void UpdateWorkspaceChromePositions( float widthPx, float heightPx, float dpiScale )
{
if ( dpiScale < 0.01f ) dpiScale = 1f;
var shouldShow = ViewportLogical is { } vpCheck && vpCheck.x >= 1f && vpCheck.y >= 1f;
if ( !shouldShow )
{
if ( _chromeVisibleLast )
{
if ( _filenameHeader is { IsValid: true } ) _filenameHeader.Style.Set( "display", "none" );
if ( _widthLabelPad is { IsValid: true } ) _widthLabelPad.Style.Set( "display", "none" );
if ( _heightLabelPad is { IsValid: true } ) _heightLabelPad.Style.Set( "display", "none" );
_chromeVisibleLast = false;
}
return;
}
var vp = ViewportLogical.Value;
var artLeft = RootBoundsFb.Left / dpiScale;
var artTop = RootBoundsFb.Top / dpiScale;
var artRight = RootBoundsFb.Right / dpiScale;
var artBottom = RootBoundsFb.Bottom / dpiScale;
var artCenterX = (artLeft + artRight) * 0.5f;
var artCenterY = (artTop + artBottom) * 0.5f;
if ( _filenameHeader is { IsValid: true } )
{
var fname = FilenameSource?.Invoke() ?? "Untitled";
var newText = $"{fname} · {vp.x:F0}×{vp.y:F0}";
if ( _filenameLastText != newText )
{
_filenameHeader.Text = newText;
_filenameLastText = newText;
}
var fs = _filenameHeader.Style;
fs.Left = artLeft;
fs.Top = MathF.Max( 4f, artTop - 20f );
if ( !_chromeVisibleLast ) fs.Set( "display", "flex" );
}
// Width label — centered above the artboard's top edge.
if ( _widthLabelPad is { IsValid: true } && _widthLabel is { IsValid: true } )
{
var newText = $"{vp.x:F0}";
if ( _widthLastText != newText )
{
_widthLabel.Text = newText;
_widthLastText = newText;
}
var ps = _widthLabelPad.Style;
ps.Left = artCenterX;
ps.Top = MathF.Max( 4f, artTop - 18f );
ps.Set( "transform", "translateX(-50%)" );
if ( !_chromeVisibleLast ) ps.Set( "display", "flex" );
}
// Height label — centered on the artboard's right edge.
if ( _heightLabelPad is { IsValid: true } && _heightLabel is { IsValid: true } )
{
var newRaw = $"{vp.y:F0}";
if ( _heightLastText != newRaw )
{
_heightLabel.Text = string.Join( "\n", newRaw.ToCharArray() );
_heightLastText = newRaw;
}
var ps = _heightLabelPad.Style;
ps.Left = artRight + 8f;
ps.Top = artCenterY;
ps.Set( "transform", "translateY(-50%)" );
if ( !_chromeVisibleLast ) ps.Set( "display", "flex" );
}
_chromeVisibleLast = true;
}
public void Dispose()
{
Log.Info( $"{LogPrefix} DesignerScene.Dispose" );
ForceTeardown( ScreenPanel );
ForceTeardown( WorkspaceScreenPanel );
Scene?.Destroy();
}
}
using System;
using Editor;
using Grains.RazorDesigner.Selection;
using Sandbox;
namespace Grains.RazorDesigner.Canvas;
public sealed class ZoomHud : Widget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
// Visual tuning. All in widget px.
private const float Inset = 12f;
private const float HudHeight = 26f;
private const float CornerRadius = 5f;
private const int FontSizePx = 12;
private static readonly Color BgColor = new( 0.078f, 0.086f, 0.110f, 0.92f ); // ~ rgba(20,22,28,0.92)
private static readonly Color BorderColor = new( 0.227f, 0.255f, 0.314f, 1f ); // ~ #3a4150
private static readonly Color DividerColor = new( 0.165f, 0.180f, 0.220f, 1f ); // ~ #2a2e38
private static readonly Color TextColor = new( 0.878f, 0.894f, 0.918f, 1f ); // ~ #e0e3ea
private static readonly Color PctColor = new( 0.667f, 0.698f, 0.769f, 1f ); // ~ #aab2c4
private static readonly Color IconColor = new( 0.541f, 0.573f, 0.643f, 1f ); // ~ #8a92a4
private static readonly Color HoverOverlay = new( 1f, 1f, 1f, 0.05f );
private static readonly Color DisabledFg = new( 1f, 1f, 1f, 0.25f );
private readonly CanvasViewportFrame _frame;
private SelectionController CurrentSelection => _frame.Selection;
// Six segments, ordered left-to-right.
private enum Segment { ZoomOut, Percent, ZoomIn, Fit, Reset, Selection }
private static readonly string[] SegmentLabels = { "−", "100%", "+", "Fit", "1:1", "Sel" };
private static readonly string[] SegmentTooltips =
{
"Zoom out (Ctrl+−)",
"",
"Zoom in (Ctrl+=)",
"Fit content (Ctrl+0)",
"Reset to 100% (Ctrl+1)",
"Zoom to selection (Ctrl+Shift+0)",
};
private int _hoveredSegment = -1;
private float[] _segmentWidths;
private DesignerCanvas _canvas;
private int _lastGeometryHash = -1;
public ZoomHud( CanvasViewportFrame parent, DesignerCanvas canvas ) : base( parent )
{
_frame = parent ?? throw new ArgumentNullException( nameof( parent ) );
_canvas = canvas ?? throw new ArgumentNullException( nameof( canvas ) );
// Editor-side widgets do not draw at all by default; opt in.
TranslucentBackground = true;
NoSystemBackground = true;
WindowFlags = WindowFlags.FramelessWindowHint | WindowFlags.Tool;
MouseTracking = true;
// Subscribe to view changes — refresh on every push.
_frame.ZoomChanged += OnZoomChanged;
Log.Info( $"{LogPrefix} ZoomHud ctor (top-level frameless window)" );
}
public override void OnDestroyed()
{
_frame.ZoomChanged -= OnZoomChanged;
base.OnDestroyed();
}
[EditorEvent.Frame]
private void TrackCanvas()
{
if ( !_canvas.IsValid() ) { Visible = false; return; }
var shouldShow = _canvas.Visible && _frame.CanPanZoom;
if ( Visible != shouldShow ) { Visible = shouldShow; if ( !shouldShow ) return; }
if ( !shouldShow ) return;
EnsureWidthsMeasured();
var pref = PreferredSize;
var canvasScreen = _canvas.ScreenPosition;
var canvasSize = _canvas.Size;
var hash = HashCode.Combine( canvasScreen, canvasSize, pref );
if ( hash == _lastGeometryHash ) return;
_lastGeometryHash = hash;
Size = pref;
Position = new Vector2(
canvasScreen.x + canvasSize.x - pref.x - 12f,
canvasScreen.y + canvasSize.y - pref.y - 12f );
Update();
}
private void OnZoomChanged()
{
Update(); // schedule repaint; the % label re-renders from frame.Zoom.
}
public Vector2 PreferredSize
{
get
{
EnsureWidthsMeasured();
float total = 0f;
foreach ( var w in _segmentWidths ) total += w;
return new Vector2( total, HudHeight );
}
}
private void EnsureWidthsMeasured()
{
if ( _segmentWidths is not null ) return;
_segmentWidths = new float[ SegmentLabels.Length ];
Paint.SetDefaultFont( FontSizePx );
for ( var i = 0; i < SegmentLabels.Length; i++ )
{
var text = i == (int)Segment.Percent ? "1000%" : SegmentLabels[i];
var w = Paint.MeasureText( text ).x;
_segmentWidths[i] = MathF.Max( 28f, w + 18f ); // 9px padding each side
}
}
protected override void OnPaint()
{
EnsureWidthsMeasured();
var rect = LocalRect;
// Background + border.
Paint.SetBrush( BgColor );
Paint.SetPen( BorderColor );
Paint.DrawRect( rect, CornerRadius );
// Segments.
float x = rect.Left;
for ( var i = 0; i < SegmentLabels.Length; i++ )
{
var w = _segmentWidths[i];
var segRect = new Rect( x, rect.Top, w, rect.Height );
// Hover background. Don't highlight disabled Sel segment.
if ( _hoveredSegment == i && IsSegmentEnabled( (Segment)i ) )
{
Paint.ClearPen();
Paint.SetBrush( HoverOverlay );
Paint.DrawRect( segRect, 0f );
}
// Divider (between segments).
if ( i > 0 )
{
Paint.SetPen( DividerColor );
Paint.DrawLine( new Vector2( x, rect.Top + 4f ), new Vector2( x, rect.Bottom - 4f ) );
}
// Label.
var enabled = IsSegmentEnabled( (Segment)i );
Paint.SetDefaultFont( FontSizePx );
Paint.SetPen( ColorFor( (Segment)i, enabled ) );
var text = (Segment)i == Segment.Percent ? CurrentPercentText() : SegmentLabels[i];
Paint.DrawText( segRect, text, TextFlag.Center );
x += w;
}
}
private string CurrentPercentText()
{
var pct = (int)MathF.Round( _frame.Zoom * 100f );
return pct + "%";
}
private bool IsSegmentEnabled( Segment seg ) =>
seg != Segment.Selection || CurrentSelection?.Selected is not null;
private Color ColorFor( Segment seg, bool enabled )
{
if ( !enabled ) return DisabledFg;
return seg switch
{
Segment.ZoomOut or Segment.ZoomIn => IconColor,
Segment.Percent => PctColor,
_ => TextColor,
};
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
var newHover = SegmentAt( e.LocalPosition );
if ( newHover != _hoveredSegment )
{
_hoveredSegment = newHover;
Update();
}
if ( _hoveredSegment >= 0 )
ToolTip = SegmentTooltips[_hoveredSegment];
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
if ( _hoveredSegment != -1 )
{
_hoveredSegment = -1;
Update();
}
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
if ( !e.LeftMouseButton ) return;
var seg = SegmentAt( e.LocalPosition );
if ( seg < 0 ) return;
if ( !IsSegmentEnabled( (Segment)seg ) ) return;
DispatchSegment( (Segment)seg );
e.Accepted = true;
}
private void DispatchSegment( Segment seg )
{
switch ( seg )
{
case Segment.ZoomOut: _frame.ZoomBy( 1f / 1.25f ); break;
case Segment.ZoomIn: _frame.ZoomBy( 1.25f ); break;
case Segment.Percent: /* passive label — no-op */ break;
case Segment.Fit: _frame.ApplyFit(); break;
case Segment.Reset: _frame.ResetZoomOnly(); break;
case Segment.Selection: ZoomToSelection(); break;
}
}
private void ZoomToSelection()
{
var record = CurrentSelection?.Selected;
var live = record?.LivePanel;
if ( live is null || !live.IsValid )
{
Log.Info( $"{LogPrefix} ZoomHud Sel: no valid selection" );
return;
}
_frame.ApplyZoomToRect( live.Box.Rect, /* maxZoom */ 4f, /* padding */ 0.10f );
}
private int SegmentAt( Vector2 localPos )
{
EnsureWidthsMeasured();
if ( localPos.y < 0f || localPos.y > HudHeight ) return -1;
float x = 0f;
for ( var i = 0; i < _segmentWidths.Length; i++ )
{
x += _segmentWidths[i];
if ( localPos.x < x ) return i;
}
return -1;
}
}
using System.Collections.Generic;
using System.Linq;
using Grains.RazorDesigner.Wiring;
namespace Grains.RazorDesigner.CodeDialog;
public sealed class CodeDialogEditBuffer
{
public List<StateSymbol> States { get; }
public List<ParameterSymbol> Parameters { get; }
public IReadOnlyList<Symbol> OtherSymbols { get; }
public string Namespace { get; set; }
public string ClassName { get; set; }
public string BaseClass { get; set; }
public IReadOnlyList<string> Usings { get; set; }
public CodeDialogEditBuffer( WiringEnvelope source )
{
States = new List<StateSymbol>(
source.Symbols.OfType<StateSymbol>() );
Parameters = new List<ParameterSymbol>(
source.Symbols.OfType<ParameterSymbol>() );
OtherSymbols = source.Symbols
.Where( s => s is not StateSymbol and not ParameterSymbol )
.ToArray();
Namespace = source.Namespace;
ClassName = source.ClassName;
BaseClass = source.BaseClass;
Usings = source.Usings;
}
public WiringEnvelope ToEnvelope()
{
var symbols = new List<Symbol>( Parameters.Count + States.Count + OtherSymbols.Count );
symbols.AddRange( Parameters );
symbols.AddRange( States );
symbols.AddRange( OtherSymbols );
return new WiringEnvelope
{
Namespace = Namespace ?? "",
ClassName = ClassName ?? "",
BaseClass = string.IsNullOrEmpty( BaseClass ) ? "PanelComponent" : BaseClass,
Symbols = symbols,
Usings = Usings ?? System.Array.Empty<string>(),
};
}
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using Editor;
using Microsoft.AspNetCore.Components;
using Sandbox;
using Sandbox.UI;
using Grains.RazorDesigner.Document;
namespace Grains.RazorDesigner.Contracts;
public static class ContractScanner
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private static readonly Dictionary<ControlType, (string Tag, string Icon, bool IsContainer)> _seed =
new()
{
// Layout
{ ControlType.Panel, ( "div", "crop_square", true ) },
{ ControlType.SplitContainer, ( "split", "vertical_split", true ) },
// Display
{ ControlType.Label, ( "label", "text_fields", false ) },
{ ControlType.Image, ( "image", "image", false ) },
{ ControlType.IconPanel, ( "i", "star", false ) },
// Input
{ ControlType.Button, ( "button", "smart_button", false ) },
{ ControlType.ButtonGroup, ( "buttongroup", "view_carousel", true ) },
{ ControlType.Checkbox, ( "checkbox", "check_box", false ) },
{ ControlType.TextEntry, ( "textentry", "input", false ) },
{ ControlType.DropDown, ( "dropdown", "arrow_drop_down", false ) },
// Form
{ ControlType.Form, ( "form", "list_alt", true ) },
{ ControlType.Field, ( "field", "label", true ) },
{ ControlType.FieldControl, ( "control", "widgets", true ) },
};
// The frozen table. Null until first access. Written once (idempotent after).
private static IContractTable _table;
public static IContractTable Table => EnsureLoaded();
private static IContractTable EnsureLoaded()
{
if ( _table is not null ) return _table;
Log.Info( $"{LogPrefix} ContractScanner: building contract table…" );
var built = new Dictionary<ControlType, ControlContract>();
foreach ( var kind in Enum.GetValues<ControlType>() )
{
try
{
built[kind] = BuildContract( kind );
}
catch ( Exception ex )
{
Log.Error( $"{LogPrefix} ContractScanner: failed to build contract for {kind}: {ex.Message}" );
throw;
}
}
ApplyOverlay( built );
var frozen = new ReadOnlyDictionary<ControlType, ControlContract>( built );
_table = new ContractTable( frozen );
Log.Info( $"{LogPrefix} ContractScanner: {built.Count} contracts built. Per-kind field counts: " +
string.Join( ", ", built.Values.Select( c => $"{c.Kind}:{c.PayloadFields.Count}f" ) ) );
return _table;
}
private static ControlContract BuildContract( ControlType kind )
{
// 1. Resolve the Sandbox.UI engine type for this kind.
var engineType = ResolveEngineType( kind );
var engineFields = WalkEngineParameters( engineType );
var payloadType = PayloadFactory.Default( kind ).GetType();
var recordFields = WalkRecordGroups( payloadType, kind );
// 4. Merge: payload-record [Group] wins (Design note 6 / spec §M6.3).
var fields = MergeFields( engineFields, recordFields );
// 5. IsContainer — seeded from _seed map (documented limit 6; was ControlMetadata).
var isContainer = _seed[kind].IsContainer;
var icon = ResolveInspectorIcon( engineType, kind );
// 7. Slots — empty until M6.2's overlay adds them (e.g. SplitContainer Left/Right).
var slots = Array.Empty<SlotDefinition>();
// 8. PreviewStrategy (reserved-but-inert in v1 — documented limit 8).
var preview = kind switch
{
ControlType.Button or ControlType.TextEntry or ControlType.Checkbox => PreviewStrategy.LabelSubstitute,
ControlType.IconPanel => PreviewStrategy.IconGlyph,
_ => PreviewStrategy.Native,
};
// 9. LibraryTag — seeded from _seed map (documented limit 7; was ControlMetadata).
var libraryTag = _seed[kind].Tag;
Log.Info( $"{LogPrefix} ContractScanner.BuildContract({kind}): engineType={engineType?.FullName ?? "<not found>"} " +
$"tag='{libraryTag}' container={isContainer} preview={preview} fields={fields.Count}" );
return new ControlContract(
Kind: kind,
PayloadType: payloadType,
LibraryTag: libraryTag,
IsContainer: isContainer,
InspectorIcon: icon,
Slots: new ReadOnlyCollection<SlotDefinition>( (SlotDefinition[])slots.Clone() ),
PayloadFields: new ReadOnlyCollection<ContractField>( fields ),
PreviewStrategy: preview );
}
private static Type ResolveEngineType( ControlType kind )
{
var tag = _seed[kind].Tag;
var found = EditorTypeLibrary.GetTypes()
.FirstOrDefault( td =>
{
var lib = td.GetAttribute<LibraryAttribute>();
if ( lib == null ) return false;
// Match the Name (the tag string passed to [Library("tag")]) case-insensitively.
return string.Equals( lib.Name, tag, StringComparison.OrdinalIgnoreCase );
} );
if ( found is not null )
return found.TargetType;
var fallback = FallbackEngineTypeMap( kind );
if ( fallback is not null )
{
Log.Warning( $"{LogPrefix} ContractScanner.ResolveEngineType({kind}): [Library('{tag}')] " +
$"reverse-lookup found nothing — using hardcoded fallback {fallback.FullName}. " +
$"Check if the engine [Library] tag changed." );
return fallback;
}
Log.Warning( $"{LogPrefix} ContractScanner.ResolveEngineType({kind}): no type found for tag '{tag}' " +
$"and no hardcoded fallback. Engine parameters for this kind will be empty." );
return null;
}
private static Type FallbackEngineTypeMap( ControlType kind ) => kind switch
{
ControlType.Panel => typeof( Sandbox.UI.Panel ),
ControlType.SplitContainer => typeof( Sandbox.UI.SplitContainer ),
ControlType.Label => typeof( Sandbox.UI.Label ),
ControlType.Image => typeof( Sandbox.UI.Image ),
ControlType.IconPanel => typeof( Sandbox.UI.IconPanel ),
ControlType.Button => typeof( Sandbox.UI.Button ),
ControlType.ButtonGroup => typeof( Sandbox.UI.ButtonGroup ),
ControlType.Checkbox => typeof( Sandbox.UI.Checkbox ),
ControlType.TextEntry => typeof( Sandbox.UI.TextEntry ),
ControlType.DropDown => typeof( Sandbox.UI.DropDown ),
ControlType.Form => typeof( Sandbox.UI.Form ),
ControlType.Field => typeof( Sandbox.UI.Field ),
ControlType.FieldControl => typeof( Sandbox.UI.FieldControl ),
_ => null,
};
private static List<ContractField> WalkEngineParameters( Type engineType )
{
if ( engineType is null ) return new List<ContractField>();
var results = new List<ContractField>();
var seenNames = new HashSet<string>( StringComparer.Ordinal );
// Walk from the concrete type up through the inheritance chain.
var current = engineType;
while ( current != null && current != typeof( object ) )
{
foreach ( var prop in current.GetProperties( BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly ) )
{
// Already collected from a more-derived override — skip.
if ( !seenNames.Add( prop.Name ) ) continue;
// Must have [Parameter] (in Microsoft.AspNetCore.Components).
var paramAttr = prop.GetCustomAttribute<ParameterAttribute>( false );
if ( paramAttr is null ) continue;
// Skip RenderFragment / RenderFragment<T> (documented limit 1).
if ( IsRenderFragment( prop.PropertyType ) ) continue;
// Skip Func<> / Action<> delegates (documented limit 2).
if ( IsFuncOrAction( prop.PropertyType ) ) continue;
// Engine [Parameter] properties don't carry [Group] — that's our authoring-side.
results.Add( new ContractField(
Name: prop.Name,
ClrType: prop.PropertyType,
Group: "",
IsOverrideGate: false,
GatedByGroup: "" ) );
}
current = current.BaseType;
}
return results;
}
private static bool IsRenderFragment( Type t )
{
if ( t == typeof( Microsoft.AspNetCore.Components.RenderFragment ) ) return true;
if ( t.IsGenericType && t.GetGenericTypeDefinition() == typeof( Microsoft.AspNetCore.Components.RenderFragment<> ) ) return true;
return false;
}
private static bool IsFuncOrAction( Type t )
{
if ( t == typeof( System.Action ) ) return true;
if ( !t.IsGenericType )
{
// Any other non-generic delegate (rare, but possible via inheritance).
return typeof( System.MulticastDelegate ).IsAssignableFrom( t )
&& t != typeof( System.MulticastDelegate )
&& t != typeof( System.Delegate );
}
// Generic delegates: Func<TResult>, Func<T,TResult>, ..., Action<T>, Action<T,T2>, ...
var def = t.GetGenericTypeDefinition();
var name = def.Name; // "Func`1", "Action`2", etc.
return name.StartsWith( "Func`", StringComparison.Ordinal )
|| name.StartsWith( "Action`", StringComparison.Ordinal );
}
private static readonly HashSet<string> PayloadContentPropNames = new( StringComparer.Ordinal )
{
"Content", "Placeholder", "CheckboxSize", "Source", "IconName",
};
private static HashSet<string> PayloadContentNamesForKind( ControlType kind )
{
return kind switch
{
ControlType.Label => new HashSet<string>( StringComparer.Ordinal ) { "Content" },
ControlType.Image => new HashSet<string>( StringComparer.Ordinal ) { "Source" },
ControlType.IconPanel => new HashSet<string>( StringComparer.Ordinal ) { "IconName" },
ControlType.Button => new HashSet<string>( StringComparer.Ordinal ) { "Content" },
ControlType.Checkbox => new HashSet<string>( StringComparer.Ordinal ) { "Content", "CheckboxSize" },
ControlType.TextEntry => new HashSet<string>( StringComparer.Ordinal ) { "Placeholder" },
// Panel, SplitContainer, ButtonGroup, DropDown, Form, Field, FieldControl — none.
ControlType.Panel or ControlType.SplitContainer or
ControlType.ButtonGroup or ControlType.DropDown or
ControlType.Form or ControlType.Field or ControlType.FieldControl
=> new HashSet<string>( StringComparer.Ordinal ),
_ => new HashSet<string>( StringComparer.Ordinal ),
};
}
private static List<ContractField> WalkRecordGroups( Type payloadType, ControlType kind )
{
var results = new List<ContractField>();
var seenNames = new HashSet<string>( StringComparer.Ordinal );
// Determine which payload-content names this kind actually uses.
var kindPayloadContentNames = PayloadContentNamesForKind( kind );
CollectPropsWithGroups( payloadType, results, seenNames, kindPayloadContentNames );
CollectPropsWithGroups( typeof( Payload ), results, seenNames, kindPayloadContentNames );
CollectPropsWithGroups( typeof( ControlRecord ), results, seenNames, kindPayloadContentNames );
var gateGroups = new HashSet<string>(
results
.Where( f => f.IsOverrideGate )
.Select( f => f.Group ),
StringComparer.Ordinal );
for ( int i = 0; i < results.Count; i++ )
{
var f = results[i];
if ( f.IsOverrideGate || string.IsNullOrEmpty( f.Group ) ) continue;
if ( gateGroups.Contains( f.Group ) )
results[i] = f with { GatedByGroup = f.Group };
}
return results;
}
private static void CollectPropsWithGroups(
Type sourceType,
List<ContractField> results,
HashSet<string> seenNames,
HashSet<string> kindPayloadContentNames )
{
foreach ( var prop in sourceType.GetProperties(
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly ) )
{
// [Hide] properties: skip (they're internal/non-inspector).
if ( prop.GetCustomAttribute<HideAttribute>() is not null ) continue;
if ( PayloadContentPropNames.Contains( prop.Name )
&& !kindPayloadContentNames.Contains( prop.Name ) ) continue;
// Collect the [Group] attribute (Sandbox.GroupAttribute).
var groupAttr = prop.GetCustomAttribute<GroupAttribute>();
var group = groupAttr?.Value ?? "";
var isOverrideGate = prop.Name.StartsWith( "Override", StringComparison.Ordinal )
&& prop.PropertyType == typeof( bool );
if ( string.IsNullOrEmpty( group ) && !isOverrideGate ) continue;
if ( !seenNames.Add( prop.Name ) ) continue;
results.Add( new ContractField(
Name: prop.Name,
ClrType: prop.PropertyType,
Group: group,
IsOverrideGate: isOverrideGate,
GatedByGroup: "" /* filled in after the full list is built */ ) );
}
}
private static List<ContractField> MergeFields(
List<ContractField> engineFields,
List<ContractField> recordFields )
{
var result = new List<ContractField>( recordFields );
var nameSet = new HashSet<string>(
recordFields.Select( f => f.Name ), StringComparer.Ordinal );
// Add engine fields not already covered by the record side.
foreach ( var ef in engineFields )
{
if ( nameSet.Add( ef.Name ) )
result.Add( ef );
}
return result;
}
private static string ResolveInspectorIcon( Type engineType, ControlType kind )
{
// Try the engine type's [Icon] attribute first (may not be present).
if ( engineType is not null )
{
var typeDesc = EditorTypeLibrary.GetType( engineType );
if ( typeDesc is not null )
{
var iconAttr = typeDesc.GetAttribute<IconAttribute>();
if ( iconAttr is not null && !string.IsNullOrEmpty( iconAttr.Value ) )
{
Log.Info( $"{LogPrefix} ContractScanner.ResolveInspectorIcon({kind}): found [Icon] = '{iconAttr.Value}'" );
return iconAttr.Value;
}
}
}
// Fall back to the _seed map (v1 default; was ControlMetadata.IconName).
var icon = _seed[kind].Icon;
Log.Info( $"{LogPrefix} ContractScanner.ResolveInspectorIcon({kind}): using seed fallback = '{icon}'" );
return icon;
}
private static readonly JsonSerializerOptions OverlayJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() },
};
private static void ApplyOverlay( Dictionary<ControlType, ControlContract> built )
{
var path = ResolveOverlayPath();
if ( !File.Exists( path ) )
{
var msg = $"{LogPrefix} ContractScanner.ApplyOverlay: contract-overlay.json not found at '{path}'. " +
$"This file is a required addon asset — ensure it exists in Editor/Contracts/.";
Log.Error( msg );
throw new FileNotFoundException( msg, path );
}
Log.Info( $"{LogPrefix} ContractScanner.ApplyOverlay: loading overlay from '{path}'" );
string text;
try
{
text = File.ReadAllText( path );
}
catch ( IOException ex )
{
var msg = $"{LogPrefix} ContractScanner.ApplyOverlay: failed to read '{path}': {ex.Message}";
Log.Error( msg );
throw;
}
OverlayDocument doc;
try
{
doc = JsonSerializer.Deserialize<OverlayDocument>( text, OverlayJsonOptions );
}
catch ( JsonException ex )
{
var msg = $"{LogPrefix} ContractScanner.ApplyOverlay: JSON parse error in '{path}': {ex.Message}";
Log.Error( msg );
throw;
}
if ( doc is null || doc.Entries is null || doc.Entries.Count == 0 )
{
Log.Info( $"{LogPrefix} ContractScanner.ApplyOverlay: overlay loaded (version={doc?.Version}) — no entries, nothing to merge." );
return;
}
Log.Info( $"{LogPrefix} ContractScanner.ApplyOverlay: overlay version={doc.Version}, {doc.Entries.Count} entries to apply." );
var applied = 0;
foreach ( var entry in doc.Entries )
{
// Governance tripwire: every entry MUST carry a non-empty _cite.
if ( string.IsNullOrWhiteSpace( entry.Cite ) )
{
var msg = $"{LogPrefix} ContractScanner.ApplyOverlay: contract-overlay.json entry for " +
$"'{entry.Kind}' is missing a non-empty \"_cite\" — every overlay entry must cite " +
$"the engine type / method / behaviour it encodes. Remove the entry or add a _cite.";
Log.Error( msg );
throw new InvalidDataException( msg );
}
if ( !built.TryGetValue( entry.Kind, out var existing ) )
{
Log.Warning( $"{LogPrefix} ContractScanner.ApplyOverlay: overlay entry kind '{entry.Kind}' " +
$"is not in the built contract table (unrecognised ControlType?). Skipping." );
continue;
}
// Merge each optional field — null means "leave the reflected value unchanged".
var slots = entry.Slots ?? existing.Slots;
var payloadFields = entry.PayloadFields is null
? existing.PayloadFields
: MergePayloadFields( existing.PayloadFields, entry.PayloadFields );
var libraryTag = entry.LibraryTag ?? existing.LibraryTag;
var inspectorIcon = entry.InspectorIcon ?? existing.InspectorIcon;
var isContainer = entry.IsContainer ?? existing.IsContainer;
var preview = entry.PreviewStrategy ?? existing.PreviewStrategy;
built[entry.Kind] = existing with
{
Slots = new ReadOnlyCollection<SlotDefinition>( slots.ToList() ),
PayloadFields = payloadFields,
LibraryTag = libraryTag,
InspectorIcon = inspectorIcon,
IsContainer = isContainer,
PreviewStrategy = preview,
};
Log.Info( $"{LogPrefix} ContractScanner.ApplyOverlay: merged '{entry.Kind}' — " +
$"slots={slots.Count} payloadFields={payloadFields.Count} " +
$"tag='{libraryTag}' icon='{inspectorIcon}' container={isContainer} preview={preview}" );
applied++;
}
Log.Info( $"{LogPrefix} ContractScanner.ApplyOverlay: {applied}/{doc.Entries.Count} entries applied." );
}
private static IReadOnlyList<ContractField> MergePayloadFields(
IReadOnlyList<ContractField> existing,
IReadOnlyList<OverlayField> overlayFields )
{
// Index overlay fields by name for O(1) lookup.
var overlayByName = new Dictionary<string, OverlayField>( StringComparer.Ordinal );
foreach ( var overlayField in overlayFields )
{
if ( !string.IsNullOrEmpty( overlayField.Name ) )
overlayByName[overlayField.Name] = overlayField;
}
var result = new List<ContractField>();
var seenNames = new HashSet<string>( StringComparer.Ordinal );
// Pass 1: walk existing (reflected) fields; apply overlay overrides where present.
foreach ( var reflectedField in existing )
{
seenNames.Add( reflectedField.Name );
if ( overlayByName.TryGetValue( reflectedField.Name, out var overlayOverride ) )
{
// Overlay override: keep reflected CLR type; take overlay group/gate data.
result.Add( reflectedField with
{
Group = string.IsNullOrEmpty( overlayOverride.Group ) ? reflectedField.Group : overlayOverride.Group,
IsOverrideGate = overlayOverride.IsOverrideGate,
GatedByGroup = string.IsNullOrEmpty( overlayOverride.GatedByGroup ) ? reflectedField.GatedByGroup : overlayOverride.GatedByGroup,
} );
}
else
{
// No overlay entry for this field — keep reflected as-is.
result.Add( reflectedField );
}
}
// Pass 2: add overlay-only fields (not seen in reflection).
foreach ( var addField in overlayFields )
{
if ( string.IsNullOrEmpty( addField.Name ) ) continue;
if ( !seenNames.Add( addField.Name ) ) continue; // already processed above
// Resolve CLR type from the name string; fall back to string when unknown.
var clrType = ResolveClrType( addField.ClrTypeName );
result.Add( new ContractField(
Name: addField.Name,
ClrType: clrType,
Group: addField.Group,
IsOverrideGate: addField.IsOverrideGate,
GatedByGroup: addField.GatedByGroup ) );
}
return new ReadOnlyCollection<ContractField>( result );
}
private static Type ResolveClrType( string typeName )
{
if ( string.IsNullOrEmpty( typeName ) ) return typeof( string );
return typeName switch
{
"String" or "string" => typeof( string ),
"Boolean" or "bool" => typeof( bool ),
"Int32" or "int" => typeof( int ),
"Single" or "float" => typeof( float ),
"Double" or "double" => typeof( double ),
_ => typeof( string ), // safe fallback — overlay-only fields are informational
};
}
private static readonly (string Ident, string Subpath)[] OverlayFileRoots = new[]
{
( "razordesigner", "Editor/Contracts/contract-overlay.json" ),
( "grains_razordesigner", "Editor/Contracts/contract-overlay.json" ),
// Also check directly inside the dev-workspace library subdirectory.
( "grains_razordesigner", "Libraries/xaz.razordesigner/Editor/Contracts/contract-overlay.json" ),
};
private static string ResolveOverlayPath()
{
foreach ( var (ident, subpath) in OverlayFileRoots )
{
var root = System.Linq.Enumerable
.FirstOrDefault( EditorUtility.Projects.GetAll(),
p => string.Equals( p.Config?.Ident, ident, StringComparison.OrdinalIgnoreCase ) )
?.GetRootPath();
if ( string.IsNullOrEmpty( root ) ) continue;
var candidate = Path.Combine( root, subpath );
if ( File.Exists( candidate ) )
{
Log.Info( $"{LogPrefix} ContractScanner.ResolveOverlayPath: resolved via ident='{ident}' → '{candidate}'" );
return candidate;
}
}
var assemblyDir = Path.GetDirectoryName( typeof( ContractScanner ).Assembly.Location ) ?? "";
var lastResort = Path.Combine( assemblyDir, "contract-overlay.json" );
Log.Warning( $"{LogPrefix} ContractScanner.ResolveOverlayPath: project-list lookup found nothing — " +
$"falling back to assembly-relative path '{lastResort}'. " +
$"This likely means EditorUtility.Projects.GetAll() returned no projects matching " +
$"'razordesigner' or 'grains_razordesigner'." );
return lastResort;
}
private sealed class ContractTable : IContractTable
{
private readonly ReadOnlyDictionary<ControlType, ControlContract> _data;
public ContractTable( ReadOnlyDictionary<ControlType, ControlContract> data )
{
_data = data;
}
public ControlContract Get( ControlType kind )
{
if ( _data.TryGetValue( kind, out var contract ) )
return contract;
throw new KeyNotFoundException(
$"ContractTable.Get: no contract registered for ControlType.{kind}. " +
$"This should never happen if all 13 types were built successfully." );
}
public IEnumerable<ControlContract> All => _data.Values;
}
}
using System.Text.Json.Serialization;
namespace Grains.RazorDesigner.Document;
public sealed record FormPayload : Payload
{
[JsonIgnore]
public override ControlType Kind => ControlType.Form;
}
using System.Diagnostics;
namespace Grains.RazorDesigner.Document;
public static class PayloadFactory
{
public static Payload Default( ControlType kind ) => kind switch
{
ControlType.Panel => new PanelPayload(),
ControlType.SplitContainer => new SplitContainerPayload(),
ControlType.Label => new LabelPayload(),
ControlType.Image => new ImagePayload(),
ControlType.IconPanel => new IconPanelPayload(),
ControlType.Button => new ButtonPayload(),
ControlType.ButtonGroup => new ButtonGroupPayload(),
ControlType.Checkbox => new CheckboxPayload(),
ControlType.TextEntry => new TextEntryPayload(),
ControlType.DropDown => new DropDownPayload(),
ControlType.Form => new FormPayload(),
ControlType.Field => new FieldPayload(),
ControlType.FieldControl => new FieldControlPayload(),
_ => throw new UnreachableException( $"PayloadFactory.Default: no payload defined for ControlType.{kind}" ),
};
public static Payload WithFields(
ControlType kind,
string content,
string placeholder,
string source,
string iconName,
Length checkboxSize ) => kind switch
{
ControlType.Panel => new PanelPayload(),
ControlType.SplitContainer => new SplitContainerPayload(),
ControlType.Label => new LabelPayload { Content = content },
ControlType.Image => new ImagePayload { Source = source },
ControlType.IconPanel => new IconPanelPayload { IconName = iconName },
ControlType.Button => new ButtonPayload { Content = content },
ControlType.ButtonGroup => new ButtonGroupPayload(),
ControlType.Checkbox => new CheckboxPayload { Content = content, CheckboxSize = checkboxSize },
ControlType.TextEntry => new TextEntryPayload { Placeholder = placeholder },
ControlType.DropDown => new DropDownPayload(),
ControlType.Form => new FormPayload(),
ControlType.Field => new FieldPayload(),
ControlType.FieldControl => new FieldControlPayload(),
_ => throw new UnreachableException( $"PayloadFactory.WithFields: no payload defined for ControlType.{kind}" ),
};
}
using System.Text.Json.Serialization;
namespace Grains.RazorDesigner.Document;
public sealed record TextEntryPayload : Payload
{
[JsonIgnore]
public override ControlType Kind => ControlType.TextEntry;
public override string Placeholder { get; init; } = "";
}
using System.Collections.Generic;
using System.Linq;
using Editor;
using static Editor.BaseItemWidget;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Hierarchy;
public sealed class ControlRecordTreeNode : TreeNode<ControlRecord>
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public bool IsCanvasRoot { get; init; }
private readonly HierarchyPanel _hierarchy;
public ControlRecordTreeNode( ControlRecord record, HierarchyPanel hierarchy, bool isCanvasRoot = false )
{
Value = record;
_hierarchy = hierarchy;
IsCanvasRoot = isCanvasRoot;
}
// ClassName excluded: engine TreeNode.Think auto-rebuilds on hash change without clearing children first; BuildChildren would duplicate. Repaint via HierarchyPanel.NodeChanged instead.
public override int ValueHash => System.HashCode.Combine( Value?.Type, Value?.Children.Count, IsCanvasRoot );
public override bool CanEdit => !IsCanvasRoot && Value is not null;
// Engine TreeView reads this to seed the F2 rename popup. Setter is unused; OnRename owns the write path.
public override string Name
{
get => Value?.ClassName ?? "";
set { /* see OnRename */ }
}
public override void OnRename( VirtualWidget item, string text, List<TreeNode> selection = null )
{
if ( Value is null ) return;
Log.Info( $"{LogPrefix} TreeNode.OnRename: {Value.ClassName} -> {text}" );
_hierarchy?.NotifyRenameRequested( Value, text );
}
protected override void BuildChildren()
{
if ( Value is null ) return;
foreach ( var child in Value.Children )
AddItem( new ControlRecordTreeNode( child, _hierarchy ) );
}
public override bool HasChildren => Value is not null && Value.Children.Count > 0;
public override bool OnDragStart()
{
if ( IsCanvasRoot || Value is null ) return false;
if ( Value.IsSlot ) return false; // slots are structural; can't be moved
var drag = new Drag( TreeView );
drag.Data.Object = Value;
drag.Execute();
Log.Info( $"{LogPrefix} TreeNode.OnDragStart: {Value.ClassName}" );
return true;
}
public override bool OnContextMenu()
{
if ( Value is null ) return false;
var doc = _hierarchy?.Document;
var hasClipboard = doc?.Clipboard is not null;
// Canvas row: only Paste makes sense, and only when there's a clipboard.
if ( IsCanvasRoot )
{
if ( !hasClipboard ) return false;
var rootMenu = new Menu( TreeView );
rootMenu.AddOption( "Paste", "content_paste", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Paste: into Canvas" );
_hierarchy?.NotifyPasteRequested( Value );
} );
rootMenu.OpenAtCursor( false );
return true;
}
if ( Value.IsSlot )
{
var slotMenu = new Menu( TreeView );
var slotPaste = slotMenu.AddOption( "Paste", "content_paste", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Paste: into slot {Value.ClassName}" );
_hierarchy?.NotifyPasteRequested( Value );
} );
slotPaste.Enabled = hasClipboard;
slotMenu.OpenAtCursor( false );
return true;
}
var multi = _hierarchy?.SelectedRecords;
IReadOnlyList<ControlRecord> targets;
if ( multi is { Count: > 1 } && multi.Contains( Value ) )
targets = multi;
else
targets = new[] { Value };
var suffix = targets.Count > 1 ? $" ({targets.Count} items)" : "";
var m = new Menu( TreeView );
m.AddOption( $"Save as Template{suffix}…", "bookmark_add", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.SaveAsTemplate: {targets.Count} record(s)" );
_hierarchy?.NotifySaveAsTemplateRequested( targets );
} );
m.AddSeparator();
m.AddOption( $"Cut{suffix}", "content_cut", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Cut: {targets.Count} record(s)" );
_hierarchy?.NotifyCutRequested( targets );
} );
m.AddOption( $"Copy{suffix}", "content_copy", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Copy: {targets.Count} record(s)" );
_hierarchy?.NotifyCopyRequested( targets );
} );
var pasteOpt = m.AddOption( "Paste", "content_paste", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Paste: onto {Value.ClassName}" );
_hierarchy?.NotifyPasteRequested( Value );
} );
// Disable rather than hide so users learn paste exists.
pasteOpt.Enabled = hasClipboard;
m.AddOption( $"Duplicate{suffix}", "file_copy", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Duplicate: {targets.Count} record(s)" );
_hierarchy?.NotifyDuplicateRequested( targets );
} );
m.AddOption( "Custom Styles…", "palette", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.CustomStyles: {Value.ClassName}" );
_hierarchy?.NotifyCustomStylesRequested( Value );
} );
m.AddSeparator();
m.AddOption( $"Delete{suffix}", "delete", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Delete: {targets.Count} record(s)" );
_hierarchy?.NotifyDeleteRequested( targets );
} );
m.OpenAtCursor( false );
return true;
}
public override DropAction OnDragDrop( ItemDragEvent e )
{
if ( Value is null ) return DropAction.Ignore;
var doc = _hierarchy?.Document;
if ( doc is null ) return DropAction.Ignore;
if ( e.Data.Object is ControlRecord dragged )
{
if ( dragged == Value ) return DropAction.Ignore;
if ( doc.IsDescendant( dragged, Value ) ) return DropAction.Ignore;
if ( !TryComputeDropTarget( doc, e, out var newParent, out var index, out _ ) )
return DropAction.Ignore;
if ( !e.IsDrop ) return DropAction.Move;
var ok = doc.MoveTo( dragged, newParent, index );
if ( !ok ) return DropAction.Ignore;
_hierarchy?.NotifyMoved( dragged );
return DropAction.Move;
}
else if ( e.Data.Object is ControlType paletteType )
{
if ( !TryComputeDropTarget( doc, e, out var newParent, out var index, out var dropOnto ) )
return DropAction.Ignore;
if ( !e.IsDrop ) return DropAction.Copy;
Log.Info( $"{LogPrefix} TreeNode.OnDragDrop create: {paletteType} -> parent={newParent.ClassName}, index={index}" );
var newRecord = doc.Add( paletteType, newParent );
if ( !dropOnto )
{
// Add always appends; shift to the intended sibling slot.
doc.MoveTo( newRecord, newParent, index );
}
_hierarchy?.NotifyCreated( newRecord );
return DropAction.Copy;
}
else if ( e.Data.Object is Grains.RazorDesigner.Templates.PaletteTemplate template )
{
if ( !TryComputeDropTarget( doc, e, out var newParent, out var index, out _ ) )
return DropAction.Ignore;
if ( !e.IsDrop ) return DropAction.Copy;
Log.Info( $"{LogPrefix} TreeNode.OnDragDrop template: \"{template.Name}\" -> parent={newParent.ClassName}, index={index}" );
_hierarchy?.NotifyTemplateDropRequested( template, newParent, index );
return DropAction.Copy;
}
else
{
return DropAction.Ignore;
}
}
private bool TryComputeDropTarget( DesignerDocument doc, ItemDragEvent e,
out ControlRecord newParent, out int index, out bool dropOnto )
{
newParent = null;
index = 0;
var edge = e.DropEdge;
var isMiddle = !edge.HasFlag( ItemEdge.Top ) && !edge.HasFlag( ItemEdge.Bottom );
var isContainer = Value is not null && ContractScanner.Table.Get( Value.Type ).IsContainer;
if ( isMiddle && !isContainer && !IsCanvasRoot )
{
var halfH = e.Item.Rect.Height * 0.5f;
edge = e.LocalPosition.y < halfH ? ItemEdge.Top : ItemEdge.Bottom;
isMiddle = false;
}
dropOnto = isMiddle;
if ( dropOnto )
{
if ( !isContainer ) return false;
newParent = Value;
index = Value.Children.Count;
return true;
}
if ( IsCanvasRoot ) return false;
newParent = doc.FindParent( Value );
if ( newParent is null ) return false;
var targetIdx = newParent.Children.IndexOf( Value );
index = edge.HasFlag( ItemEdge.Top ) ? targetIdx : targetIdx + 1;
return true;
}
public override void OnPaint( VirtualWidget item )
{
PaintSelection( item );
var r = item.Rect;
var iconRect = new Rect( r.Left + 4, r.Top, r.Height, r.Height );
var textRect = r;
textRect.Left += r.Height + 8;
var iconType = IsCanvasRoot ? ControlType.Panel : Value.Type;
var icon = ContractScanner.Table.Get( iconType ).InspectorIcon;
Paint.SetPen( IsCanvasRoot ? ControlPresentation.ContainerTint : ControlPresentation.IconTint( iconType ) );
Paint.DrawIcon( iconRect, icon, r.Height - 6, TextFlag.Center );
Paint.SetDefaultFont();
if ( IsCanvasRoot )
{
Paint.SetPen( Theme.TextControl );
Paint.DrawText( textRect, "Canvas", TextFlag.LeftCenter );
}
else
{
Paint.SetPen( Theme.TextControl );
var typeLabel = Value.IsSlot ? $"Slot:{Value.SlotName} " : $"{Value.Type} ";
Paint.DrawText( textRect, typeLabel, TextFlag.LeftCenter );
var typeWidth = Paint.MeasureText( typeLabel ).x;
textRect.Left += typeWidth;
Paint.SetPen( Theme.TextControl.WithAlpha( 0.6f ) );
Paint.DrawText( textRect, Value.ClassName, TextFlag.LeftCenter );
if ( Value.IsSlot )
{
var lockRect = new Rect( r.Right - r.Height, r.Top, r.Height, r.Height );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
Paint.DrawIcon( lockRect, "lock", r.Height - 8, TextFlag.Center );
}
}
}
}
using System;
using System.Linq;
using Editor;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Inspector;
[CustomEditor( typeof( string ), WithAllAttributes = new[] { typeof( BackgroundImagePickerAttribute ) } )]
public sealed class BackgroundImageControlWidget : ControlWidget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public override bool SupportsMultiEdit => true;
private readonly LineEdit _textEdit;
// Synchronous change events both directions; without this SetValue would re-enter.
private bool _syncing;
public BackgroundImageControlWidget( SerializedProperty property ) : base( property )
{
Log.Info( $"{LogPrefix} BackgroundImageControlWidget ctor for {property.Name}" );
AcceptDrops = true;
Layout = Layout.Row();
Layout.Spacing = 2;
_textEdit = new LineEdit( this )
{
PlaceholderText = "url(\"ui/bg.png\") or linear-gradient(...)",
};
_textEdit.MinimumSize = new Vector2( 60, Theme.RowHeight );
_textEdit.MaximumSize = new Vector2( 4096, Theme.RowHeight );
_textEdit.SetStyles( "background-color: transparent;" );
_textEdit.AcceptDrops = false;
_textEdit.EditingStarted += OnEditStarted;
_textEdit.TextEdited += OnTextEdited;
_textEdit.EditingFinished += OnEditFinished;
Layout.Add( _textEdit, 1 );
var pick = new IconButton( "image", OpenPicker )
{
ToolTip = "Pick an image asset — inserts url(\"…\")",
Background = Color.Transparent,
FixedSize = new Vector2( Theme.RowHeight, Theme.RowHeight ),
};
Layout.Add( pick );
SyncFromProperty();
}
private void SyncFromProperty()
{
if ( _syncing ) return;
_syncing = true;
try
{
if ( SerializedProperty.IsMultipleDifferentValues )
{
if ( !_textEdit.IsFocused ) _textEdit.Text = "";
return;
}
var v = SerializedProperty.GetValue<string>( "" ) ?? "";
// Don't clobber the user's in-progress typing.
if ( !_textEdit.IsFocused && _textEdit.Text != v )
_textEdit.Text = v;
}
finally
{
_syncing = false;
}
}
private void OnEditStarted()
{
if ( ReadOnly || !SerializedProperty.IsEditable ) return;
PropertyStartEdit();
}
private void OnTextEdited( string text )
{
if ( _syncing ) return;
if ( ReadOnly || !SerializedProperty.IsEditable ) return;
Commit( text );
}
private void OnEditFinished()
{
if ( _syncing ) return;
if ( !ReadOnly && SerializedProperty.IsEditable )
Commit( _textEdit.Text );
PropertyFinishEdit();
}
private void Commit( string text )
{
var current = SerializedProperty.GetValue<string>( "" ) ?? "";
if ( text == current ) return;
_syncing = true;
try
{
SerializedProperty.SetValue( text );
SignalValuesChanged();
}
finally
{
_syncing = false;
}
}
// External set (picker / drop) — wrap in its own undo bracket and refresh the text box.
private void SetExternal( string text )
{
if ( ReadOnly || !SerializedProperty.IsEditable ) return;
PropertyStartEdit();
_syncing = true;
try
{
SerializedProperty.SetValue( text );
SignalValuesChanged();
_textEdit.Text = text;
}
finally
{
_syncing = false;
}
PropertyFinishEdit();
Log.Info( $"{LogPrefix} BackgroundImageControlWidget set: {text}" );
}
private static string ToUrl( string relativePath ) => $"url(\"{relativePath}\")";
private void OpenPicker()
{
if ( ReadOnly ) return;
var picker = AssetPicker.Create( this, AssetType.ImageFile );
picker.Title = "Select Background Image";
picker.OnAssetPicked = ( assets ) =>
{
var asset = assets.FirstOrDefault();
if ( asset is not null ) SetExternal( ToUrl( asset.RelativePath ) );
};
picker.Show();
}
public override void OnDragHover( DragEvent ev )
{
// Must set ev.Action here or the drop is never offered — keep it cheap (fires per move).
if ( !ReadOnly && TryResolveImageRelPath( ev, out _ ) )
ev.Action = DropAction.Link;
}
public override void OnDragDrop( DragEvent ev )
{
if ( TryResolveImageRelPath( ev, out var rel ) )
{
Log.Info( $"{LogPrefix} BackgroundImageControlWidget drop -> {rel}" );
SetExternal( ToUrl( rel ) );
ev.Action = DropAction.Link;
return;
}
Log.Info( $"{LogPrefix} BackgroundImageControlWidget drop ignored — not an image asset (Text='{ev.Data.Text}', HasFileOrFolder={ev.Data.HasFileOrFolder}, FileOrFolder='{(ev.Data.HasFileOrFolder ? ev.Data.FileOrFolder : "")}')" );
}
private static bool TryResolveImageRelPath( DragEvent ev, out string relativePath )
{
relativePath = null;
var text = ev.Data.Text;
if ( !string.IsNullOrEmpty( text ) && !text.StartsWith( "file:", StringComparison.OrdinalIgnoreCase ) )
{
var byText = AssetSystem.FindByPath( text );
if ( IsImageAsset( byText ) ) { relativePath = byText.RelativePath; return true; }
}
if ( ev.Data.HasFileOrFolder )
{
var path = ev.Data.FileOrFolder;
if ( !string.IsNullOrEmpty( path ) )
{
var byPath = AssetSystem.FindByPath( path ) ?? AssetSystem.FindByPath( path.Replace( '\\', '/' ) );
if ( IsImageAsset( byPath ) ) { relativePath = byPath.RelativePath; return true; }
}
}
return false;
}
private static bool IsImageAsset( Asset asset )
=> asset is not null && !asset.IsDeleted
&& ( asset.AssetType == AssetType.ImageFile || asset.AssetType == AssetType.Texture );
protected override void OnValueChanged()
{
base.OnValueChanged();
SyncFromProperty();
}
}
namespace Grains.RazorDesigner.Projection;
public interface IControlProjector
{
string Kind { get; } // matches ControlType.ToString()
ProjectionResult Project( IReadOnlyNode node, IAppearance appearance, IPayload payload, ProjectionContext ctx );
}
using System.Linq;
using System.Reflection;
using Grains.RazorDesigner.Projection.CSharp;
namespace Grains.RazorDesigner.Projection.Tests;
public static class CSharpOpExhaustivenessTest
{
public static (bool pass, string message) Run()
{
var ops = new CSharpOp[]
{
// File-level scaffold (5)
new HeaderBanner( "Foo", "Bar.Baz" ),
new UsingDirective( "Sandbox.UI" ),
new NamespaceOpen( "My.Namespace" ),
new ClassOpen( "MyPanel", "PanelComponent" ),
new ClassClose(),
// Member-level (3)
new FieldDecl( "private", "int", "_count", "0", false ),
new MethodOpen( "public", true, false, "void", "OnAfterTreeRender", "bool firstRender" ),
new MethodClose(),
// Body-level (5)
new Statement( "x = 1" ),
new BlockOpen( "if ( x > 0 )" ),
new BlockClose(),
new BlankLine(),
new Comment( "Auto-generated — do not edit." ),
};
int arrayCount = ops.Length;
var reflectedTypes = typeof( CSharpOp ).Assembly
.GetTypes()
.Where( t => t != typeof( CSharpOp ) && typeof( CSharpOp ).IsAssignableFrom( t ) && !t.IsAbstract )
.ToArray();
int reflectedCount = reflectedTypes.Length;
if ( arrayCount != reflectedCount )
{
var reflectedNames = string.Join( ", ", reflectedTypes.Select( t => t.Name ) );
return (false,
$"CSharpOpExhaustivenessTest MISMATCH: inline array has {arrayCount} ops " +
$"but reflection found {reflectedCount} CSharpOp-derived types: {reflectedNames}. " +
"Add the missing leaf to the inline array AND to CSharpApplier's switch in the same commit.");
}
foreach ( var op in ops )
{
try
{
CSharpApplier.ApplyOpToScratch( op );
}
catch ( System.Diagnostics.UnreachableException ex )
{
return (false,
$"CSharpOpExhaustivenessTest: CSharpApplier.ApplyOpToScratch threw for " +
$"'{op.GetType().Name}': {ex.Message}");
}
}
return (true,
$"13/13 known leaves accepted by CSharpApplier.ApplyOpToScratch.");
}
}
using System.Text;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Projection;
namespace Grains.RazorDesigner.Serialization;
public static class DocumentSerializer
{
private const int SchemaVersion = 4;
// Sandbox.UI outline shorthand only accepts 'solid'.
private const string SelectionRule =
".selected { outline: 2px solid #3FA9F5; }";
private const string PreviewMarkerRules =
".preview-image-empty { " +
"background-image: linear-gradient(to bottom right, rgba(120, 120, 160, 0.5), rgba(60, 60, 90, 0.5)); " +
"border: 1px solid rgba(180, 180, 200, 0.5); " +
"min-width: 32px; " +
"min-height: 32px; " +
"}\n" +
".preview-panel { position: relative; }\n" +
".preview-chrome-label { " +
"position: absolute; " +
"top: 0; left: 0; right: 0; bottom: 0; " +
"justify-content: center; " +
"align-items: center; " +
"color: rgba(255, 255, 255, 0.4); " +
"font-size: 20px; " +
"text-align: center; " +
"}\n" +
".chrome-hidden .preview-chrome-label { display: none; }\n" +
".chrome-hidden .preview-image-empty { " +
"background-image: none; " +
"border: none; " +
"min-width: 0; " +
"min-height: 0; " +
"}\n" +
".chrome-hidden .preview-buttongroup { " +
"background-color: transparent; " +
"border: none; " +
"min-height: 0; " +
"}\n" +
".chrome-hidden .preview-dropdown { " +
"background-image: none; " +
"background-color: transparent; " +
"border: none; " +
"min-height: 0; " +
"box-shadow: none; " +
"}\n" +
".chrome-hidden .preview-dropdown > .inner { " +
"background-color: transparent; " +
"border-left: none; " +
"}\n" +
".chrome-hidden .selected { outline: 0px solid transparent; }";
public static string GenerateRazorMarkup( DesignerDocument doc, string irHash = null )
{
var sb = new StringBuilder();
sb.AppendLine( $"@* Grains.RazorDesigner schema={SchemaVersion} *@" );
if ( irHash is not null )
sb.AppendLine( $"@* generated-from-ir-hash: {irHash} *@" );
sb.AppendLine( "@* https://sbox.game/xaz/razordesigner *@" );
sb.AppendLine( "@using Sandbox;" );
sb.AppendLine( "@using Sandbox.UI;" );
sb.AppendLine( "@inherits PanelComponent" );
sb.AppendLine( "<root class=\"root\">" );
var ctx = new ProjectionContext( PreviewTheme.Default, ForPreview: false );
var (razorChildren, _) = Applier.BuildSave( new RecordNode( doc.RootRecord ), "", ctx );
sb.Append( razorChildren );
sb.AppendLine( "</root>" );
return sb.ToString();
}
public static string GeneratePreviewStylesheet( DesignerDocument doc, PreviewTheme theme )
{
var sb = new StringBuilder();
sb.AppendLine( SelectionRule );
sb.AppendLine( ( theme ?? PreviewTheme.Default ).Css );
sb.AppendLine( PreviewMarkerRules );
var ctx = new ProjectionContext( theme ?? PreviewTheme.Default, ForPreview: true );
var (_, scss) = Applier.BuildSave( new RecordNode( doc.RootRecord ), "", ctx );
sb.Append( scss );
return sb.ToString();
}
public static string GenerateCSharp(
DesignerDocument doc,
string classNameFallback,
string namespaceFallback )
{
var view = new Projection.CSharp.WiringEnvelopeView(
doc.Wiring ?? Wiring.WiringEnvelope.Empty,
namespaceFallback,
classNameFallback );
var result = Projection.CSharp.CSharpProjector.Project(
view, documentHasAnyBindings: doc.HasAnyBindings() );
return result.Source; // null = elision; caller skips the write
}
public static string GenerateSavedScss( DesignerDocument doc, string className, PreviewTheme theme )
{
var effectiveTheme = theme ?? PreviewTheme.Default;
var sb = new StringBuilder();
sb.AppendLine( $"// designed against theme: {effectiveTheme.Name}" );
sb.AppendLine( $"{className} {{" );
var ctx = new ProjectionContext( effectiveTheme, ForPreview: false );
var (_, scss) = Applier.BuildSave( new RecordNode( doc.RootRecord ), className, ctx );
sb.Append( scss );
sb.AppendLine( "}" );
return sb.ToString();
}
}
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Validation;
using Sandbox;
namespace Grains.RazorDesigner.Serialization;
public static class LegacyRazorImporter
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const int SchemaVersion = 3;
private static readonly Regex SchemaMarker = new(
@"@\*\s*Grains\.RazorDesigner\s+schema=(?<v>\d+)\s*\*@",
RegexOptions.Compiled );
// Built lazily on first call. Inverse of ContractScanner.Table.Get(t).LibraryTag.
private static Dictionary<string, ControlType> _tagToType;
private static Dictionary<string, ControlType> TagToType
{
get
{
if ( _tagToType is not null ) return _tagToType;
var map = new Dictionary<string, ControlType>( StringComparer.OrdinalIgnoreCase );
foreach ( ControlType t in Enum.GetValues( typeof( ControlType ) ) )
{
var tag = Grains.RazorDesigner.Contracts.ContractScanner.Table.Get( t ).LibraryTag;
if ( !string.IsNullOrEmpty( tag ) ) map[tag] = t;
}
_tagToType = map;
return map;
}
}
public static (DesignerDocument Document, List<ValidationDiagnostic> Diagnostics) Import( string razorPath )
{
var diags = new List<ValidationDiagnostic>();
if ( string.IsNullOrEmpty( razorPath ) )
{
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Error, "legacy-import-failed", "razorPath is empty" ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Import: razorPath is empty" );
return (null, diags);
}
if ( !File.Exists( razorPath ) )
{
var msg = $"file does not exist: {razorPath}";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Error, "legacy-import-failed", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Import: {msg}" );
return (null, diags);
}
string razorText;
try
{
razorText = File.ReadAllText( razorPath );
}
catch ( IOException ex )
{
var msg = $"cannot read .razor: {ex.Message}";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Error, "legacy-import-failed", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Import: {msg}" );
return (null, diags);
}
// Schema gate.
var match = SchemaMarker.Match( razorText );
if ( !match.Success )
{
var msg = $"not a Razor Designer file (missing '@* Grains.RazorDesigner schema=N *@' marker)";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Error, "legacy-import-failed", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Import: {msg}" );
return (null, diags);
}
var fileSchema = int.Parse( match.Groups["v"].Value, CultureInfo.InvariantCulture );
if ( fileSchema > SchemaVersion )
{
var msg = $"newer schema version (file={fileSchema} > designer={SchemaVersion}); please update Designer";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Error, "legacy-import-failed", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Import: {msg}" );
return (null, diags);
}
// Locate <root ...>.
var rootOpen = FindRootOpen( razorText );
if ( rootOpen < 0 )
{
var msg = "markup contains no <root> element";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Error, "legacy-import-failed", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Import: {msg}" );
return (null, diags);
}
var fresh = new DesignerDocument();
var cursor = rootOpen;
SkipPastTagOpen( razorText, ref cursor );
ParseChildren( razorText, ref cursor, fresh.RootRecord, diags, "root" );
var nameMap = new Dictionary<string, ControlRecord>( StringComparer.Ordinal );
nameMap[fresh.RootRecord.ClassName] = fresh.RootRecord;
foreach ( var r in fresh.WalkAll() )
{
if ( !string.IsNullOrEmpty( r.ClassName ) )
nameMap[r.ClassName] = r;
}
// Companion .razor.scss — same path with `.scss` appended (matches OnSave).
var scssPath = razorPath + ".scss";
if ( !File.Exists( scssPath ) )
{
var msg = $"companion stylesheet not found at {scssPath}; loaded markup only (records keep type defaults)";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Warn, "legacy-import", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Import: {msg}" );
}
else
{
string scssText;
try
{
scssText = File.ReadAllText( scssPath );
ParseScssBlocks( scssText, ( sel, body ) => ProcessScssRule( sel, body, fresh.RootRecord, nameMap, diags ) );
}
catch ( IOException ex )
{
var msg = $"cannot read .razor.scss: {ex.Message}; loaded markup only";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Warn, "legacy-import", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Import: {msg}" );
}
}
Log.Info( $"{LogPrefix} LegacyRazorImporter.Import: parsed {razorPath} (schema={fileSchema}; {diags.Count} diagnostic(s))" );
return (fresh, diags);
}
// --- DescribeLoss -------------------------------------------------------
public static IReadOnlyList<string> DescribeLoss( DesignerDocument imported, DesignerDocument current )
{
var lines = new List<string>();
if ( imported == null )
{
lines.Add( "imported document is null — re-import produced no document" );
return lines;
}
if ( current == null )
{
lines.Add( "current document is null — nothing to compare against" );
return lines;
}
// Collect all records from each tree (excluding the hidden root itself).
var importedNodes = imported.WalkAll().ToList();
var currentNodes = current.WalkAll().ToList();
var importedCount = importedNodes.Count;
var currentCount = currentNodes.Count;
if ( importedCount != currentCount )
{
lines.Add( $"imported tree has {importedCount} node(s) vs current {currentCount} node(s)" );
}
// Build ClassName sets for each side.
var importedNames = new HashSet<string>(
importedNodes.Select( r => r.ClassName ).Where( n => !string.IsNullOrEmpty( n ) ),
StringComparer.Ordinal );
var currentNames = new HashSet<string>(
currentNodes.Select( r => r.ClassName ).Where( n => !string.IsNullOrEmpty( n ) ),
StringComparer.Ordinal );
foreach ( var name in currentNames )
{
if ( !importedNames.Contains( name ) )
lines.Add( $"node '{name}' present in current IR but not in .razor — will be lost" );
}
foreach ( var name in importedNames )
{
if ( !currentNames.Contains( name ) )
lines.Add( $"node '{name}' present in .razor but not in current IR — will be added" );
}
var importedByName = importedNodes
.Where( r => !string.IsNullOrEmpty( r.ClassName ) )
.GroupBy( r => r.ClassName )
.ToDictionary( g => g.Key, g => g.First(), StringComparer.Ordinal );
var currentByName = currentNodes
.Where( r => !string.IsNullOrEmpty( r.ClassName ) )
.GroupBy( r => r.ClassName )
.ToDictionary( g => g.Key, g => g.First(), StringComparer.Ordinal );
foreach ( var kvp in importedByName )
{
if ( !currentByName.TryGetValue( kvp.Key, out var cur ) ) continue;
var imp = kvp.Value;
// Content / icon fields (payload-facing).
if ( imp.Content != cur.Content )
lines.Add( $"node '{imp.ClassName}': content differs ('{cur.Content}' → '{imp.Content}')" );
if ( imp.IconName != cur.IconName )
lines.Add( $"node '{imp.ClassName}': icon differs ('{cur.IconName}' → '{imp.IconName}')" );
if ( imp.Placeholder != cur.Placeholder )
lines.Add( $"node '{imp.ClassName}': placeholder differs ('{cur.Placeholder}' → '{imp.Placeholder}')" );
// Key appearance dimensions.
if ( imp.Width != cur.Width )
lines.Add( $"node '{imp.ClassName}': width differs ({cur.Width} → {imp.Width})" );
if ( imp.Height != cur.Height )
lines.Add( $"node '{imp.ClassName}': height differs ({cur.Height} → {imp.Height})" );
}
return lines;
}
// --- Markup parsing ----------------------------------------------------
private static int FindRootOpen( string source )
{
// Match `<root` followed by attribute-or-close char.
for ( int i = 0; i < source.Length - 5; i++ )
{
if ( source[i] != '<' ) continue;
if ( i + 4 >= source.Length ) break;
if ( source[i + 1] == 'r' && source[i + 2] == 'o' && source[i + 3] == 'o' && source[i + 4] == 't' )
{
var after = i + 5;
if ( after >= source.Length ) return -1;
var c = source[after];
if ( char.IsWhiteSpace( c ) || c == '>' || c == '/' ) return i;
}
}
return -1;
}
private static void SkipPastTagOpen( string source, ref int pos )
{
while ( pos < source.Length && source[pos] != '>' ) pos++;
if ( pos < source.Length ) pos++;
}
private static void SkipWhitespace( string source, ref int pos )
{
while ( pos < source.Length && char.IsWhiteSpace( source[pos] ) ) pos++;
}
private static void ParseChildren( string source, ref int pos, ControlRecord parent, List<ValidationDiagnostic> diags, string parentTag )
{
while ( pos < source.Length )
{
SkipWhitespace( source, ref pos );
if ( pos >= source.Length ) return;
if ( source[pos] != '<' )
{
pos++;
continue;
}
// Closing tag for parent? </parentTag>
if ( pos + 1 < source.Length && source[pos + 1] == '/' )
{
// Skip past closing tag.
while ( pos < source.Length && source[pos] != '>' ) pos++;
if ( pos < source.Length ) pos++;
return;
}
ParseElement( source, ref pos, parent, diags );
}
}
private static void ParseElement( string source, ref int pos, ControlRecord parent, List<ValidationDiagnostic> diags )
{
// pos at '<'
pos++; // skip '<'
var tagStart = pos;
while ( pos < source.Length && IsTagChar( source[pos] ) ) pos++;
var tag = source.Substring( tagStart, pos - tagStart );
var attrs = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
bool selfClose = false;
while ( pos < source.Length )
{
SkipWhitespace( source, ref pos );
if ( pos >= source.Length ) break;
if ( source[pos] == '/' && pos + 1 < source.Length && source[pos + 1] == '>' )
{
selfClose = true;
pos += 2;
break;
}
if ( source[pos] == '>' )
{
pos++;
break;
}
var nameStart = pos;
while ( pos < source.Length && source[pos] != '=' && !char.IsWhiteSpace( source[pos] )
&& source[pos] != '>' && source[pos] != '/' )
pos++;
var attrName = source.Substring( nameStart, pos - nameStart );
SkipWhitespace( source, ref pos );
string attrValue = "";
if ( pos < source.Length && source[pos] == '=' )
{
pos++;
SkipWhitespace( source, ref pos );
if ( pos < source.Length && source[pos] == '"' )
{
pos++;
var valStart = pos;
while ( pos < source.Length && source[pos] != '"' ) pos++;
attrValue = Unescape( source.Substring( valStart, pos - valStart ) );
if ( pos < source.Length ) pos++; // closing quote
}
}
if ( !string.IsNullOrEmpty( attrName ) )
attrs[attrName] = attrValue;
}
// Resolve type.
if ( !TagToType.TryGetValue( tag, out var type ) )
{
var msg = $"unknown tag <{tag}> — skipped";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Warn, "legacy-import", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Parse: {msg}" );
if ( !selfClose ) SkipToCloseTag( source, ref pos, tag );
return;
}
var defaults = ControlDefaults.For( type );
var record = new ControlRecord
{
Type = type,
ClassName = attrs.TryGetValue( "class", out var cls ) ? cls : "",
Width = defaults.DefaultWidth,
Height = defaults.DefaultHeight,
Content = defaults.DefaultContent,
IconName = defaults.DefaultIcon,
FlexGrow = defaults.DefaultFlexGrow,
Direction = defaults.DefaultDirection,
Wrap = defaults.DefaultWrap,
};
if ( attrs.TryGetValue( "slot", out var slotName ) && !string.IsNullOrEmpty( slotName ) )
{
record.IsSlot = true;
record.SlotName = slotName;
}
if ( attrs.TryGetValue( "src", out var src ) )
record.Source = src;
if ( attrs.TryGetValue( "placeholder", out var ph ) )
record.Placeholder = ph;
parent.Children.Add( record );
if ( selfClose ) return;
if ( Grains.RazorDesigner.Contracts.ContractScanner.Table.Get( type ).IsContainer )
{
ParseChildren( source, ref pos, record, diags, tag );
}
else
{
// Read inner text up to </tag>.
var textStart = pos;
while ( pos < source.Length && source[pos] != '<' ) pos++;
var raw = source.Substring( textStart, pos - textStart ).Trim();
var text = Unescape( raw );
if ( !string.IsNullOrEmpty( text ) )
{
if ( type == ControlType.IconPanel )
record.IconName = text;
else
record.Content = text;
}
// Skip </tag>.
if ( pos < source.Length && source[pos] == '<' )
{
while ( pos < source.Length && source[pos] != '>' ) pos++;
if ( pos < source.Length ) pos++;
}
}
}
private static void SkipToCloseTag( string source, ref int pos, string tag )
{
// Brace-balanced skip until matching </tag>. Tolerant of nested same-name tags.
int depth = 1;
while ( pos < source.Length && depth > 0 )
{
if ( source[pos] != '<' ) { pos++; continue; }
if ( pos + 1 < source.Length && source[pos + 1] == '/' )
{
// closing tag
var nameStart = pos + 2;
int nameEnd = nameStart;
while ( nameEnd < source.Length && IsTagChar( source[nameEnd] ) ) nameEnd++;
var name = source.Substring( nameStart, nameEnd - nameStart );
while ( pos < source.Length && source[pos] != '>' ) pos++;
if ( pos < source.Length ) pos++;
if ( string.Equals( name, tag, StringComparison.OrdinalIgnoreCase ) ) depth--;
}
else
{
// opening tag — check self-close
var nameStart = pos + 1;
int nameEnd = nameStart;
while ( nameEnd < source.Length && IsTagChar( source[nameEnd] ) ) nameEnd++;
var name = source.Substring( nameStart, nameEnd - nameStart );
bool same = string.Equals( name, tag, StringComparison.OrdinalIgnoreCase );
bool selfClose = false;
while ( pos < source.Length && source[pos] != '>' )
{
if ( source[pos] == '/' && pos + 1 < source.Length && source[pos + 1] == '>' ) { selfClose = true; break; }
pos++;
}
if ( pos < source.Length && source[pos] != '>' ) pos++;
if ( pos < source.Length ) pos++;
if ( same && !selfClose ) depth++;
}
}
}
private static bool IsTagChar( char c ) => char.IsLetterOrDigit( c ) || c == '-' || c == '_';
private static string Unescape( string s )
{
if ( string.IsNullOrEmpty( s ) ) return s;
return s
.Replace( """, "\"" )
.Replace( ">", ">" )
.Replace( "<", "<" )
.Replace( "&", "&" );
}
// --- SCSS parsing ------------------------------------------------------
private static void ParseScssBlocks( string css, Action<string, string> onRule )
{
int pos = 0;
while ( pos < css.Length )
{
SkipScssNoise( css, ref pos );
if ( pos >= css.Length ) break;
// Read selector until '{'.
var selStart = pos;
while ( pos < css.Length && css[pos] != '{' )
{
if ( IsScssCommentStart( css, pos ) ) { SkipOneScssComment( css, ref pos ); continue; }
pos++;
}
if ( pos >= css.Length ) break;
var selector = css.Substring( selStart, pos - selStart ).Trim();
pos++; // skip '{'
// Brace-balanced body.
var bodyStart = pos;
int depth = 1;
while ( pos < css.Length && depth > 0 )
{
if ( IsScssCommentStart( css, pos ) ) { SkipOneScssComment( css, ref pos ); continue; }
if ( css[pos] == '{' ) depth++;
else if ( css[pos] == '}' ) { depth--; if ( depth == 0 ) break; }
pos++;
}
var body = css.Substring( bodyStart, pos - bodyStart );
if ( pos < css.Length ) pos++; // skip '}'
if ( !string.IsNullOrEmpty( selector ) )
onRule( selector, body );
}
}
private static void SkipScssNoise( string css, ref int pos )
{
while ( pos < css.Length )
{
if ( char.IsWhiteSpace( css[pos] ) ) { pos++; continue; }
if ( IsScssCommentStart( css, pos ) ) { SkipOneScssComment( css, ref pos ); continue; }
break;
}
}
private static bool IsScssCommentStart( string css, int pos )
{
if ( pos + 1 >= css.Length ) return false;
return css[pos] == '/' && ( css[pos + 1] == '/' || css[pos + 1] == '*' );
}
private static void SkipOneScssComment( string css, ref int pos )
{
if ( css[pos + 1] == '/' )
{
while ( pos < css.Length && css[pos] != '\n' ) pos++;
return;
}
// /* ... */
pos += 2;
while ( pos + 1 < css.Length && !( css[pos] == '*' && css[pos + 1] == '/' ) ) pos++;
if ( pos + 1 < css.Length ) pos += 2;
}
private static void ProcessScssRule( string selector, string body, ControlRecord rootRecord, Dictionary<string, ControlRecord> nameMap, List<ValidationDiagnostic> diags )
{
var sel = selector.Trim();
if ( sel.StartsWith( "." ) )
{
var className = sel.Substring( 1 ).Trim();
if ( nameMap.TryGetValue( className, out var record ) )
{
ApplyDeclsAndRecurse( body, record, rootRecord, nameMap, diags );
}
else
{
var msg = $"scss rule for unknown class '.{className}' — recursing for nested rules";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Warn, "legacy-import", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Scss: {msg}" );
ParseScssBlocks( body, ( s, b ) => ProcessScssRule( s, b, rootRecord, nameMap, diags ) );
}
}
else
{
ApplyDeclsAndRecurse( body, rootRecord, rootRecord, nameMap, diags );
}
}
private static void ApplyDeclsAndRecurse( string body, ControlRecord record, ControlRecord rootRecord, Dictionary<string, ControlRecord> nameMap, List<ValidationDiagnostic> diags )
{
ParseRuleBody( body,
onDecl: ( prop, val ) => DispatchDecl( record, prop, val, diags ),
onNestedRule: ( sel, nestedBody ) =>
{
var s = sel.Trim();
if ( s.StartsWith( ">" ) && record.Type == ControlType.Checkbox && s.Contains( ".checkmark" ) )
{
ParseRuleBody( nestedBody,
onDecl: ( p, v ) =>
{
if ( string.Equals( p, "width", StringComparison.OrdinalIgnoreCase )
&& Length.TryParse( v, out var len ) )
{
record.CheckboxSize = len;
}
},
onNestedRule: ( ss, bb ) =>
{
var msg = $"unexpected nested rule inside .checkmark: {ss}";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Warn, "legacy-import", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Scss: {msg}" );
} );
return;
}
ProcessScssRule( sel, nestedBody, rootRecord, nameMap, diags );
} );
}
private static void ParseRuleBody( string body, Action<string, string> onDecl, Action<string, string> onNestedRule )
{
int pos = 0;
while ( pos < body.Length )
{
SkipScssNoise( body, ref pos );
if ( pos >= body.Length ) break;
// Lookahead: find next ';' or '{', honoring comments.
int scan = pos;
while ( scan < body.Length && body[scan] != ';' && body[scan] != '{' )
{
if ( IsScssCommentStart( body, scan ) ) { SkipOneScssComment( body, ref scan ); continue; }
scan++;
}
if ( scan >= body.Length ) break;
if ( body[scan] == ';' )
{
var declText = body.Substring( pos, scan - pos ).Trim();
pos = scan + 1;
var colon = declText.IndexOf( ':' );
if ( colon > 0 )
{
var prop = declText.Substring( 0, colon ).Trim();
var val = declText.Substring( colon + 1 ).Trim();
onDecl( prop, val );
}
}
else // '{'
{
var selector = body.Substring( pos, scan - pos ).Trim();
pos = scan + 1;
var bodyStart = pos;
int depth = 1;
while ( pos < body.Length && depth > 0 )
{
if ( IsScssCommentStart( body, pos ) ) { SkipOneScssComment( body, ref pos ); continue; }
if ( body[pos] == '{' ) depth++;
else if ( body[pos] == '}' ) { depth--; if ( depth == 0 ) break; }
pos++;
}
var nestedBody = body.Substring( bodyStart, pos - bodyStart );
if ( pos < body.Length ) pos++;
onNestedRule( selector, nestedBody );
}
}
}
// --- Decl dispatch -----------------------------------------------------
private static void DispatchDecl( ControlRecord r, string prop, string val, List<ValidationDiagnostic> diags )
{
switch ( prop.ToLowerInvariant() )
{
case "width": if ( Length.TryParse( val, out var w ) ) r.Width = w; else WarnDecl( prop, val, diags ); break;
case "height": if ( Length.TryParse( val, out var h ) ) r.Height = h; else WarnDecl( prop, val, diags ); break;
case "flex-basis": if ( Length.TryParse( val, out var fb ) ) r.FlexBasis = fb; else WarnDecl( prop, val, diags ); break;
case "flex-grow": if ( TryParseFloat( val, out var fg ) ) r.FlexGrow = fg; else WarnDecl( prop, val, diags ); break;
case "flex-shrink": if ( TryParseFloat( val, out var fs ) ) r.FlexShrink = fs; else WarnDecl( prop, val, diags ); break;
case "flex-direction":
if ( TryParseDirection( val, out var dir ) ) r.Direction = dir;
else WarnDecl( prop, val, diags );
break;
case "justify-content":
if ( TryParseJustify( val, out var jc ) ) r.Justify = jc;
else WarnDecl( prop, val, diags );
break;
case "align-items":
if ( TryParseAlign( val, out var ai ) ) r.Align = ai;
else WarnDecl( prop, val, diags );
break;
case "flex-wrap":
if ( TryParseWrap( val, out var fw ) ) r.Wrap = fw;
else WarnDecl( prop, val, diags );
break;
case "gap":
if ( TryParsePxFloat( val, out var gap ) ) r.Gap = gap;
else WarnDecl( prop, val, diags );
break;
case "padding":
if ( Edges.TryParse( val, out var pad ) ) r.Padding = pad;
else WarnDecl( prop, val, diags );
break;
// Typography group
case "font-family":
r.FontFamily = val;
r.OverrideTypography = true;
break;
case "font-size":
if ( Length.TryParse( val, out var fontSize ) ) r.FontSize = fontSize;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideTypography = true;
break;
case "font-weight":
if ( int.TryParse( val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var fwt ) ) r.FontWeight = fwt;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideTypography = true;
break;
case "color":
if ( TryParseColor( val, out var col ) ) r.Color = col;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideTypography = true;
break;
case "text-align":
if ( TryParseTextAlign( val, out var ta ) ) r.TextAlign = ta;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideTypography = true;
break;
// Background group
case "background-color":
if ( TryParseColor( val, out var bgc ) ) r.BackgroundColor = bgc;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideBackground = true;
break;
case "background-image":
r.BackgroundImage = string.Equals( val, "none", StringComparison.OrdinalIgnoreCase ) ? "" : val;
r.OverrideBackground = true;
break;
// Border group
case "border-color":
if ( TryParseColor( val, out var bc ) ) r.BorderColor = bc;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideBorder = true;
break;
case "border-radius":
if ( Length.TryParse( val, out var br ) ) r.BorderRadius = br;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideBorder = true;
break;
case "border-width":
if ( Length.TryParse( val, out var bw ) ) r.BorderWidth = bw;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideBorder = true;
break;
// Effects group
case "box-shadow":
if ( TryParseBoxShadow( val, out var bsX, out var bsY, out var bsBlur, out var bsColor, out var bsInset ) )
{
r.BoxShadowX = bsX;
r.BoxShadowY = bsY;
r.BoxShadowBlur = bsBlur;
r.BoxShadowColor = bsColor;
r.BoxShadowInset = bsInset;
}
else { WarnDecl( prop, val, diags ); break; }
r.OverrideEffects = true;
break;
case "opacity":
if ( TryParseFloat( val, out var op ) ) r.Opacity = op;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideEffects = true;
break;
// Constraints group
case "margin":
if ( Edges.TryParse( val, out var mg ) ) r.Margin = mg;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideConstraints = true;
break;
case "min-width":
if ( Length.TryParse( val, out var mnw ) ) r.MinWidth = mnw;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideConstraints = true;
break;
case "max-width":
if ( Length.TryParse( val, out var mxw ) ) r.MaxWidth = mxw;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideConstraints = true;
break;
case "min-height":
if ( Length.TryParse( val, out var mnh ) ) r.MinHeight = mnh;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideConstraints = true;
break;
case "max-height":
if ( Length.TryParse( val, out var mxh ) ) r.MaxHeight = mxh;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideConstraints = true;
break;
// Interaction group
case "cursor":
if ( TryParseCursor( val, out var cur ) ) r.Cursor = cur;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideInteraction = true;
break;
case "overflow":
if ( TryParseOverflow( val, out var ov ) ) r.Overflow = ov;
else { WarnDecl( prop, val, diags ); break; }
r.OverrideInteraction = true;
break;
default:
var msg = $"unknown css property '{prop}: {val}' on .{r.ClassName} — dropped";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Warn, "legacy-import", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Scss: {msg}" );
break;
}
}
private static void WarnDecl( string prop, string val, List<ValidationDiagnostic> diags )
{
var msg = $"could not parse '{prop}: {val}' — kept default";
diags.Add( new ValidationDiagnostic( null, DiagnosticSeverity.Warn, "legacy-import", msg ) );
Log.Warning( $"{LogPrefix} LegacyRazorImporter.Scss: {msg}" );
}
private static bool TryParseFloat( string s, out float v ) =>
float.TryParse( s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out v );
private static bool TryParsePxFloat( string s, out float v )
{
var t = s.Trim();
if ( t.EndsWith( "px", StringComparison.OrdinalIgnoreCase ) )
t = t.Substring( 0, t.Length - 2 ).Trim();
return float.TryParse( t, NumberStyles.Float, CultureInfo.InvariantCulture, out v );
}
private static bool TryParseDirection( string s, out FlexDirection v )
{
switch ( s.Trim().ToLowerInvariant() )
{
case "row": v = FlexDirection.Row; return true;
case "column": v = FlexDirection.Column; return true;
default: v = FlexDirection.Row; return false;
}
}
private static bool TryParseJustify( string s, out JustifyContent v )
{
switch ( s.Trim().ToLowerInvariant() )
{
case "flex-start": case "start": v = JustifyContent.Start; return true;
case "center": v = JustifyContent.Center; return true;
case "flex-end": case "end": v = JustifyContent.End; return true;
case "space-between": v = JustifyContent.SpaceBetween; return true;
case "space-around": v = JustifyContent.SpaceAround; return true;
default: v = JustifyContent.Start; return false;
}
}
private static bool TryParseAlign( string s, out AlignItems v )
{
switch ( s.Trim().ToLowerInvariant() )
{
case "flex-start": case "start": v = AlignItems.Start; return true;
case "center": v = AlignItems.Center; return true;
case "flex-end": case "end": v = AlignItems.End; return true;
case "stretch": v = AlignItems.Stretch; return true;
default: v = AlignItems.Stretch; return false;
}
}
private static bool TryParseWrap( string s, out FlexWrap v )
{
switch ( s.Trim().ToLowerInvariant() )
{
case "nowrap": v = FlexWrap.NoWrap; return true;
case "wrap": v = FlexWrap.Wrap; return true;
case "wrap-reverse": v = FlexWrap.WrapReverse; return true;
default: v = FlexWrap.NoWrap; return false;
}
}
private static bool TryParseTextAlign( string s, out TextAlignment v )
{
switch ( s.Trim().ToLowerInvariant() )
{
case "left": v = TextAlignment.Left; return true;
case "center": v = TextAlignment.Center; return true;
case "right": v = TextAlignment.Right; return true;
default: v = TextAlignment.Left; return false;
}
}
private static bool TryParseCursor( string s, out CursorKind v )
{
switch ( s.Trim().ToLowerInvariant() )
{
case "auto": v = CursorKind.Auto; return true;
case "default": v = CursorKind.Default; return true;
case "pointer": v = CursorKind.Pointer; return true;
case "text": v = CursorKind.Text; return true;
case "grab": v = CursorKind.Grab; return true;
case "grabbing": v = CursorKind.Grabbing; return true;
case "wait": v = CursorKind.Wait; return true;
case "crosshair": v = CursorKind.Crosshair; return true;
case "move": v = CursorKind.Move; return true;
case "not-allowed": v = CursorKind.NotAllowed; return true;
case "none": v = CursorKind.None; return true;
default: v = CursorKind.Auto; return false;
}
}
private static bool TryParseOverflow( string s, out OverflowKind v )
{
switch ( s.Trim().ToLowerInvariant() )
{
case "visible": v = OverflowKind.Visible; return true;
case "hidden": v = OverflowKind.Hidden; return true;
case "scroll": v = OverflowKind.Scroll; return true;
case "clip": v = OverflowKind.Clip; return true;
case "clip-whole": v = OverflowKind.ClipWhole; return true;
default: v = OverflowKind.Visible; return false;
}
}
private static bool TryParseBoxShadow( string s, out Length x, out Length y, out Length blur, out Color color, out bool inset )
{
x = Length.Px( 0 ); y = Length.Px( 0 ); blur = Length.Px( 0 ); color = Color.Black; inset = false;
var parts = s.Trim().Split( (char[])null, StringSplitOptions.RemoveEmptyEntries );
if ( parts.Length < 4 ) return false;
int n = parts.Length;
if ( string.Equals( parts[n - 1], "inset", StringComparison.OrdinalIgnoreCase ) )
{
inset = true;
n--;
}
if ( n < 4 ) return false;
if ( !Length.TryParse( parts[0], out x ) ) return false;
if ( !Length.TryParse( parts[1], out y ) ) return false;
if ( !Length.TryParse( parts[2], out blur ) ) return false;
if ( !TryParseColor( parts[3], out color ) ) return false;
return true;
}
private static bool TryParseColor( string s, out Color color )
{
color = Color.White;
if ( string.IsNullOrWhiteSpace( s ) ) return false;
var t = s.Trim();
if ( !t.StartsWith( "#" ) ) return false;
var hex = t.Substring( 1 );
int r, g, b, a = 255;
if ( hex.Length == 6 )
{
if ( !TryHex( hex.Substring( 0, 2 ), out r ) ) return false;
if ( !TryHex( hex.Substring( 2, 2 ), out g ) ) return false;
if ( !TryHex( hex.Substring( 4, 2 ), out b ) ) return false;
}
else if ( hex.Length == 8 )
{
if ( !TryHex( hex.Substring( 0, 2 ), out r ) ) return false;
if ( !TryHex( hex.Substring( 2, 2 ), out g ) ) return false;
if ( !TryHex( hex.Substring( 4, 2 ), out b ) ) return false;
if ( !TryHex( hex.Substring( 6, 2 ), out a ) ) return false;
}
else if ( hex.Length == 3 )
{
// #RGB shorthand — duplicate each nibble.
if ( !TryHex( "" + hex[0] + hex[0], out r ) ) return false;
if ( !TryHex( "" + hex[1] + hex[1], out g ) ) return false;
if ( !TryHex( "" + hex[2] + hex[2], out b ) ) return false;
}
else
{
return false;
}
color = new Color( r / 255f, g / 255f, b / 255f, a / 255f );
return true;
}
private static bool TryHex( string s, out int v ) =>
int.TryParse( s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out v );
}
using System;
using System.Collections.Generic;
using Editor;
using Sandbox;
namespace Grains.RazorDesigner.Serialization;
public sealed class LossPreviewDialog
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private readonly Widget _parent;
public LossPreviewDialog( Widget parent )
{
_parent = parent;
}
public void Show(
IReadOnlyList<string> lossLines,
string razorPath,
Action onConfirm,
Action onCancel = null )
{
var dialog = new Editor.Dialog( _parent );
dialog.Window.WindowTitle = "Re-import from .razor — Data Loss Preview";
dialog.Window.SetWindowIcon( "warning" );
dialog.Window.SetModal( true, true );
dialog.Window.MinimumWidth = 480;
dialog.Layout = Layout.Column();
dialog.Layout.Margin = 16;
dialog.Layout.Spacing = 10;
var header = new Editor.Label( dialog )
{
Text = $"Re-importing from:\n{razorPath}\n\n" +
"The following differences were detected between the current IR and the " +
".razor file. These items may be lost if you proceed:",
WordWrap = true,
};
dialog.Layout.Add( header );
// Scrollable list of loss lines.
var scroll = new ScrollArea( dialog );
scroll.Canvas = new Widget();
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = 4;
scroll.Canvas.Layout.Spacing = 2;
scroll.MinimumHeight = 120;
scroll.MaximumHeight = 280;
foreach ( var line in lossLines )
{
var lbl = new Editor.Label( scroll.Canvas )
{
Text = $"• {line}",
WordWrap = true,
};
lbl.SetStyles( "font-size: 11px; color: #e0a060;" );
scroll.Canvas.Layout.Add( lbl );
}
dialog.Layout.Add( scroll );
dialog.Layout.AddSeparator();
var buttonRow = dialog.Layout.Add( Layout.Row() );
buttonRow.Spacing = 6;
buttonRow.AddStretchCell();
var cancelBtn = new Editor.Button( dialog ) { Text = "Cancel", MinimumWidth = 80 };
cancelBtn.MouseLeftPress += () =>
{
Log.Info( $"{LogPrefix} LossPreviewDialog: user chose Cancel" );
dialog.Close();
onCancel?.Invoke();
};
buttonRow.Add( cancelBtn );
var confirmBtn = new Editor.Button( dialog ) { Text = "Re-import anyway", MinimumWidth = 120 };
confirmBtn.MouseLeftPress += () =>
{
Log.Info( $"{LogPrefix} LossPreviewDialog: user confirmed Re-import" );
dialog.Close();
onConfirm?.Invoke();
};
buttonRow.Add( confirmBtn );
dialog.Window.AdjustSize();
dialog.Show();
Log.Info( $"{LogPrefix} LossPreviewDialog: shown ({lossLines.Count} loss line(s))" );
}
}
using System;
using System.Collections.Generic;
using Editor;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Templates;
public sealed class SaveTemplateDialog
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private static readonly char[] InvalidNameChars = { '/', '\\', ':', '*', '?', '"', '<', '>', '|' };
private readonly Widget _parent;
private readonly PaletteTemplateStore _store;
private readonly IReadOnlyList<ControlRecord> _selectedRoots;
private readonly ControlRecord _wrapInheritFrom;
private Editor.Dialog _dialog;
private LineEdit _nameEdit;
private Editor.Label _errorLabel;
private Editor.Label _wrapHint;
private Checkbox _wrapCheckbox;
private Editor.Button _saveButton;
private IconHolder _iconHolder;
public SaveTemplateDialog( Widget parent, PaletteTemplateStore store, IReadOnlyList<ControlRecord> selectedRoots, ControlRecord wrapInheritFrom )
{
_parent = parent;
_store = store;
_selectedRoots = selectedRoots;
_wrapInheritFrom = wrapInheritFrom;
_iconHolder = new IconHolder { Icon = "" };
}
public void Show( Action<PaletteTemplate> onConfirm, Action onCancel = null )
{
_dialog = new Editor.Dialog( _parent );
_dialog.Window.WindowTitle = "Save as Template";
_dialog.Window.SetWindowIcon( "bookmark_add" );
_dialog.Window.SetModal( true, true );
_dialog.Window.MinimumWidth = 360;
_dialog.Layout = Layout.Column();
_dialog.Layout.Margin = 16;
_dialog.Layout.Spacing = 10;
// Name field with live validation.
_dialog.Layout.Add( new Editor.Label( _dialog ) { Text = "Name" } );
_nameEdit = new LineEdit( _dialog ) { PlaceholderText = "ButtonRow" };
_nameEdit.TextEdited += _ => RevalidateName();
_dialog.Layout.Add( _nameEdit );
_errorLabel = new Editor.Label( _dialog ) { Text = "" };
_errorLabel.SetStyles( "color: #e07070; font-size: 11px;" );
_dialog.Layout.Add( _errorLabel );
// Icon picker — engine [IconName] + ControlSheet binding.
_dialog.Layout.Add( new Editor.Label( _dialog ) { Text = "Icon" } );
var iconSheet = new ControlSheet();
iconSheet.IncludePropertyNames = false;
var serialized = EditorTypeLibrary.GetSerializedObject( _iconHolder );
iconSheet.AddObject( serialized );
_dialog.Layout.Add( iconSheet );
// Wrap checkbox + hint.
var canWrap = _wrapInheritFrom is not null && _selectedRoots is { Count: > 1 };
_wrapCheckbox = new Checkbox( "Wrap selected controls in a new Panel", _dialog );
_wrapCheckbox.Enabled = canWrap;
_wrapCheckbox.Value = false;
_wrapCheckbox.ToolTip = canWrap
? $"Inherits Direction / Wrap / Justify / Align from \"{_wrapInheritFrom.ClassName}\"."
: "Only meaningful when 2+ siblings are selected.";
_dialog.Layout.Add( _wrapCheckbox );
_wrapHint = new Editor.Label( _dialog )
{
Text = canWrap
? "Disabled when the selection is a single root or spans multiple parents."
: "(Save the selection as-is.)",
};
_wrapHint.SetStyles( "color: #888; font-size: 11px;" );
_dialog.Layout.Add( _wrapHint );
// Buttons.
var buttonRow = _dialog.Layout.Add( Layout.Row() );
buttonRow.Spacing = 6;
buttonRow.AddStretchCell();
var cancelButton = new Editor.Button( _dialog ) { Text = "Cancel", MinimumWidth = 72 };
cancelButton.MouseLeftPress += () =>
{
Log.Info( $"{LogPrefix} SaveTemplateDialog cancelled" );
_dialog.Close();
onCancel?.Invoke();
};
buttonRow.Add( cancelButton );
_saveButton = new Editor.Button( _dialog ) { Text = "Save", MinimumWidth = 72 };
_saveButton.MouseLeftPress += () => OnSave( onConfirm );
buttonRow.Add( _saveButton );
_dialog.Window.AdjustSize();
_dialog.Show();
RevalidateName();
_nameEdit.Focus();
}
private void RevalidateName()
{
var name = (_nameEdit.Text ?? "").Trim();
string error = null;
if ( string.IsNullOrEmpty( name ) )
{
error = "Name required.";
}
else if ( name.IndexOfAny( InvalidNameChars ) >= 0 )
{
error = "Name contains an invalid character (/ \\ : * ? \" < > |).";
}
else if ( _store.NameExists( name ) )
{
error = $"A template named \"{name}\" already exists.";
}
_errorLabel.Text = error ?? "";
_saveButton.Enabled = error is null;
}
private void OnSave( Action<PaletteTemplate> onConfirm )
{
var name = (_nameEdit.Text ?? "").Trim();
var icon = _iconHolder.Icon ?? "";
var wrap = _wrapCheckbox.Enabled && _wrapCheckbox.Value;
IReadOnlyList<ControlRecord> roots;
if ( wrap && _wrapInheritFrom is not null )
{
var wrapper = new ControlRecord
{
Type = ControlType.Panel,
ClassName = "wrapper",
Width = Length.Auto,
Height = Length.Auto,
Direction = _wrapInheritFrom.Direction,
Wrap = _wrapInheritFrom.Wrap,
Justify = _wrapInheritFrom.Justify,
Align = _wrapInheritFrom.Align,
};
foreach ( var r in _selectedRoots )
{
if ( r is null ) continue;
wrapper.Children.Add( CloneSerialisable( r ) );
}
roots = new[] { wrapper };
}
else
{
var list = new List<ControlRecord>( _selectedRoots.Count );
foreach ( var r in _selectedRoots )
{
if ( r is null ) continue;
list.Add( CloneSerialisable( r ) );
}
roots = list;
}
// FilePath is overwritten by Store.Save with the actual on-disk path.
var template = new PaletteTemplate(
Name: name,
IconName: icon,
WrappedInContainer: wrap,
Roots: roots,
FilePath: "" );
Log.Info( $"{LogPrefix} SaveTemplateDialog confirm: \"{name}\", icon=\"{icon}\", wrap={wrap}, roots={roots.Count}" );
_dialog.Close();
onConfirm?.Invoke( template );
}
private static ControlRecord CloneSerialisable( ControlRecord src )
{
var clone = new ControlRecord
{
Type = src.Type,
ClassName = src.ClassName,
};
src.CopyFieldsTo( clone );
foreach ( var c in src.Children )
clone.Children.Add( CloneSerialisable( c ) );
return clone;
}
private sealed class IconHolder
{
[IconName]
[Title( "" )]
public string Icon { get; set; }
}
}