Search the source of every open source package.
924 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.Text.Json;
using SboxMcp.Server;
using Xunit;
namespace SboxMcp.Tests;
public class ProtocolTests
{
[Fact]
public void Parse_request_with_id_and_params()
{
var req = JsonRpcRequest.Parse( """{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"x"}}""" );
Assert.False( req.IsNotification );
Assert.Equal( 7, req.Id.Value.GetInt32() );
Assert.Equal( "tools/call", req.Method );
Assert.Equal( "x", req.Params.Value.GetProperty( "name" ).GetString() );
}
[Fact]
public void Parse_notification_has_no_id()
{
var req = JsonRpcRequest.Parse( """{"jsonrpc":"2.0","method":"notifications/initialized"}""" );
Assert.True( req.IsNotification );
Assert.Equal( "notifications/initialized", req.Method );
}
[Fact]
public void Parse_invalid_json_throws()
{
Assert.Throws<JsonRpcParseException>( () => JsonRpcRequest.Parse( "{nope" ) );
}
[Fact]
public void Parse_missing_method_throws()
{
Assert.Throws<JsonRpcParseException>( () => JsonRpcRequest.Parse( """{"jsonrpc":"2.0","id":1}""" ) );
}
[Fact]
public void Writer_result_emits_envelope()
{
var id = JsonDocument.Parse( "3" ).RootElement;
var json = JsonRpcWriter.Result( id, new { protocolVersion = "2025-06-18" } );
var doc = JsonDocument.Parse( json ).RootElement;
Assert.Equal( "2.0", doc.GetProperty( "jsonrpc" ).GetString() );
Assert.Equal( 3, doc.GetProperty( "id" ).GetInt32() );
Assert.Equal( "2025-06-18", doc.GetProperty( "result" ).GetProperty( "protocolVersion" ).GetString() );
}
[Fact]
public void Writer_error_emits_code_and_message()
{
var json = JsonRpcWriter.Error( null, JsonRpcError.MethodNotFound, "no such method" );
var doc = JsonDocument.Parse( json ).RootElement;
Assert.Equal( JsonValueKind.Null, JsonKind( doc, "id" ) );
Assert.Equal( -32601, doc.GetProperty( "error" ).GetProperty( "code" ).GetInt32() );
Assert.Equal( "no such method", doc.GetProperty( "error" ).GetProperty( "message" ).GetString() );
}
[Fact]
public void Records_serialize_camel_case()
{
var schema = JsonDocument.Parse( """{"type":"object"}""" ).RootElement;
var json = JsonRpcWriter.Result( null,
McpResults.ToolsList( new[] { new McpToolDescriptor( "a_tool", "does things", schema ) } ) );
var doc = JsonDocument.Parse( json ).RootElement;
var tool = doc.GetProperty( "result" ).GetProperty( "tools" )[0];
Assert.Equal( "a_tool", tool.GetProperty( "name" ).GetString() );
Assert.Equal( "does things", tool.GetProperty( "description" ).GetString() );
Assert.Equal( "object", tool.GetProperty( "inputSchema" ).GetProperty( "type" ).GetString() );
}
[Fact]
public void Version_negotiation()
{
// only 2025-06-18 is supported (older revisions require JSON-RPC batching)
Assert.Equal( "2025-06-18", McpVersion.Negotiate( "2025-06-18" ) );
Assert.Equal( "2025-06-18", McpVersion.Negotiate( "2025-03-26" ) );
Assert.Equal( "2025-06-18", McpVersion.Negotiate( null ) );
}
[Fact]
public void Null_id_is_rejected()
{
Assert.Throws<JsonRpcParseException>( () =>
JsonRpcRequest.Parse( """{"jsonrpc":"2.0","id":null,"method":"ping"}""" ) );
}
[Fact]
public void Text_content_shape()
{
var json = JsonRpcWriter.Result( null, McpResults.TextContent( "hello", isError: true ) );
var result = JsonDocument.Parse( json ).RootElement.GetProperty( "result" );
Assert.Equal( "text", result.GetProperty( "content" )[0].GetProperty( "type" ).GetString() );
Assert.Equal( "hello", result.GetProperty( "content" )[0].GetProperty( "text" ).GetString() );
Assert.True( result.GetProperty( "isError" ).GetBoolean() );
}
[Fact]
public void Image_content_shape()
{
var json = JsonRpcWriter.Result( null, McpResults.ImageContent( "QUJD", "a screenshot" ) );
var content = JsonDocument.Parse( json ).RootElement.GetProperty( "result" ).GetProperty( "content" );
Assert.Equal( "image", content[0].GetProperty( "type" ).GetString() );
Assert.Equal( "QUJD", content[0].GetProperty( "data" ).GetString() );
Assert.Equal( "image/png", content[0].GetProperty( "mimeType" ).GetString() );
Assert.Equal( "a screenshot", content[1].GetProperty( "text" ).GetString() );
}
static JsonValueKind JsonKind( JsonElement el, string prop ) =>
el.TryGetProperty( prop, out var v ) ? v.ValueKind : JsonValueKind.Undefined;
}
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Maths;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
namespace HumanoidRetargeter.Cleanup;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>Tunables for the grounded-foot stance recalibration pass.</summary>
public sealed class FootGroundAlignOptions
{
/// <summary>
/// Dead zone (degrees): measured stance offsets at or below this are genuine planted
/// articulation (heel-roll bias, natural lean — measured 2–4° on well-rested rigs and
/// on citizen clips) and are left untouched, keeping the transfer byte-faithful there.
/// Only offsets beyond it are clearly rest-pose artifacts (measured 12–25° on the
/// repro rig) and get recalibrated.
/// </summary>
public float MinCorrectionDeg { get; set; } = 8f;
/// <summary>
/// Maximum mean sole deviation (degrees) a plant may show and still count as a STANCE
/// for the offset measurement. Plants beyond this are not standing on the sole (crawls,
/// kneels, prone contact — measured 60–90° there) and are excluded; genuine rest-pose
/// stance artifacts measure well below it (largest seen: 27°).
/// </summary>
public float MaxStanceDeviationDeg { get; set; } = 35f;
}
/// <summary>Per-foot results of a <see cref="FootGroundAlign.Apply"/> run.</summary>
public sealed class FootGroundAlignFootReport
{
/// <summary>Plants that contributed to the stance measurement.</summary>
public int StancePlants { get; set; }
/// <summary>Plants excluded as non-stance (mean sole deviation beyond
/// <see cref="FootGroundAlignOptions.MaxStanceDeviationDeg"/>).</summary>
public int SkippedPlants { get; set; }
/// <summary>Measured planted sole offset from the ground plane, degrees (0 when no
/// stance plants exist).</summary>
public float MeasuredOffsetDeg { get; set; }
/// <summary>Foot correction applied to every frame, degrees (0 = inside the dead zone,
/// nothing changed).</summary>
public float AppliedFootDeg { get; set; }
/// <summary>Toe correction applied to every frame, degrees.</summary>
public float AppliedToeDeg { get; set; }
}
/// <summary>Results of a <see cref="FootGroundAlign.Apply"/> run.</summary>
public sealed class FootGroundAlignReport
{
/// <summary>Left-foot results.</summary>
public required FootGroundAlignFootReport Left { get; init; }
/// <summary>Right-foot results.</summary>
public required FootGroundAlignFootReport Right { get; init; }
}
/// <summary>
/// Grounded-foot stance recalibration: measures how far the foot's SOLE sits from the ground
/// plane while planted, and — when that offset is clearly a rest-pose artifact — rotates it
/// out with one constant per foot, applied to every frame of the clip.
/// </summary>
/// <remarks>
/// <para><b>Why a cleanup pass.</b> The solver transfers feet as rest-relative deltas
/// (<see cref="Solve.RoleTransferMode.CharacterDeltaFromRest"/>), so the target keeps its own
/// ankle anatomy — correct whenever the source's rest pose is a flat-footed stance (the delta
/// is then "deviation from standing"). Some rigs ship a NON-stance rest (measured: an
/// Auto-Rig-Pro export whose rest foot sits 12–25° from its planted stance), and that constant
/// offset rides into every frame of the replay — planted feet hover toe-down/heel-up. What a
/// stance actually looks like is animation evidence (planted phases), which a per-frame
/// solver cannot see, so the recalibration lives here.</para>
/// <para><b>Measurement.</b> Per foot: over every planted frame, the sole normal = rest up
/// carried by the foot's world delta from the target bind rest (whose feet stand on the
/// ground by construction); plants whose own mean normal sits beyond
/// <see cref="FootGroundAlignOptions.MaxStanceDeviationDeg"/> are excluded (crawl/kneel/prone
/// contact is not a stance). The pooled mean normal's deviation from up is the stance
/// offset.</para>
/// <para><b>Correction.</b> Offsets inside <see cref="FootGroundAlignOptions.MinCorrectionDeg"/>
/// are genuine articulation — nothing is changed (well-rested rigs and same-rig round trips
/// stay byte-identical through this pass). Beyond it, the shortest-arc rotation taking the
/// pooled normal back to up (pitch+roll only — yaw/toe-out is pose and follows the source)
/// premultiplies the foot's world rotation on EVERY frame: a rest artifact is constant, so
/// the fix is too — within-plant heel-roll, swing styling and frame-to-frame continuity are
/// preserved exactly, and no blending is needed. The toe then receives its own residual
/// constant measured on top of the corrected foot (it neither double-rotates with the foot
/// fix nor inherits the source toe's own rest artifact). Corrections rotate bones about
/// their own joints: ankle positions are untouched, so the pass composes freely with the
/// <see cref="FootPlant"/> position pinning (which preserves foot world rotations).</para>
/// <para><b>Plant intervals come from the caller</b> (the pipeline detects them on the
/// SOURCE clip via <see cref="FootPlant.DetectPlantIntervals"/> — ground truth, immune to
/// the hip-height rescaling that can push target-side trajectories outside the cm-tuned
/// Kovar thresholds). So does the decision to run at all: the pipeline invokes this pass
/// only when the source's normalized rest is implausible as a flat stance (toe at/above
/// ankle level or asymmetric feet — see <c>Retargeter.GroundAlignFeet</c>); on plausible
/// stance rests the solver's rest-relative transfer is already faithful and planted-sole
/// deviations are genuine articulation (boxing stances, heel rolls) that must not be
/// flattened.</para>
/// </remarks>
public static class FootGroundAlign
{
/// <summary>Measures planted stance offsets and recalibrates feet whose offset is a
/// rest-pose artifact; returns what was measured and done.</summary>
/// <param name="frames">Per-frame local transforms (skeleton bone order); modified in place.</param>
/// <param name="skeleton">Bone hierarchy the frames are expressed against; its bind rest
/// is the flat-stance reference.</param>
/// <param name="left">Left leg chain bone indices.</param>
/// <param name="right">Right leg chain bone indices.</param>
/// <param name="up">World up direction of the clip's space.</param>
/// <param name="leftPlants">Left-foot plant intervals (frame indices into
/// <paramref name="frames"/>; out-of-range parts are clamped/ignored).</param>
/// <param name="rightPlants">Right-foot plant intervals.</param>
/// <param name="options">Tunables; defaults used when null.</param>
public static FootGroundAlignReport Apply(
List<XForm[]> frames,
SkeletonModel skeleton,
FootChain left,
FootChain right,
Vector3 up,
IReadOnlyList<FrameRange> leftPlants,
IReadOnlyList<FrameRange> rightPlants,
FootGroundAlignOptions? options = null)
{
ArgumentNullException.ThrowIfNull(frames);
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(right);
ArgumentNullException.ThrowIfNull(leftPlants);
ArgumentNullException.ThrowIfNull(rightPlants);
options ??= new FootGroundAlignOptions();
var report = new FootGroundAlignReport
{
Left = new FootGroundAlignFootReport(),
Right = new FootGroundAlignFootReport(),
};
if (frames.Count == 0 || up.LengthSquared() < 1e-12f)
return report;
up = Vector3.Normalize(up);
RecalibrateFoot(frames, skeleton, left, up, leftPlants, options, report.Left);
RecalibrateFoot(frames, skeleton, right, up, rightPlants, options, report.Right);
return report;
}
private static void RecalibrateFoot(
List<XForm[]> frames, SkeletonModel skeleton, FootChain chain, Vector3 up,
IReadOnlyList<FrameRange> plants, FootGroundAlignOptions options,
FootGroundAlignFootReport report)
{
int n = frames.Count;
var foot = chain.Ankle;
var restFootRotInv = Quaternion.Conjugate(skeleton.RestWorld[foot].Rot);
var maxStanceCos = MathF.Cos(options.MaxStanceDeviationDeg * MathF.PI / 180f);
// ---- measurement: pooled planted sole normal over the stance plants ----
var pooled = Vector3.Zero;
foreach (var plant in plants)
{
int start = Math.Max(plant.Start, 0);
int end = Math.Min(plant.End, n - 1);
if (start > end)
continue;
var plantSum = Vector3.Zero;
for (int f = start; f <= end; f++)
{
var footRot = FkUtil.BoneWorld(frames[f], skeleton, foot).Rot;
plantSum += Vector3.Transform(up, MathQ.Normalize(footRot * restFootRotInv));
}
if (plantSum.LengthSquared() < 1e-8f
|| Vector3.Dot(Vector3.Normalize(plantSum), up) < maxStanceCos)
{
report.SkippedPlants++; // not standing on the sole — crawl/kneel/toe contact
continue;
}
report.StancePlants++;
pooled += plantSum; // frame-count-weighted: longer stances dominate
}
if (pooled.LengthSquared() < 1e-8f)
return;
pooled = Vector3.Normalize(pooled);
var offsetDeg = MathQ.AngleBetween(pooled, up) * (180f / MathF.PI);
report.MeasuredOffsetDeg = offsetDeg;
if (offsetDeg <= options.MinCorrectionDeg)
return; // genuine planted articulation — leave the transfer byte-faithful
// ---- correction: one constant per foot, every frame ----
var footFix = MathQ.FromTo(pooled, up);
report.AppliedFootDeg = offsetDeg;
// Toe residual measured on top of the corrected foot, same dead zone.
var toeFix = Quaternion.Identity;
if (chain.Toe is { } toe && skeleton[toe].ParentIndex == foot)
{
var restToeRotInv = Quaternion.Conjugate(skeleton.RestWorld[toe].Rot);
var toePooled = Vector3.Zero;
foreach (var plant in plants)
{
int start = Math.Max(plant.Start, 0);
int end = Math.Min(plant.End, n - 1);
for (int f = start; f <= end && f >= 0; f++)
{
var toeRot = FkUtil.BoneWorld(frames[f], skeleton, toe).Rot;
toePooled += Vector3.Transform(
up, MathQ.Normalize(footFix * toeRot * restToeRotInv));
}
}
if (toePooled.LengthSquared() > 1e-8f)
{
toePooled = Vector3.Normalize(toePooled);
var toeDeg = MathQ.AngleBetween(toePooled, up) * (180f / MathF.PI);
if (toeDeg > options.MinCorrectionDeg && Vector3.Dot(toePooled, up) >= maxStanceCos)
{
toeFix = MathQ.FromTo(toePooled, up);
report.AppliedToeDeg = toeDeg;
}
}
}
for (int f = 0; f < n; f++)
CorrectFrame(frames[f], skeleton, chain, footFix, toeFix);
}
/// <summary>Premultiplies the foot's world rotation by the constant fix (the joint
/// position is untouched — the rotation pivots the foot about its own head), then gives
/// the toe its own residual on top of the corrected foot.</summary>
private static void CorrectFrame(
XForm[] locals, SkeletonModel skeleton, FootChain chain,
Quaternion footFix, Quaternion toeFix)
{
var foot = chain.Ankle;
var parent = skeleton[foot].ParentIndex;
var parentRot = parent < 0
? Quaternion.Identity
: FkUtil.BoneWorld(locals, skeleton, parent).Rot;
var footWorld = MathQ.Normalize(parentRot * locals[foot].Rot);
var newFootWorld = MathQ.Normalize(footFix * footWorld);
locals[foot] = new XForm(
locals[foot].Pos, MathQ.Normalize(Quaternion.Conjugate(parentRot) * newFootWorld));
if (chain.Toe is { } toe && skeleton[toe].ParentIndex == foot)
{
// Desired toe world = toeFix ∘ footFix ∘ original world; re-derive its local
// against the corrected foot so it does not double-rotate with the foot fix.
var toeWorldOld = MathQ.Normalize(footWorld * locals[toe].Rot);
var desired = MathQ.Normalize(toeFix * footFix * toeWorldOld);
locals[toe] = new XForm(
locals[toe].Pos, MathQ.Normalize(Quaternion.Conjugate(newFootWorld) * desired));
}
}
}
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Skeleton;
using HumanoidRetargeter.Solve;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
namespace HumanoidRetargeter.Dl;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>The z-normalization statistics shipped with the SAME checkpoint
/// (<c>ms_dict</c>): per-feature mean/std applied to every input except contact.</summary>
public sealed class SameStats
{
internal float[] LoM, LoS, GoM, GoS, QM, QS, PM, PS, RM, RS, PvM, PvS, QvM, QvS, PprevM, PprevS;
/// <summary>Reads the 16 <c>ms.*</c> arrays from a parsed weight blob.</summary>
public SameStats(SameWeights weights)
{
ArgumentNullException.ThrowIfNull(weights);
LoM = weights.Stat("lo_m"); LoS = weights.Stat("lo_s");
GoM = weights.Stat("go_m"); GoS = weights.Stat("go_s");
QM = weights.Stat("q_m"); QS = weights.Stat("q_s");
PM = weights.Stat("p_m"); PS = weights.Stat("p_s");
RM = weights.Stat("r_m"); RS = weights.Stat("r_s");
PvM = weights.Stat("pv_m"); PvS = weights.Stat("pv_s");
QvM = weights.Stat("qv_m"); QvS = weights.Stat("qv_s");
PprevM = weights.Stat("pprev_m"); PprevS = weights.Stat("pprev_s");
}
}
/// <summary>A batched per-frame source graph ready for <see cref="SameModel.Encode"/>.</summary>
public sealed class SameSourceGraph
{
/// <summary>Normalized node features, flat [FrameCount·JointCount × 32].</summary>
public required float[] X { get; init; }
/// <summary>Edge sources (bidirectional + self-loops, all frames).</summary>
public required int[] EdgeSrc { get; init; }
/// <summary>Edge destinations.</summary>
public required int[] EdgeDst { get; init; }
/// <summary>Frame id per node.</summary>
public required int[] Batch { get; init; }
/// <summary>Number of feature frames (matches the clip's frame count in production
/// mode; native frames − 2 in golden-parity mode).</summary>
public required int FrameCount { get; init; }
/// <summary>Graph joints per frame (hips subtree + end joints).</summary>
public required int JointCount { get; init; }
/// <summary>Graph node names within one frame (bone names; synthesized leaf tips get
/// a <c>_end</c> suffix). For diagnostics and parity tests.</summary>
public required string[] JointNames { get; init; }
}
/// <summary>
/// Source-side feature pipeline of the SAME port (FEASIBILITY.md "C# port work list"
/// steps 1–5): skeleton normalization, cm/Y-up/+Z-facing alignment, per-frame
/// q/p/r/pv/qv/pprev/c features in the root-facing frame, z-normalization, and the
/// bidirectional+self-loop edge list.
/// </summary>
/// <remarks>
/// <para><b>Skeleton normalization without an intermediate skeleton.</b> SAME's
/// <c>motion_normalize</c> rebuilds the rig with identity rest-local rotations and
/// re-expresses every frame against it. Algebraically the normalized motion's world
/// rotations are exactly the world-space deltas from the T-pose,
/// <c>Ĝ(j,t) = G(j,t) · G_tpose(j)⁻¹</c>, its local rotations are
/// <c>Ĝ(parent)⁻¹ · Ĝ(j)</c>, and its world positions equal the original world positions
/// — so this port computes the features directly from FK world transforms, no rebuilt
/// skeleton needed (verified against the Python pipeline by the golden-vector tests).</para>
/// <para><b>T-pose reference.</b> SAME consumes the source clip's first frame as the
/// reference; production keeps that convention but emits one feature frame per clip frame
/// (the sequence is computed over [f0, f0…fN−1] with f0 doubling as the reference — see
/// <see cref="TposeReference"/> for why the rest-pose alternative measurably loses).
/// Golden-parity mode replicates Python's frame accounting exactly (frame 0 = reference,
/// frame 1 dropped).</para>
/// <para><b>Alignment.</b> Features assume cm (guaranteed by the importers), Y-up and
/// rest facing +Z with +X to the character's left. The source is rotated by a world
/// alignment derived from the rig's rest geometry (<see cref="CharacterFrame"/> via the
/// mapping when computable, else the file's axis metadata), snapped to the nearest whole
/// axis permutation (an exact-axis rig must map to the identity — the rest-geometry tilt
/// of a few degrees otherwise leaks into every feature), and shifted so the lowest joint
/// over the clip sits on the ground plane.</para>
/// <para><b>Graph.</b> Nodes are the hips subtree (hips = mapped Hips role, else the
/// shallowest branch bone) in skeleton order — hips is always node 0, which is where the
/// root feature row lives — plus one synthesized end joint per childless leaf (BVH End
/// Sites already import as <c>_end</c> bones and are used as-is; FBX leaves get a
/// half-length continuation of their parent segment).</para>
/// </remarks>
public static class SameFeatures
{
/// <summary>How the T-pose reference (skeleton normalization + lo/go features) is chosen.</summary>
public enum TposeReference
{
/// <summary>The clip's own first frame — SAME's native convention and the
/// production default. Empirically the pretrained checkpoint tracks arms FAR
/// better against the clip's first frame than against a synthesized true T-pose,
/// even though its training references are T-poses (measured on the fixture clip:
/// mean role cosine vs the geometric solver 0.94 first-frame vs 0.57 rest-pose,
/// hands flipping negative — reproduced identically in the Python reference
/// pipeline, so it is a property of the checkpoint, not of this port).</summary>
FirstFrame,
/// <summary>Synthesize the reference from the skeleton's rest pose (the
/// FEASIBILITY suggestion; kept for experiments — see above for why it lost).</summary>
RestPose,
}
/// <summary>Options for <see cref="BuildSourceGraph"/>; defaults are production mode.</summary>
public sealed class SourceOptions
{
/// <summary>T-pose reference choice (see <see cref="TposeReference"/>).</summary>
public TposeReference Reference { get; init; } = TposeReference.FirstFrame;
/// <summary>SAME's native frame accounting: the first frame is consumed as the
/// reference and the next dropped for its undefined velocity, so the output has
/// two frames fewer than the clip. Golden-parity tests only — production emits
/// one feature frame per clip frame (the first frame doubles as the reference
/// and gets zero velocity).</summary>
public bool NativeFrameDrop { get; init; }
/// <summary>Apply the rest-geometry world alignment (Y-up, +Z facing). Disabled
/// only by golden-parity tests (Python applies none).</summary>
public bool Align { get; init; } = true;
/// <summary>Ground both the T-pose reference and the animation: the T-pose is
/// shifted so its lowest joint sits at height 0 (a BVH rest pose has its root at
/// the origin and would otherwise put the hips on the floor), and the animation is
/// shifted by its own lowest joint height over the clip (no-op for the usual
/// authored-ground-at-0 data). Disabled only by golden-parity tests (the Python
/// reference consumes data as authored).</summary>
public bool GroundShift { get; init; } = true;
}
private const float ContactHeightCm = 5f;
private const float ContactSpeedMps = 0.4f;
private const float VelocityFps = 30f;
/// <summary>
/// Builds the batched source graph for one clip: graph selection, alignment, per-frame
/// features, normalization, edges.
/// </summary>
/// <param name="scene">Imported source (cm, native axes).</param>
/// <param name="clipIndex">Clip to encode.</param>
/// <param name="map">Source mapping; used only for hips identification and the
/// rest-geometry alignment (the model itself is skeleton-agnostic). May be sparse —
/// heuristics cover missing roles.</param>
/// <param name="stats">Normalization statistics.</param>
/// <param name="options">Null = production mode.</param>
public static SameSourceGraph BuildSourceGraph(
SourceScene scene, int clipIndex, MappingResult? map, SameStats stats, SourceOptions? options = null)
{
ArgumentNullException.ThrowIfNull(scene);
ArgumentNullException.ThrowIfNull(stats);
options ??= new SourceOptions();
if (clipIndex < 0 || clipIndex >= scene.Clips.Count)
throw new ArgumentOutOfRangeException(nameof(clipIndex));
var clip = scene.Clips[clipIndex];
if (clip.FrameCount < 1)
throw new ArgumentException("Clip has no frames.", nameof(clipIndex));
if (options.NativeFrameDrop && clip.FrameCount < 3)
throw new ArgumentException("Native frame accounting needs at least 3 frames.", nameof(options));
var skeleton = scene.Skeleton;
var hips = FindHips(skeleton, map);
var nodes = GraphNodes.Build(skeleton, hips);
var align = options.Align ? ComputeAlignment(skeleton, map, scene) : Quaternion.Identity;
// T-pose reference world transforms (aligned), grounded on its own lowest joint
// (a BVH rest pose has the root at the origin — ungrounded, its hips would sit on
// the floor and every height-bearing feature would be wrong).
var tposeLocals = options.Reference == TposeReference.RestPose
? Pose.Rest(skeleton).Locals
: clip.Frames[0];
var tposeWorld = AlignedWorld(skeleton, tposeLocals, align, nodes);
if (options.GroundShift)
ShiftToGround(tposeWorld.Pos);
// The pose sequence the features run over; features are emitted for seq[1..].
var seq = new List<XForm[]>();
if (options.NativeFrameDrop)
{
for (var f = 1; f < clip.FrameCount; f++)
seq.Add(clip.Frames[f]);
}
else
{
seq.Add(clip.Frames[0]); // duplicated: gives the real first frame zero velocity
for (var f = 0; f < clip.FrameCount; f++)
seq.Add(clip.Frames[f]);
}
var frames = seq.Count - 1;
var j = nodes.Count;
// Pass 0: aligned world transforms; ground the whole clip on its lowest joint.
var worlds = new AlignedFrame[seq.Count];
for (var t = 0; t < seq.Count; t++)
worlds[t] = AlignedWorld(skeleton, seq[t], align, nodes);
if (options.GroundShift)
{
var ground = float.PositiveInfinity;
foreach (var world in worlds)
{
foreach (var p in world.Pos)
ground = MathF.Min(ground, p.Y);
}
if (float.IsFinite(ground) && ground != 0f)
{
foreach (var world in worlds)
{
for (var i = 0; i < j; i++)
world.Pos[i].Y -= ground;
}
}
}
// Pass 1: normalized-skeleton local rotations + facing per frame.
var localRots = new Quaternion[seq.Count][]; // facing-adjusted at the root row
var facing = new (float Yaw, Vector3 Pos)[seq.Count];
for (var t = 0; t < seq.Count; t++)
{
var world = worlds[t];
// Normalized-skeleton world rotations: world delta from the T-pose.
var normWorld = new Quaternion[j];
for (var i = 0; i < j; i++)
normWorld[i] = MathQ.Normalize(world.Rot[i] * Quaternion.Conjugate(tposeWorld.Rot[i]));
// Root facing: yaw (about +Y) of the normalized root rotation, at the root's
// ground-plane position.
var yaw = YawAngle(normWorld[0]);
facing[t] = (yaw, new Vector3(world.Pos[0].X, 0f, world.Pos[0].Z));
// Normalized-skeleton local rotations; root premultiplied by the inverse facing.
var locals = new Quaternion[j];
locals[0] = MathQ.Normalize(Quaternion.CreateFromAxisAngle(Vector3.UnitY, -yaw) * normWorld[0]);
for (var i = 1; i < j; i++)
{
locals[i] = MathQ.Normalize(
Quaternion.Conjugate(normWorld[nodes.Parent[i]]) * normWorld[i]);
}
localRots[t] = locals;
}
// Pass 2: feature rows.
var x = new float[frames * j * SameModel.InputDim];
for (var t = 1; t < seq.Count; t++)
{
var f = t - 1;
var (yaw, fpos) = facing[t];
var invFacing = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -yaw);
var (yawPrev, fposPrev) = facing[t - 1];
var invFacingPrev = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -yawPrev);
// r: facing delta (dθ, dx, dz) + absolute root height.
var dTheta = WrapPi(yaw - yawPrev);
var dPlanar = Vector3.Transform(fpos - fposPrev, invFacingPrev);
var rootHeight = worlds[t].Pos[0].Y;
for (var i = 0; i < j; i++)
{
var row = (f * j + i) * SameModel.InputDim;
var col = 0;
// ---- skel: lo, go (tiled per frame) -------------------------------------
Vector3 lo, go;
if (i == 0)
{
lo = new Vector3(0f, tposeWorld.Pos[0].Y, 0f);
go = lo;
}
else
{
lo = tposeWorld.Pos[i] - tposeWorld.Pos[nodes.Parent[i]];
go = tposeWorld.Pos[i] - new Vector3(tposeWorld.Pos[0].X, 0f, tposeWorld.Pos[0].Z);
}
WriteNorm3(x, row, ref col, lo, stats.LoM, stats.LoS);
WriteNorm3(x, row, ref col, go, stats.GoM, stats.GoS);
// ---- q ------------------------------------------------------------------
WriteNorm6(x, row, ref col, SixD(localRots[t][i]), stats.QM, stats.QS);
// ---- p (facing-frame-relative global position) --------------------------
var p = Vector3.Transform(worlds[t].Pos[i] - fpos, invFacing);
WriteNorm3(x, row, ref col, p, stats.PM, stats.PS);
// ---- r (root row only; other rows are the mean → zeros after norm) ------
if (i == 0)
{
x[row + col++] = (dTheta - stats.RM[0]) / stats.RS[0];
x[row + col++] = (dPlanar.X - stats.RM[1]) / stats.RS[1];
x[row + col++] = (dPlanar.Z - stats.RM[2]) / stats.RS[2];
x[row + col++] = (rootHeight - stats.RM[3]) / stats.RS[3];
}
else
{
col += 4; // already zero
}
// ---- pv (facing-frame velocity, ×30 fps) ---------------------------------
var pv = Vector3.Transform(worlds[t].Pos[i] - worlds[t - 1].Pos[i], invFacing) * VelocityFps;
WriteNorm3(x, row, ref col, pv, stats.PvM, stats.PvS);
// ---- qv (local rotation delta) -------------------------------------------
var qv = MathQ.Normalize(Quaternion.Conjugate(localRots[t - 1][i]) * localRots[t][i]);
WriteNorm6(x, row, ref col, SixD(qv), stats.QvM, stats.QvS);
// ---- pprev (previous position in the CURRENT facing frame) ---------------
var pprev = Vector3.Transform(worlds[t - 1].Pos[i] - fpos, invFacing);
WriteNorm3(x, row, ref col, pprev, stats.PprevM, stats.PprevS);
// ---- c (ground contact; not normalized) -----------------------------------
var speedMps = (worlds[t].Pos[i] - worlds[t - 1].Pos[i]).Length() * VelocityFps / 100f;
x[row + col] = worlds[t].Pos[i].Y < ContactHeightCm && speedMps < ContactSpeedMps ? 1f : 0f;
}
}
var (edgeSrc, edgeDst) = BuildEdges(nodes.Parent, frames);
var batch = new int[frames * j];
for (var f = 0; f < frames; f++)
{
for (var i = 0; i < j; i++)
batch[f * j + i] = f;
}
AssertFinite(x, "SAME source features");
return new SameSourceGraph
{
X = x,
EdgeSrc = edgeSrc,
EdgeDst = edgeDst,
Batch = batch,
FrameCount = frames,
JointCount = j,
JointNames = nodes.Names,
};
}
// ================================================================ graph topology
/// <summary>The per-frame graph node set: hips-subtree bones in skeleton order
/// (hips first) plus synthesized end joints for childless leaves.</summary>
internal sealed class GraphNodes
{
/// <summary>Skeleton bone index per node; -1 for synthesized end joints.</summary>
public required int[] Bone { get; init; }
/// <summary>Graph-parent node index; -1 for the root (node 0).</summary>
public required int[] Parent { get; init; }
/// <summary>For synthesized end joints: the rest-local offset from the leaf bone
/// (zero vector for real bones).</summary>
public required Vector3[] EndOffset { get; init; }
public required string[] Names { get; init; }
public int Count => Bone.Length;
public static GraphNodes Build(SkeletonModel skeleton, int hips)
{
// Hips subtree, skeleton order (parents precede children, hips first).
var inSubtree = new bool[skeleton.Count];
inSubtree[hips] = true;
var bones = new List<int> { hips };
for (var i = hips + 1; i < skeleton.Count; i++)
{
var parent = skeleton[i].ParentIndex;
if (parent >= 0 && inSubtree[parent])
{
inSubtree[i] = true;
bones.Add(i);
}
}
var nodeOfBone = new Dictionary<int, int>(bones.Count);
for (var n = 0; n < bones.Count; n++)
nodeOfBone[bones[n]] = n;
var hasChild = new bool[skeleton.Count];
foreach (var b in bones)
{
var parent = skeleton[b].ParentIndex;
if (parent >= 0 && inSubtree[parent])
hasChild[parent] = true;
}
var bone = new List<int>(bones);
var parentNode = new List<int>(bones.Count);
var endOffset = new List<Vector3>(bones.Count);
var names = new List<string>(bones.Count);
foreach (var b in bones)
{
var p = skeleton[b].ParentIndex;
parentNode.Add(b == hips ? -1 : nodeOfBone[p]);
endOffset.Add(Vector3.Zero);
names.Add(skeleton[b].Name);
}
// Synthesized end joints: leaves with no children anywhere in the skeleton.
// BVH End Sites already import as real `_end`/`_End` bones and ARE the end
// joints — no tip on a tip. The tip continues the parent→leaf segment at half
// length — a neutral stand-in for the unknown bone tail (FBX carries none).
foreach (var b in bones)
{
if (hasChild[b]
|| skeleton[b].Name.EndsWith("_end", StringComparison.OrdinalIgnoreCase))
continue;
var p = skeleton[b].ParentIndex;
var segment = p >= 0
? skeleton.RestWorld[b].Pos - skeleton.RestWorld[p].Pos
: Vector3.Zero;
var tip = segment.Length() > 1e-4f ? segment * 0.5f : new Vector3(0f, 2f, 0f);
// Express in the leaf's rest-local frame (applied via the leaf's world rot).
var local = Vector3.Transform(tip, Quaternion.Conjugate(skeleton.RestWorld[b].Rot));
bone.Add(-1);
parentNode.Add(nodeOfBone[b]);
endOffset.Add(local);
names.Add(skeleton[b].Name + "_end");
}
return new GraphNodes
{
Bone = bone.ToArray(),
Parent = parentNode.ToArray(),
EndOffset = endOffset.ToArray(),
Names = names.ToArray(),
};
}
}
/// <summary>Aligned world transforms of the graph nodes for one pose.</summary>
internal readonly struct AlignedFrame
{
public required Vector3[] Pos { get; init; }
public required Quaternion[] Rot { get; init; }
}
private static AlignedFrame AlignedWorld(
SkeletonModel skeleton, XForm[] locals, Quaternion align, GraphNodes nodes)
{
var world = new Pose(locals).ToWorld(skeleton);
var pos = new Vector3[nodes.Count];
var rot = new Quaternion[nodes.Count];
for (var n = 0; n < nodes.Count; n++)
{
XForm w;
if (nodes.Bone[n] >= 0)
{
w = world[nodes.Bone[n]];
}
else
{
// Synthesized end joint: rides its leaf bone (identity local rotation).
var leaf = world[nodes.Bone[nodes.Parent[n]]];
w = new XForm(leaf.TransformPoint(nodes.EndOffset[n]), leaf.Rot);
}
pos[n] = Vector3.Transform(w.Pos, align);
rot[n] = MathQ.Normalize(align * w.Rot);
}
return new AlignedFrame { Pos = pos, Rot = rot };
}
/// <summary>Bidirectional parent↔child pairs plus one self-loop per node, replicated
/// per frame with node indices offset.</summary>
internal static (int[] Src, int[] Dst) BuildEdges(int[] parent, int frames)
{
var j = parent.Length;
var nonRoot = 0;
for (var i = 0; i < j; i++)
{
if (parent[i] >= 0)
nonRoot++;
}
var perFrame = nonRoot * 2 + j;
var src = new int[perFrame * frames];
var dst = new int[perFrame * frames];
var e = 0;
for (var f = 0; f < frames; f++)
{
var offset = f * j;
for (var i = 0; i < j; i++)
{
if (parent[i] < 0)
continue;
src[e] = offset + parent[i];
dst[e] = offset + i;
e++;
src[e] = offset + i;
dst[e] = offset + parent[i];
e++;
}
for (var i = 0; i < j; i++)
{
src[e] = offset + i;
dst[e] = offset + i;
e++;
}
}
return (src, dst);
}
// ================================================================ alignment + hips
/// <summary>Mapped Hips role when available, else the shallowest bone with two or more
/// children (the hips of any humanoid: the legs/spine branch point).</summary>
internal static int FindHips(SkeletonModel skeleton, MappingResult? map)
{
if (map is not null && map.RoleToBone.TryGetValue(BoneRole.Hips, out var mapped)
&& mapped >= 0 && mapped < skeleton.Count)
return mapped;
var childCount = new int[skeleton.Count];
for (var i = 0; i < skeleton.Count; i++)
{
if (skeleton[i].ParentIndex >= 0)
childCount[skeleton[i].ParentIndex]++;
}
var best = -1;
var bestDepth = int.MaxValue;
for (var i = 0; i < skeleton.Count; i++)
{
if (childCount[i] < 2)
continue;
var depth = 0;
for (var a = skeleton[i].ParentIndex; a >= 0; a = skeleton[a].ParentIndex)
depth++;
if (depth < bestDepth)
{
best = i;
bestDepth = depth;
}
}
return best >= 0 ? best : 0;
}
/// <summary>
/// World rotation taking the rig into the canonical SAME frame (X = character left,
/// Y = up, Z = facing): rest-geometry character frame when computable from the mapping,
/// else the file's recorded axis conventions.
/// </summary>
internal static Quaternion ComputeAlignment(SkeletonModel skeleton, MappingResult? map, SourceScene? scene)
{
if (map is not null)
{
try
{
var frame = CharacterFrame.Compute(skeleton, map, skeleton.RestWorld);
return AlignFromBasis(frame.Lateral, frame.Up, frame.Forward);
}
catch (ArgumentException)
{
// fall through to axis metadata
}
}
if (scene is not null)
{
var up = AxisVector(scene.UpAxis, scene.UpAxisSign);
var forward = AxisVector(scene.FrontAxis, scene.FrontAxisSign);
if (MathF.Abs(Vector3.Dot(up, forward)) < 0.5f)
return AlignFromBasis(Vector3.Cross(up, forward), up, forward);
}
return Quaternion.Identity;
}
/// <summary>
/// Rotation mapping the given (left, up, forward) world directions onto (+X, +Y, +Z),
/// snapped to the nearest whole axis permutation when one is unambiguous: rigs authored
/// on exact axes (BVH Y-up/+Z, the s&box rig, Z-up FBX) must map by an exact
/// quarter-turn — the few degrees of rest-geometry tilt (shoulders not exactly above
/// hips) otherwise leak into every feature and measurably cost accuracy.
/// </summary>
internal static Quaternion AlignFromBasis(Vector3 left, Vector3 up, Vector3 forward)
{
var l = SnapAxis(left);
var u = SnapAxis(up);
var f = SnapAxis(forward);
if (MathF.Abs(Vector3.Dot(l, u)) > 0.5f || MathF.Abs(Vector3.Dot(l, f)) > 0.5f
|| MathF.Abs(Vector3.Dot(u, f)) > 0.5f)
{
// Genuinely oblique rig: keep the exact (orthonormalized) directions.
l = Vector3.Normalize(left);
u = Vector3.Normalize(up - l * Vector3.Dot(up, l));
f = Vector3.Cross(l, u);
}
// Row-major with rows = basis images maps +X→left, +Y→up, +Z→forward
// (System.Numerics row-vector convention); the alignment is its inverse.
var m = new Matrix4x4(
l.X, l.Y, l.Z, 0f,
u.X, u.Y, u.Z, 0f,
f.X, f.Y, f.Z, 0f,
0f, 0f, 0f, 1f);
return Quaternion.Conjugate(MathQ.Normalize(Quaternion.CreateFromRotationMatrix(m)));
}
private static Vector3 SnapAxis(Vector3 v)
{
var ax = MathF.Abs(v.X);
var ay = MathF.Abs(v.Y);
var az = MathF.Abs(v.Z);
if (ax >= ay && ax >= az)
return new Vector3(MathF.Sign(v.X), 0f, 0f);
if (ay >= az)
return new Vector3(0f, MathF.Sign(v.Y), 0f);
return new Vector3(0f, 0f, MathF.Sign(v.Z));
}
private static Vector3 AxisVector(int axis, int sign) => axis switch
{
0 => new Vector3(sign, 0f, 0f),
2 => new Vector3(0f, 0f, sign),
_ => new Vector3(0f, sign, 0f),
};
// ================================================================ small math
/// <summary>The yaw (rotation about +Y) closest to <paramref name="q"/> — fairmotion's
/// <c>Q_closest(q, identity, +Y)</c>, reproduced exactly for parity.</summary>
internal static float YawAngle(Quaternion q)
{
var alpha = Math.Atan2(q.W, q.Y);
var theta1 = -2.0 * alpha + Math.PI;
var theta2 = -2.0 * alpha - Math.PI;
var d1 = q.Y * Math.Sin(theta1 * 0.5) + q.W * Math.Cos(theta1 * 0.5);
var d2 = q.Y * Math.Sin(theta2 * 0.5) + q.W * Math.Cos(theta2 * 0.5);
return (float)(d1 > d2 ? theta1 : theta2);
}
private static void ShiftToGround(Vector3[] positions)
{
var ground = float.PositiveInfinity;
foreach (var p in positions)
ground = MathF.Min(ground, p.Y);
if (!float.IsFinite(ground) || ground == 0f)
return;
for (var i = 0; i < positions.Length; i++)
positions[i].Y -= ground;
}
internal static float WrapPi(float angle)
{
while (angle > MathF.PI)
angle -= 2f * MathF.PI;
while (angle < -MathF.PI)
angle += 2f * MathF.PI;
return angle;
}
/// <summary>6D rotation representation: the first two columns of the rotation matrix
/// (<c>R·e_x</c> then <c>R·e_y</c>).</summary>
internal static (Vector3 C0, Vector3 C1) SixD(Quaternion q)
=> (Vector3.Transform(Vector3.UnitX, q), Vector3.Transform(Vector3.UnitY, q));
private static void WriteNorm3(float[] x, int row, ref int col, Vector3 v, float[] m, float[] s)
{
x[row + col++] = (v.X - m[0]) / s[0];
x[row + col++] = (v.Y - m[1]) / s[1];
x[row + col++] = (v.Z - m[2]) / s[2];
}
private static void WriteNorm6(float[] x, int row, ref int col, (Vector3 C0, Vector3 C1) sixD, float[] m, float[] s)
{
x[row + col++] = (sixD.C0.X - m[0]) / s[0];
x[row + col++] = (sixD.C0.Y - m[1]) / s[1];
x[row + col++] = (sixD.C0.Z - m[2]) / s[2];
x[row + col++] = (sixD.C1.X - m[3]) / s[3];
x[row + col++] = (sixD.C1.Y - m[4]) / s[4];
x[row + col++] = (sixD.C1.Z - m[5]) / s[5];
}
internal static void AssertFinite(float[] values, string what)
{
foreach (var v in values)
{
if (!float.IsFinite(v))
throw new InvalidOperationException($"{what} contain non-finite values.");
}
}
}
using System.Collections.Generic;
namespace HumanoidRetargeter.Mapping;
/// <summary>
/// Built-in preset profiles, embedded as C# data (the same data is written to
/// <c>Assets/humanoid_retargeter/profiles/*.json</c> by a regenerate-and-diff test so the
/// shipped JSON can never drift from the code).
/// </summary>
public static class ProfileLibrary
{
/// <summary>Mixamo / Adobe rigs: <c>mixamorig[N]:</c> namespace, <c>LeftArm</c> /
/// <c>LeftForeArm</c> / <c>LeftHandIndex1..3</c> style names.</summary>
public static Profile Mixamo { get; } = BuildMixamo();
/// <summary>
/// Reallusion ActorCore / AccuRig / Character Creator rigs (<c>CC_Base_*</c>).
/// Empirical notes from <c>research/rig_actorcore.json</c>:
/// <list type="bullet">
/// <item><c>CC_Base_Hip</c> is the parent of BOTH <c>CC_Base_Pelvis</c> (leg branch) and
/// <c>CC_Base_Waist</c> (spine branch), i.e. the LCA of legs+spine and the true animated
/// hips root → it carries <see cref="BoneRole.Hips"/>; <c>CC_Base_Pelvis</c> is a
/// leg-branch intermediate and stays unmapped.</item>
/// <item>The neck chain is <c>CC_Base_NeckTwist01 → CC_Base_NeckTwist02 → CC_Base_Head</c>;
/// despite the name, <c>NeckTwist01</c> IS the neck bone (there is no plain
/// <c>CC_Base_Neck</c>), so it is the <see cref="BoneRole.Neck"/> alias. NeckTwist02 is
/// left unmapped. All other Twist/ShareBone helpers are excluded (no aliases).</item>
/// <item><c>CC_Base_L_ToeBase</c> is the toe role; the co-located
/// <c>CC_Base_L_ToeBaseShareBone</c> is a helper and must never be mapped.</item>
/// </list>
/// </summary>
public static Profile ActorCoreCc { get; } = BuildActorCoreCc();
/// <summary>Unreal Engine mannequin (UE4/UE5): <c>pelvis</c>, <c>spine_01..05</c>,
/// <c>clavicle_l</c>, <c>thumb_01_l</c>, UE5 <c>*_metacarpal_*</c>; <c>*_twist_*</c>
/// bones have no aliases and are never mapped.</summary>
public static Profile UeMannequin { get; } = BuildUeMannequin();
/// <summary>Rokoko / Xsens style BVH rigs: plain <c>Hips</c>/<c>Spine..Spine4</c>/<c>
/// LeftArm|LeftUpperArm</c> name variants, usually no fingers.</summary>
public static Profile RokokoBvh { get; } = BuildRokokoBvh();
/// <summary>
/// SMPL body model family (AMASS exports, Meshcapade FBX rigs). Joint names per the
/// published model (vchoutas/smplx <c>joint_names.py</c>, Meshcapade wiki):
/// <c>pelvis</c>, sided <c>hip→knee→ankle→foot</c> legs (the "hip" joint IS the thigh;
/// "ankle" is the foot, "foot" is the toe region) and <c>collar→shoulder→elbow→wrist</c>
/// arms ("shoulder" is the upper arm, "wrist" is the hand; the <c>hand</c> joint is a
/// finger stub and stays unmapped). Both spellings occur in the wild: <c>left_hip</c>
/// (model joints) and <c>L_Hip</c> with gendered FBX prefixes <c>m_avg_</c>/<c>f_avg_</c>
/// (SMPL Unity/FBX rigs). No fingers — that is SMPL-X (<see cref="SmplX"/>), kept as a
/// separate preset so a finger-less SMPL rig still reaches full optional coverage.
/// </summary>
public static Profile Smpl { get; } = BuildSmpl(withFingers: false);
/// <summary>
/// SMPL-X: the SMPL body joints (<see cref="Smpl"/>) plus articulated hands —
/// <c>left_thumb1..3</c>/<c>left_index1..3</c>-style finger joints per
/// vchoutas/smplx <c>joint_names.py</c> (jaw/eye joints carry no humanoid role).
/// Evaluated before <see cref="Smpl"/> so it wins the tie on SMPL-X rigs (both score
/// the body fully; only this one maps the fingers).
/// </summary>
public static Profile SmplX { get; } = BuildSmpl(withFingers: true);
/// <summary>
/// NVIDIA SOMA uniform-proportion skeleton (SOMA/SEED BVH exports, e.g.
/// github.com/NVIDIA/soma-retargeter <c>assets/motions/bvh</c>). Mixamo-identical
/// upper-body and finger names, but: spine is <c>Spine1→Spine2→Chest</c> (no plain
/// "Spine"), neck is <c>Neck1→Neck2</c>, and the legs are <c>LeftLeg→LeftShin</c> —
/// SOMA's <c>LeftLeg</c> is the THIGH (mixamo's is the calf), which is exactly why the
/// mixamo preset must never claim these rigs.
/// </summary>
public static Profile SomaBvh { get; } = BuildSomaBvh();
/// <summary>
/// Classic BVH / Character-Studio-friendly naming (MotionBuilder "Export BVH to
/// Character Studio" convention, ACCAD-style mocap BVHs): <c>Hips</c>,
/// <c>Chest[2..4]</c> spine, arms <c>Collar→Shoulder→Elbow→Wrist</c> (the "Shoulder"
/// is the upper arm) and legs <c>Hip→Knee→Ankle→Toe</c> (the sided "Hip" is the
/// thigh). No fingers.
/// </summary>
public static Profile ClassicBvh { get; } = BuildClassicBvh();
/// <summary>
/// 3ds Max Character Studio Biped rigs: every bone is "<BipedName> <Part>"
/// where the biped name defaults to <c>Bip01</c> (3ds Max ≤2009) / <c>Bip001</c>
/// (2010+) per the Autodesk "Naming the Biped" documentation; some exporters mangle
/// the spaces to underscores (<c>Bip01_L_Thigh</c>), hence the <c>^Bip\d+[ _]</c>
/// namespace pattern (alias comparison is separator-insensitive, so "L UpperArm" and
/// "L_UpperArm" normalize identically). Sided bones use a bare mid-name <c>L/R</c>:
/// <c>L Clavicle→L UpperArm→L Forearm→L Hand</c> arms,
/// <c>L Thigh→L Calf→L Foot→L Toe0</c> legs. Fingers are numbered chains
/// <c>L Finger0..4</c> (0 = thumb) with phalanx segments <c>Finger01/Finger02</c>
/// etc. (MotionBuilder's "3ds Max Biped Template" characterization maps exactly these
/// names). The COM root <c>Bip01</c> itself, <c>Footsteps</c>, toe segments
/// <c>Toe01/Toe02</c> and <c>HorseLink</c> carry no aliases and are never mapped.
/// </summary>
public static Profile Biped { get; } = BuildBiped();
/// <summary>
/// DAZ/Poser classic naming (Poser 4 era figures, DAZ Generation-4 V4/M4, Genesis 1/2,
/// MakeHuman's "Poser/DAZ names" BVH export — verified against the local
/// <c>dev/corpus/unknown_rigs/makehuman_cmu_03_03_dazNames.bvh</c>): camel-case bones
/// with a lower-case <c>l</c>/<c>r</c> side prefix — <c>hip</c> (the translating
/// root), <c>abdomen[→abdomen2]→chest</c> spine, <c>neck</c>, <c>head</c>,
/// <c>lCollar→lShldr→lForeArm→lHand</c> arms, <c>lThigh→lShin→lFoot→lToe</c> legs and
/// <c>lThumb1..3/lIndex1..3/lMid1..3/lRing1..3/lPinky1..3</c> fingers. The
/// <c>l/rButtock</c> thigh helpers and eye bones carry no aliases and stay unmapped.
/// DAZ Genesis 3/8/9 renamed the skeleton (<c>abdomenLower</c>, <c>lShldrBend</c>, …)
/// and is NOT covered by this preset.
/// </summary>
public static Profile DazPoser { get; } = BuildDazPoser();
/// <summary>
/// Blender Rigify human rigs, per the metarig definition in the rigify add-on
/// (<c>rigify/metarigs/human.py</c>) and the Blender manual's basic.human reference:
/// the spine chain is <c>spine→spine.001..spine.006</c> where <c>spine</c> IS the
/// pelvis/hips bone (it sits at the pelvis and parents the thighs), spine.001–003 are
/// the torso, spine.004/005 the two neck bones (004 carries <see cref="BoneRole.Neck"/>,
/// 005 stays unmapped — same policy as ActorCore's NeckTwist02) and spine.006 is the
/// head. Limbs: <c>shoulder.L→upper_arm.L→forearm.L→hand.L</c>,
/// <c>thigh.L→shin.L→foot.L→toe.L</c>; fingers <c>thumb.01.L..03.L</c> and
/// <c>f_index/f_middle/f_ring/f_pinky.01.L..03.L</c>. The <c>^DEF-</c> namespace
/// pattern also matches rigify's generated deform skeleton (<c>DEF-spine.001</c>,
/// <c>DEF-upper_arm.L</c>, …); the segmented deform twins (<c>DEF-upper_arm.L.001</c>),
/// <c>palm.*</c>, <c>pelvis.L/R</c>, <c>heel.02.L</c>, face bones and the generated
/// ORG-/MCH-/control bones have no aliases and are never mapped.
/// </summary>
public static Profile Rigify { get; } = BuildRigify();
/// <summary>
/// VRoid Studio / VRM avatars (UniVRM exports): <c>J_Bip_<side>_<Part></c>
/// bones where side is <c>C</c> (center), <c>L</c> or <c>R</c> — the standard VRoid
/// skeleton behind the VRM humanoid spec (vrm-c/vrm-specification, humanoid bone map):
/// <c>J_Bip_C_Hips/Spine/Chest/UpperChest/Neck/Head</c>,
/// <c>J_Bip_L_Shoulder→UpperArm→LowerArm→Hand</c>,
/// <c>J_Bip_L_UpperLeg→LowerLeg→Foot→ToeBase</c>, fingers
/// <c>J_Bip_L_Thumb1..3/Index1..3/Middle1..3/Ring1..3/Little1..3</c> ("Little" is the
/// pinky, per the VRM littleProximal/Intermediate/Distal humanoid bones). Secondary
/// physics/adjust bones (<c>J_Sec_*</c>, <c>J_Adj_*</c>) and the <c>Root</c> bone have
/// no aliases and are never mapped.
/// </summary>
public static Profile Vrm { get; } = BuildVrm();
/// <summary>
/// Blender Auto-Rig Pro humanoid FBX exports — bone names verified empirically against
/// the local user repro <c>dev/corpus/todo/Defenses.fbx</c> (the PunchPerfect family):
/// <c>.x</c> suffix marks center bones, <c>.l/.r</c> the sides, and the exported limb
/// deform bones carry the <c>_stretch</c> twin name — <c>root.x</c> is the hips
/// (under a ground bone <c>root</c>), <c>spine_01.x→spine_02.x→spine_03.x</c>,
/// <c>neck.x</c>, <c>head.x</c>, arms <c>shoulder.l→arm_stretch.l→forearm_stretch.l→
/// hand.l</c> (plain "arm", NOT "upperarm"), legs <c>thigh_stretch.l→leg_stretch.l→
/// foot.l→toes_01.l</c> ("leg" is the calf). Fingers keep Auto-Rig Pro's <c>c_</c>
/// control prefix on the exported deform chain: <c>c_thumb1.l..3.l</c>,
/// <c>c_index/c_middle/c_ring/c_pinky1.l..3.l</c>. Leftover finger-tip markers
/// (<c>mixamorig:LeftHandIndex4</c> in the repro) and <c>root</c> have no aliases.
/// </summary>
public static Profile AutoRigPro { get; } = BuildAutoRigPro();
/// <summary>All built-in presets, in detection order (first wins score ties — see
/// <see cref="SmplX"/> vs <see cref="Smpl"/>).</summary>
public static IReadOnlyList<Profile> All { get; } =
new[]
{
Mixamo, ActorCoreCc, UeMannequin, RokokoBvh, SmplX, Smpl, SomaBvh, ClassicBvh,
Biped, DazPoser, Rigify, Vrm, AutoRigPro,
};
// ---------------------------------------------------------------- mixamo
private static Profile BuildMixamo()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Hips" },
[BoneRole.Spine0] = new[] { "Spine" },
[BoneRole.Spine1] = new[] { "Spine1" },
[BoneRole.Spine2] = new[] { "Spine2" },
[BoneRole.Neck] = new[] { "Neck" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var (roleSide, nameSide) in Sides())
{
aliases[Role("Clavicle", roleSide)] = new[] { $"{nameSide}Shoulder" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{nameSide}Arm" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{nameSide}ForeArm" };
aliases[Role("Hand", roleSide)] = new[] { $"{nameSide}Hand" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"{nameSide}UpLeg" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{nameSide}Leg" };
aliases[Role("Foot", roleSide)] = new[] { $"{nameSide}Foot" };
aliases[Role("Toe", roleSide)] = new[] { $"{nameSide}ToeBase" };
foreach (var finger in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" })
{
aliases[Role($"{finger}Prox", roleSide)] = new[] { $"{nameSide}Hand{finger}1" };
aliases[Role($"{finger}Mid", roleSide)] = new[] { $"{nameSide}Hand{finger}2" };
aliases[Role($"{finger}Dist", roleSide)] = new[] { $"{nameSide}Hand{finger}3" };
}
}
// Both ':' (FBX namespace) and '_' (namespace mangled by some exporters) forms occur
// in the wild; some Mixamo downloads ship with no namespace at all, which still
// matches because the aliases are the bare names.
return new Profile("mixamo", new[] { "^mixamorig[0-9]*:", "^mixamorig[0-9]*_" }, aliases);
}
// ---------------------------------------------------------------- actorcore / cc
private static Profile BuildActorCoreCc()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Hip" },
[BoneRole.Spine0] = new[] { "Waist" },
[BoneRole.Spine1] = new[] { "Spine01" },
[BoneRole.Spine2] = new[] { "Spine02" },
[BoneRole.Neck] = new[] { "NeckTwist01" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var roleSide in new[] { "L", "R" })
{
var nameSide = roleSide; // CC bones use the bare side letter: CC_Base_L_Thigh.
aliases[Role("Clavicle", roleSide)] = new[] { $"{nameSide}_Clavicle" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{nameSide}_Upperarm" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{nameSide}_Forearm" };
aliases[Role("Hand", roleSide)] = new[] { $"{nameSide}_Hand" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"{nameSide}_Thigh" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{nameSide}_Calf" };
aliases[Role("Foot", roleSide)] = new[] { $"{nameSide}_Foot" };
aliases[Role("Toe", roleSide)] = new[] { $"{nameSide}_ToeBase" };
foreach (var (role, cc) in new[]
{
("Thumb", "Thumb"), ("Index", "Index"), ("Middle", "Mid"), ("Ring", "Ring"), ("Pinky", "Pinky"),
})
{
aliases[Role($"{role}Prox", roleSide)] = new[] { $"{nameSide}_{cc}1" };
aliases[Role($"{role}Mid", roleSide)] = new[] { $"{nameSide}_{cc}2" };
aliases[Role($"{role}Dist", roleSide)] = new[] { $"{nameSide}_{cc}3" };
}
}
return new Profile("actorcore_cc", new[] { "^CC_Base_" }, aliases);
}
// ---------------------------------------------------------------- ue mannequin
private static Profile BuildUeMannequin()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "pelvis" },
[BoneRole.Spine0] = new[] { "spine_01" },
[BoneRole.Spine1] = new[] { "spine_02" },
[BoneRole.Spine2] = new[] { "spine_03" },
[BoneRole.Spine3] = new[] { "spine_04" },
[BoneRole.Spine4] = new[] { "spine_05" },
[BoneRole.Neck] = new[] { "neck_01" },
[BoneRole.Head] = new[] { "head" },
};
foreach (var (roleSide, s) in new[] { ("L", "l"), ("R", "r") })
{
aliases[Role("Clavicle", roleSide)] = new[] { $"clavicle_{s}" };
aliases[Role("UpperArm", roleSide)] = new[] { $"upperarm_{s}" };
aliases[Role("LowerArm", roleSide)] = new[] { $"lowerarm_{s}" };
aliases[Role("Hand", roleSide)] = new[] { $"hand_{s}" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"thigh_{s}" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"calf_{s}" };
aliases[Role("Foot", roleSide)] = new[] { $"foot_{s}" };
aliases[Role("Toe", roleSide)] = new[] { $"ball_{s}" };
foreach (var (role, ue) in new[]
{
("Thumb", "thumb"), ("Index", "index"), ("Middle", "middle"), ("Ring", "ring"), ("Pinky", "pinky"),
})
{
// UE5 mannequin adds metacarpals for the four fingers (not the thumb).
if (role != "Thumb")
aliases[Role($"{role}Meta", roleSide)] = new[] { $"{ue}_metacarpal_{s}" };
aliases[Role($"{role}Prox", roleSide)] = new[] { $"{ue}_01_{s}" };
aliases[Role($"{role}Mid", roleSide)] = new[] { $"{ue}_02_{s}" };
aliases[Role($"{role}Dist", roleSide)] = new[] { $"{ue}_03_{s}" };
}
}
return new Profile("ue_mannequin", new string[0], aliases);
}
// ---------------------------------------------------------------- rokoko / xsens bvh
private static Profile BuildRokokoBvh()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Hips" },
// Spine naming varies (Spine, Spine1..Spine4); ordered alias preference plus the
// used-bone exclusion in the detector shifts the chain up when "Spine" is absent.
[BoneRole.Spine0] = new[] { "Spine", "Spine1" },
[BoneRole.Spine1] = new[] { "Spine1", "Spine2" },
[BoneRole.Spine2] = new[] { "Spine2", "Spine3" },
[BoneRole.Spine3] = new[] { "Spine3", "Spine4" },
[BoneRole.Spine4] = new[] { "Spine4" },
[BoneRole.Neck] = new[] { "Neck", "Neck1" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var (roleSide, nameSide) in Sides())
{
aliases[Role("Clavicle", roleSide)] = new[] { $"{nameSide}Shoulder", $"{nameSide}Collar" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{nameSide}Arm", $"{nameSide}UpperArm" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{nameSide}ForeArm", $"{nameSide}LowerArm" };
aliases[Role("Hand", roleSide)] = new[] { $"{nameSide}Hand" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"{nameSide}UpLeg", $"{nameSide}Thigh", $"{nameSide}UpperLeg" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{nameSide}Leg", $"{nameSide}Shin", $"{nameSide}LowerLeg" };
aliases[Role("Foot", roleSide)] = new[] { $"{nameSide}Foot" };
aliases[Role("Toe", roleSide)] = new[] { $"{nameSide}Toe", $"{nameSide}ToeBase" };
}
return new Profile("rokoko_bvh", new string[0], aliases);
}
// ---------------------------------------------------------------- smpl / smpl-x
private static Profile BuildSmpl(bool withFingers)
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Pelvis" },
[BoneRole.Spine0] = new[] { "Spine1" },
[BoneRole.Spine1] = new[] { "Spine2" },
[BoneRole.Spine2] = new[] { "Spine3" },
[BoneRole.Neck] = new[] { "Neck" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var (roleSide, abbr, word) in new[] { ("L", "L", "left"), ("R", "R", "right") })
{
// Both documented spellings per role: abbreviated FBX-rig names ("L_Hip") and
// spelled model joint names ("left_hip"). Comparison is separator-insensitive.
aliases[Role("Clavicle", roleSide)] = new[] { $"{abbr}_Collar", $"{word}_collar" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{abbr}_Shoulder", $"{word}_shoulder" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{abbr}_Elbow", $"{word}_elbow" };
aliases[Role("Hand", roleSide)] = new[] { $"{abbr}_Wrist", $"{word}_wrist" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"{abbr}_Hip", $"{word}_hip" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{abbr}_Knee", $"{word}_knee" };
aliases[Role("Foot", roleSide)] = new[] { $"{abbr}_Ankle", $"{word}_ankle" };
aliases[Role("Toe", roleSide)] = new[] { $"{abbr}_Foot", $"{word}_foot" };
if (!withFingers)
continue;
// SMPL-X finger joints (left_index1..3 etc., per vchoutas/smplx joint_names.py).
foreach (var finger in new[] { "thumb", "index", "middle", "ring", "pinky" })
{
var name = char.ToUpperInvariant(finger[0]) + finger[1..];
aliases[Role($"{name}Prox", roleSide)] = new[] { $"{word}_{finger}1" };
aliases[Role($"{name}Mid", roleSide)] = new[] { $"{word}_{finger}2" };
aliases[Role($"{name}Dist", roleSide)] = new[] { $"{word}_{finger}3" };
}
}
// Gendered SMPL FBX rigs prefix every bone (m_avg_L_Hip, f_avg_Pelvis).
return new Profile(withFingers ? "smpl_x" : "smpl", new[] { "^m_avg_", "^f_avg_" }, aliases);
}
// ---------------------------------------------------------------- nvidia soma bvh
private static Profile BuildSomaBvh()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Hips" },
[BoneRole.Spine0] = new[] { "Spine1" },
[BoneRole.Spine1] = new[] { "Spine2" },
[BoneRole.Spine2] = new[] { "Chest" },
[BoneRole.Neck] = new[] { "Neck1" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var (roleSide, nameSide) in Sides())
{
aliases[Role("Clavicle", roleSide)] = new[] { $"{nameSide}Shoulder" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{nameSide}Arm" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{nameSide}ForeArm" };
aliases[Role("Hand", roleSide)] = new[] { $"{nameSide}Hand" };
// SOMA's "Leg" is the thigh, "Shin" the calf — the decisive difference from
// mixamo, where "Leg" is the calf under "UpLeg".
aliases[Role("UpperLeg", roleSide)] = new[] { $"{nameSide}Leg" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{nameSide}Shin" };
aliases[Role("Foot", roleSide)] = new[] { $"{nameSide}Foot" };
aliases[Role("Toe", roleSide)] = new[] { $"{nameSide}ToeBase" };
// Mixamo-style finger names; segment 4 ("LeftHandIndex4") and the *End markers
// carry no role.
foreach (var finger in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" })
{
aliases[Role($"{finger}Prox", roleSide)] = new[] { $"{nameSide}Hand{finger}1" };
aliases[Role($"{finger}Mid", roleSide)] = new[] { $"{nameSide}Hand{finger}2" };
aliases[Role($"{finger}Dist", roleSide)] = new[] { $"{nameSide}Hand{finger}3" };
}
}
return new Profile("soma_bvh", new string[0], aliases);
}
// ---------------------------------------------------------------- classic bvh
private static Profile BuildClassicBvh()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Hips" },
[BoneRole.Spine0] = new[] { "Chest" },
[BoneRole.Spine1] = new[] { "Chest2" },
[BoneRole.Spine2] = new[] { "Chest3" },
[BoneRole.Spine3] = new[] { "Chest4" },
[BoneRole.Neck] = new[] { "Neck" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var (roleSide, nameSide) in Sides())
{
aliases[Role("Clavicle", roleSide)] = new[] { $"{nameSide}Collar" };
aliases[Role("UpperArm", roleSide)] = new[] { $"{nameSide}Shoulder" };
aliases[Role("LowerArm", roleSide)] = new[] { $"{nameSide}Elbow" };
aliases[Role("Hand", roleSide)] = new[] { $"{nameSide}Wrist" };
aliases[Role("UpperLeg", roleSide)] = new[] { $"{nameSide}Hip" };
aliases[Role("LowerLeg", roleSide)] = new[] { $"{nameSide}Knee" };
aliases[Role("Foot", roleSide)] = new[] { $"{nameSide}Ankle" };
aliases[Role("Toe", roleSide)] = new[] { $"{nameSide}Toe" };
}
return new Profile("classic_bvh", new string[0], aliases);
}
// ---------------------------------------------------------------- 3ds max biped
private static Profile BuildBiped()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "Pelvis" },
[BoneRole.Spine0] = new[] { "Spine" },
[BoneRole.Spine1] = new[] { "Spine1" },
[BoneRole.Spine2] = new[] { "Spine2" },
[BoneRole.Spine3] = new[] { "Spine3" },
[BoneRole.Neck] = new[] { "Neck" },
[BoneRole.Head] = new[] { "Head" },
};
foreach (var s in new[] { "L", "R" })
{
aliases[Role("Clavicle", s)] = new[] { $"{s} Clavicle" };
aliases[Role("UpperArm", s)] = new[] { $"{s} UpperArm" };
aliases[Role("LowerArm", s)] = new[] { $"{s} Forearm" };
aliases[Role("Hand", s)] = new[] { $"{s} Hand" };
aliases[Role("UpperLeg", s)] = new[] { $"{s} Thigh" };
aliases[Role("LowerLeg", s)] = new[] { $"{s} Calf" };
aliases[Role("Foot", s)] = new[] { $"{s} Foot" };
aliases[Role("Toe", s)] = new[] { $"{s} Toe0" };
// Numbered finger chains: Finger0 is the thumb; segment names append the
// phalanx digit (Finger0 → Finger01 → Finger02, Finger1 → Finger11 → ...).
foreach (var (finger, n) in new[]
{
("Thumb", 0), ("Index", 1), ("Middle", 2), ("Ring", 3), ("Pinky", 4),
})
{
aliases[Role($"{finger}Prox", s)] = new[] { $"{s} Finger{n}" };
aliases[Role($"{finger}Mid", s)] = new[] { $"{s} Finger{n}1" };
aliases[Role($"{finger}Dist", s)] = new[] { $"{s} Finger{n}2" };
}
}
// "Bip01 "/"Bip001 " biped-name prefix; underscore form covers exporters that
// mangle the spaces ("Bip01_L_Thigh"). The bare COM root "Bip01" is untouched by
// the pattern (no trailing separator) and has no alias.
return new Profile("biped", new[] { @"^Bip\d+[ _]" }, aliases);
}
// ---------------------------------------------------------------- daz / poser
private static Profile BuildDazPoser()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "hip" },
[BoneRole.Spine0] = new[] { "abdomen" },
// Poser classic / DAZ Gen4 spine is abdomen→chest; DAZ Genesis 1/2 inserts
// abdomen2. Ordered preference + used-bone exclusion handles both: without
// abdomen2 the chest falls back to Spine1 and Spine2 stays unmapped.
[BoneRole.Spine1] = new[] { "abdomen2", "chest" },
[BoneRole.Spine2] = new[] { "chest" },
[BoneRole.Neck] = new[] { "neck" },
[BoneRole.Head] = new[] { "head" },
};
foreach (var s in new[] { "L", "R" })
{
var p = s == "L" ? "l" : "r"; // lower-case side prefix: lShldr, rThigh
aliases[Role("Clavicle", s)] = new[] { $"{p}Collar" };
aliases[Role("UpperArm", s)] = new[] { $"{p}Shldr" };
aliases[Role("LowerArm", s)] = new[] { $"{p}ForeArm" };
aliases[Role("Hand", s)] = new[] { $"{p}Hand" };
aliases[Role("UpperLeg", s)] = new[] { $"{p}Thigh" };
aliases[Role("LowerLeg", s)] = new[] { $"{p}Shin" };
aliases[Role("Foot", s)] = new[] { $"{p}Foot" };
aliases[Role("Toe", s)] = new[] { $"{p}Toe" };
foreach (var (role, daz) in new[]
{
("Thumb", "Thumb"), ("Index", "Index"), ("Middle", "Mid"), ("Ring", "Ring"), ("Pinky", "Pinky"),
})
{
aliases[Role($"{role}Prox", s)] = new[] { $"{p}{daz}1" };
aliases[Role($"{role}Mid", s)] = new[] { $"{p}{daz}2" };
aliases[Role($"{role}Dist", s)] = new[] { $"{p}{daz}3" };
}
}
return new Profile("daz_poser", new string[0], aliases);
}
// ---------------------------------------------------------------- blender rigify
private static Profile BuildRigify()
{
var aliases = new Dictionary<BoneRole, string[]>
{
// rigify's "spine" bone sits AT the pelvis and parents both thighs — it is
// the hips, not a spine link (rigify/metarigs/human.py).
[BoneRole.Hips] = new[] { "spine" },
[BoneRole.Spine0] = new[] { "spine.001" },
[BoneRole.Spine1] = new[] { "spine.002" },
[BoneRole.Spine2] = new[] { "spine.003" },
// spine.004 + spine.005 are the two neck bones, spine.006 the head;
// spine.005 stays unmapped (same policy as ActorCore's NeckTwist02).
[BoneRole.Neck] = new[] { "spine.004" },
[BoneRole.Head] = new[] { "spine.006" },
};
foreach (var s in new[] { "L", "R" })
{
aliases[Role("Clavicle", s)] = new[] { $"shoulder.{s}" };
aliases[Role("UpperArm", s)] = new[] { $"upper_arm.{s}" };
aliases[Role("LowerArm", s)] = new[] { $"forearm.{s}" };
aliases[Role("Hand", s)] = new[] { $"hand.{s}" };
aliases[Role("UpperLeg", s)] = new[] { $"thigh.{s}" };
aliases[Role("LowerLeg", s)] = new[] { $"shin.{s}" };
aliases[Role("Foot", s)] = new[] { $"foot.{s}" };
aliases[Role("Toe", s)] = new[] { $"toe.{s}" };
foreach (var (role, rigify) in new[]
{
("Thumb", "thumb"), ("Index", "f_index"), ("Middle", "f_middle"),
("Ring", "f_ring"), ("Pinky", "f_pinky"),
})
{
aliases[Role($"{role}Prox", s)] = new[] { $"{rigify}.01.{s}" };
aliases[Role($"{role}Mid", s)] = new[] { $"{rigify}.02.{s}" };
aliases[Role($"{role}Dist", s)] = new[] { $"{rigify}.03.{s}" };
}
}
// The generated deform skeleton prefixes every deform bone with "DEF-"; its
// segmented limb twins ("DEF-upper_arm.L.001") keep their numeric suffix after
// stripping and therefore never collide with the whole-bone aliases.
return new Profile("rigify", new[] { "^DEF-" }, aliases);
}
// ---------------------------------------------------------------- vroid / vrm
private static Profile BuildVrm()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "J_Bip_C_Hips" },
[BoneRole.Spine0] = new[] { "J_Bip_C_Spine" },
[BoneRole.Spine1] = new[] { "J_Bip_C_Chest" },
[BoneRole.Spine2] = new[] { "J_Bip_C_UpperChest" },
[BoneRole.Neck] = new[] { "J_Bip_C_Neck" },
[BoneRole.Head] = new[] { "J_Bip_C_Head" },
};
foreach (var s in new[] { "L", "R" })
{
aliases[Role("Clavicle", s)] = new[] { $"J_Bip_{s}_Shoulder" };
aliases[Role("UpperArm", s)] = new[] { $"J_Bip_{s}_UpperArm" };
aliases[Role("LowerArm", s)] = new[] { $"J_Bip_{s}_LowerArm" };
aliases[Role("Hand", s)] = new[] { $"J_Bip_{s}_Hand" };
aliases[Role("UpperLeg", s)] = new[] { $"J_Bip_{s}_UpperLeg" };
aliases[Role("LowerLeg", s)] = new[] { $"J_Bip_{s}_LowerLeg" };
aliases[Role("Foot", s)] = new[] { $"J_Bip_{s}_Foot" };
aliases[Role("Toe", s)] = new[] { $"J_Bip_{s}_ToeBase" };
foreach (var (role, vrm) in new[]
{
("Thumb", "Thumb"), ("Index", "Index"), ("Middle", "Middle"),
("Ring", "Ring"), ("Pinky", "Little"),
})
{
aliases[Role($"{role}Prox", s)] = new[] { $"J_Bip_{s}_{vrm}1" };
aliases[Role($"{role}Mid", s)] = new[] { $"J_Bip_{s}_{vrm}2" };
aliases[Role($"{role}Dist", s)] = new[] { $"J_Bip_{s}_{vrm}3" };
}
}
return new Profile("vrm", new string[0], aliases);
}
// ---------------------------------------------------------------- auto-rig pro
private static Profile BuildAutoRigPro()
{
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "root.x" },
[BoneRole.Spine0] = new[] { "spine_01.x" },
[BoneRole.Spine1] = new[] { "spine_02.x" },
[BoneRole.Spine2] = new[] { "spine_03.x" },
[BoneRole.Neck] = new[] { "neck.x" },
[BoneRole.Head] = new[] { "head.x" },
};
foreach (var s in new[] { "L", "R" })
{
var p = s == "L" ? "l" : "r";
aliases[Role("Clavicle", s)] = new[] { $"shoulder.{p}" };
aliases[Role("UpperArm", s)] = new[] { $"arm_stretch.{p}" };
aliases[Role("LowerArm", s)] = new[] { $"forearm_stretch.{p}" };
aliases[Role("Hand", s)] = new[] { $"hand.{p}" };
aliases[Role("UpperLeg", s)] = new[] { $"thigh_stretch.{p}" };
aliases[Role("LowerLeg", s)] = new[] { $"leg_stretch.{p}" };
aliases[Role("Foot", s)] = new[] { $"foot.{p}" };
aliases[Role("Toe", s)] = new[] { $"toes_01.{p}" };
// Exported finger deform bones keep ARP's c_ control prefix (Defenses.fbx).
foreach (var finger in new[] { "thumb", "index", "middle", "ring", "pinky" })
{
var role = char.ToUpperInvariant(finger[0]) + finger[1..];
aliases[Role($"{role}Prox", s)] = new[] { $"c_{finger}1.{p}" };
aliases[Role($"{role}Mid", s)] = new[] { $"c_{finger}2.{p}" };
aliases[Role($"{role}Dist", s)] = new[] { $"c_{finger}3.{p}" };
}
}
return new Profile("auto_rig_pro", new string[0], aliases);
}
// ---------------------------------------------------------------- helpers
private static IEnumerable<(string RoleSide, string NameSide)> Sides()
{
yield return ("L", "Left");
yield return ("R", "Right");
}
private static BoneRole Role(string baseName, string side)
=> System.Enum.Parse<BoneRole>(baseName + side);
}
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Target;
namespace HumanoidRetargeter.Solve;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>
/// Mirrors a solved TARGET-space clip across the target character's sagittal plane,
/// producing the left/right-swapped twin of an animation (e.g. a right-foot-lead walk from a
/// left-foot-lead one).
/// </summary>
/// <remarks>
/// <para><b>Mirror plane.</b> The plane through the rig-space origin spanned by the target
/// character's up and forward directions; its normal is the character's LATERAL axis,
/// computed from the target rig's rest geometry via <see cref="CharacterFrame"/> (never
/// hardcoded — an arbitrary target may be authored in any axis convention). When the
/// computed lateral lies on a coordinate axis up to float dirt (< 1e-3 on the other two
/// components — true for every axis-aligned authored rig, including the s&box citizen
/// rigs), it is snapped to that exact axis, which makes every reflection below an EXACT
/// sign-flip in IEEE arithmetic and therefore the whole mirror a bit-exact involution
/// (mirror ∘ mirror == identity, verified by test).</para>
/// <para><b>Math.</b> Let M = I − 2n̂n̂ᵀ be the reflection across the plane with unit normal
/// n̂. A world transform W = (R, t) maps to its mirror image by conjugation:
/// W′ = M̂ ∘ W ∘ M̂ (M̂ is its own inverse), giving rotation R′ = M·R·M and translation
/// t′ = M·t. For a quaternion q = (v, w), M·R·M is the rotation by the SAME angle about the
/// REFLECTED axis with REVERSED sense (a reflection flips orientation), i.e.
/// q′ = (2(n̂·v)n̂ − v, w); with n̂ = +X that is exactly q′ = (x, −y, −z, w), and positions
/// reflect as p′ = p − 2(n̂·p)n̂ = (−pₓ, p_y, p_z).</para>
/// <para><b>Locals, not worlds.</b> Because conjugation is a homomorphism
/// (M̂(AB)M̂ = (M̂AM̂)(M̂BM̂)) and world transforms are products of locals down the
/// hierarchy, mirroring every LOCAL transform and permuting bones by their L↔R partner is
/// exactly equivalent to mirroring the FK worlds — provided the partner permutation is
/// hierarchy-consistent (the partner's parent is the parent's partner), which is validated
/// and holds on structurally symmetric humanoid rigs. This avoids FK→inverse-FK float drift
/// entirely, which is what makes the double-mirror identity bit-exact.</para>
/// <para><b>Pairing.</b> Left/right bones are paired by the rig's canonical role annotations
/// first (UpperArmL ↔ UpperArmR, …); role-less bones (twist helpers, IK bones) fall back to
/// <c>_L</c>/<c>_R</c> name-token pairing (<c>arm_upper_L_twist0</c> ↔
/// <c>arm_upper_R_twist0</c>, <c>foot_L_IK_target</c> ↔ <c>foot_R_IK_target</c>); anything
/// unpaired (center bones: pelvis, spine, neck, head) mirrors in place, which reflects its
/// rotation across the sagittal plane and negates its lateral translation. IK-baked bones
/// should be re-baked from the mirrored body afterwards (<see cref="IkBoneBaker"/>) — the
/// pipeline does exactly that.</para>
/// </remarks>
public static class ClipMirror
{
/// <summary>Maximum off-axis component magnitude below which the computed lateral axis is
/// snapped to the exact coordinate axis (authored rigs are axis-aligned; the tiny rest
/// asymmetries of a real mesh stay far below this).</summary>
private const float AxisSnapTolerance = 1e-3f;
/// <summary>
/// Returns the mirrored copy of <paramref name="frames"/> (one new list, inputs
/// untouched): per frame, bone i takes the conjugated local transform of its L↔R partner
/// σ(i). See the class remarks for the math and pairing rules.
/// </summary>
/// <param name="frames">Solved per-frame local transforms (target skeleton bone order).</param>
/// <param name="rig">The target rig (skeleton + roles) the frames belong to.</param>
/// <exception cref="ArgumentException">Thrown when the rig maps a sided role without its
/// counterpart, the pairing is not hierarchy-consistent, or the character frame is not
/// computable — mirroring would silently produce garbage in those cases.</exception>
public static List<XForm[]> Mirror(List<XForm[]> frames, TargetRig rig)
{
ArgumentNullException.ThrowIfNull(frames);
ArgumentNullException.ThrowIfNull(rig);
var skeleton = rig.Skeleton;
var lateral = LateralAxis(rig);
var pair = BuildPairing(rig);
var result = new List<XForm[]>(frames.Count);
foreach (var locals in frames)
{
if (locals.Length != skeleton.Count)
throw new ArgumentException(
$"Frame has {locals.Length} bones but the target skeleton has {skeleton.Count}.",
nameof(frames));
var mirrored = new XForm[locals.Length];
for (var i = 0; i < locals.Length; i++)
{
var source = locals[pair[i]];
mirrored[i] = new XForm(
ReflectPoint(source.Pos, lateral),
ReflectRotation(source.Rot, lateral));
}
result.Add(mirrored);
}
return result;
}
// ================================================================ mirror plane
/// <summary>The unit mirror normal: the target character's lateral axis from rest
/// geometry, snapped to an exact coordinate axis when within tolerance (bit-exact
/// reflections, see class remarks).</summary>
private static Vector3 LateralAxis(TargetRig rig)
{
Vector3 lateral;
try
{
lateral = CharacterFrame.Compute(
rig.Skeleton, rig.ToMappingResult(), rig.Skeleton.RestWorld).Lateral;
}
catch (ArgumentException e)
{
throw new ArgumentException(
$"Cannot mirror: target character frame not computable ({e.Message}).", e);
}
var a = Vector3.Abs(lateral);
if (a.Y <= AxisSnapTolerance && a.Z <= AxisSnapTolerance)
return Vector3.UnitX;
if (a.X <= AxisSnapTolerance && a.Z <= AxisSnapTolerance)
return Vector3.UnitY;
if (a.X <= AxisSnapTolerance && a.Y <= AxisSnapTolerance)
return Vector3.UnitZ;
return lateral; // general (non-axis-aligned) rig: exact involution is lost, math is not
}
/// <summary>p′ = p − 2(n̂·p)n̂. With a snapped axis this is an exact sign flip of one
/// component (IEEE subtraction of representable values is exact).</summary>
private static Vector3 ReflectPoint(Vector3 p, Vector3 n)
=> p - 2f * Vector3.Dot(p, n) * n;
/// <summary>q′ = (2(n̂·v)n̂ − v, w): the conjugated rotation M·R·M — same angle, axis
/// reflected, sense reversed. With n̂ = +X this is (x, −y, −z, w). Components are
/// preserved exactly (no renormalization), keeping the double mirror bit-exact.</summary>
private static Quaternion ReflectRotation(Quaternion q, Vector3 n)
{
var v = new Vector3(q.X, q.Y, q.Z);
var reflected = 2f * Vector3.Dot(v, n) * n - v;
return new Quaternion(reflected.X, reflected.Y, reflected.Z, q.W);
}
// ================================================================ L↔R pairing
/// <summary>
/// σ: bone → mirror partner (identity for center/unpaired bones). Roles pair first;
/// role-less bones pair by <c>_L</c>/<c>_R</c> name tokens. Validated to be an involution
/// consistent with the hierarchy (σ(parent(i)) == parent(σ(i))).
/// </summary>
private static int[] BuildPairing(TargetRig rig)
{
var skeleton = rig.Skeleton;
var pair = new int[skeleton.Count];
for (var i = 0; i < pair.Length; i++)
pair[i] = i;
for (var i = 0; i < skeleton.Count; i++)
{
if (rig.RoleOf(i) is { } role)
{
if (MirrorRole(role) is not { } mirroredRole)
continue; // center role: mirrors in place
pair[i] = rig.BoneForRole(mirroredRole)
?? throw new ArgumentException(
$"Cannot mirror: target rig maps role {role} ('{skeleton[i].Name}') "
+ $"but not its counterpart {mirroredRole}.");
}
else
{
var partnerName = SwapSideTokens(skeleton[i].Name);
if (partnerName is null)
continue; // no side token: center bone
var partner = skeleton.IndexOf(partnerName);
if (partner >= 0)
pair[i] = partner;
// No partner bone: leave in place (e.g. an asymmetric prop bone) — its
// rotation still mirrors across the sagittal plane.
}
}
for (var i = 0; i < pair.Length; i++)
{
if (pair[pair[i]] != i)
throw new ArgumentException(
$"Cannot mirror: bone pairing is not symmetric ('{skeleton[i].Name}' → "
+ $"'{skeleton[pair[i]].Name}' → '{skeleton[pair[pair[i]]].Name}').");
var parent = skeleton[i].ParentIndex;
var partnerParent = skeleton[pair[i]].ParentIndex;
var consistent = parent < 0
? partnerParent < 0
: partnerParent == pair[parent];
if (!consistent)
throw new ArgumentException(
$"Cannot mirror: left/right pairing is not hierarchy-consistent — "
+ $"'{skeleton[i].Name}' and partner '{skeleton[pair[i]].Name}' hang under "
+ "non-mirrored parents.");
}
return pair;
}
/// <summary>UpperArmL → UpperArmR (and back); null for center roles. Every sided
/// <see cref="BoneRole"/> ends in <c>L</c>/<c>R</c>; no center role does.</summary>
private static BoneRole? MirrorRole(BoneRole role)
{
var name = role.ToString();
var mirroredName = name[^1] switch
{
'L' => name[..^1] + "R",
'R' => name[..^1] + "L",
_ => null,
};
return mirroredName is not null && Enum.TryParse<BoneRole>(mirroredName, out var mirrored)
? mirrored
: null;
}
/// <summary>Swaps <c>L</c>/<c>R</c> underscore-delimited name tokens
/// (<c>foot_L_IK_target</c> → <c>foot_R_IK_target</c>); null when the name carries no
/// side token.</summary>
private static string? SwapSideTokens(string name)
{
var tokens = name.Split('_');
for (var i = 0; i < tokens.Length; i++)
{
tokens[i] = tokens[i] switch
{
"L" => "R",
"R" => "L",
"l" => "r",
"r" => "l",
_ => tokens[i],
};
}
var result = string.Join('_', tokens);
return string.Equals(result, name, StringComparison.Ordinal) ? null : result;
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
namespace HumanoidRetargeter.Solve;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>
/// Finger retargeting. Picks one of three strategies per finger chain:
/// <list type="number">
/// <item><b>1:1 absolute copy</b> (via the <c>transferOneToOne</c> callback into
/// <see cref="GeometricSolver"/>'s body path) when the source and target chains are
/// <i>geometrically identical</i> — same mapped role set, same canonical frames, same
/// normalized rest rotations. This is the same-rig round-trip case and is lossless (exact
/// identity, twist included).</item>
/// <item><b>Direction matching</b> when the phalanx counts match ordinally but the rigs
/// differ (the common cross-rig case, e.g. Mixamo Prox/Mid/Dist onto the s&box finger
/// with its extra metacarpal — which keeps its rest local; a source metacarpal's rotation is
/// implicit in the proximal's absolute direction). Each target phalanx is swung — shortest
/// arc, rotation axis ⊥ the finger axis, hence <b>zero twist by construction</b> — so that its
/// segment direction matches the source phalanx's direction in character-frame coordinates
/// exactly. Curl and splay are both captured by the direction; the source's axial twist is
/// dropped (hinge-joint noise; copying it absolutely would read as roll through the
/// inter-phalanx canonical mismatch between rigs, measured up to ~12° on thumbs).</item>
/// <item><b>Proportional redistribution</b> when phalanx counts differ (e.g. a two-phalanx
/// source finger): per-phalanx local curls — swing-twist about the canonical hinge Y of
/// <c>λ_b = C_b⁻¹·(ΔR_prev⁻¹·ΔR_b)·C_b</c> — are summed over the source chain (metacarpal
/// included) and redistributed over the target phalanges proportional to rest segment
/// lengths; splay (metacarpal + proximal, swing-twist about canonical Z) goes 100% to the
/// target proximal; the X-twist residual is dropped.</item>
/// </list>
/// In every mode target world deltas rebuild hierarchically from the solved target hand:
/// <c>ΔR_i = ΔR_{i-1} · (C_i · λ_i · C_i⁻¹)</c>, then <c>W_i = ΔR_i · R_tgtNormRest,i</c>.
/// Instances are per-solve and not thread-safe.
/// </summary>
internal sealed class FingerSolver
{
/// <summary>Two canonical frames / rest rotations within this angle count as identical
/// (same-rig detection for the lossless 1:1 path); cross-rig differences are degrees.</summary>
private const float SameRigToleranceRad = 1e-3f;
private enum ChainMode
{
DirectionMatch,
Proportional,
}
private readonly struct SourcePhalanx
{
public required int Slot { get; init; }
public required Quaternion C { get; init; }
public required Quaternion CInv { get; init; }
public required bool TakesSplay { get; init; }
}
private readonly struct Recipient
{
public required int TgtBone { get; init; }
public required Quaternion C { get; init; }
public required Quaternion CInv { get; init; }
public required Quaternion RestRot { get; init; }
public required float Weight { get; init; }
public required bool Splay { get; init; }
}
private sealed class Chain
{
public required ChainMode Mode { get; init; }
public required int SrcHandSlot { get; init; }
public required int TgtHandBone { get; init; }
public required Quaternion TgtHandNormRestRotInv { get; init; }
public required SourcePhalanx[] Sources { get; init; }
public required Recipient[] Recipients { get; init; }
}
private readonly List<Chain> _chains;
private readonly Quaternion _chrSrcInv;
private readonly Quaternion _chrTgt;
private FingerSolver(List<Chain> chains, Quaternion chrSrcInv, Quaternion chrTgt)
{
_chains = chains;
_chrSrcInv = chrSrcInv;
_chrTgt = chrTgt;
}
// ---------------------------------------------------------------- role tables
private static readonly BoneRole[][] ChainRoles = BuildChainRoles();
private static readonly HashSet<BoneRole> FingerRoleSet = ChainRoles.SelectMany(c => c.Skip(1)).ToHashSet();
private static BoneRole[][] BuildChainRoles()
{
var chains = new List<BoneRole[]>();
foreach (var side in new[] { "L", "R" })
{
foreach (var finger in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" })
{
// Element 0 is the hand the chain hangs off; 1.. are Meta/Prox/Mid/Dist.
chains.Add(new[]
{
Enum.Parse<BoneRole>("Hand" + side),
Enum.Parse<BoneRole>(finger + "Meta" + side),
Enum.Parse<BoneRole>(finger + "Prox" + side),
Enum.Parse<BoneRole>(finger + "Mid" + side),
Enum.Parse<BoneRole>(finger + "Dist" + side),
});
}
}
return chains.ToArray();
}
/// <summary>True for the 40 per-finger segment roles (Meta/Prox/Mid/Dist × finger × side).</summary>
public static bool IsFingerRole(BoneRole role) => FingerRoleSet.Contains(role);
// ---------------------------------------------------------------- build
/// <summary>
/// Builds the per-chain plans. Geometrically identical chains are reported through
/// <paramref name="transferOneToOne"/> instead of being planned here. Returns null when
/// every mapped chain took that path (or none is mapped).
/// </summary>
public static FingerSolver? Build(
MappingResult sourceMap,
CanonicalFrames srcCanon,
IReadOnlyList<XForm> srcNormRest,
Func<BoneRole, int?> tgtBoneForRole,
CanonicalFrames tgtCanon,
IReadOnlyList<XForm> tgtNormRest,
Quaternion chrSrcInv,
Quaternion chrTgt,
Func<int, int> registerSlot,
Action<BoneRole> transferOneToOne)
{
var chains = new List<Chain>();
foreach (var chainRoles in ChainRoles)
{
var handRole = chainRoles[0];
var metaRole = chainRoles[1];
var proxRole = chainRoles[2];
var segments = chainRoles.Skip(1).ToArray();
var srcRoles = segments
.Where(r => sourceMap.RoleToBone.ContainsKey(r) && srcCanon.Has(r))
.ToArray();
var tgtRoles = segments
.Where(r => tgtBoneForRole(r) is not null && tgtCanon.Has(r))
.ToArray();
if (srcRoles.Length == 0 || tgtRoles.Length == 0)
continue;
if (srcRoles.SequenceEqual(tgtRoles) && ChainsCoincide(
srcRoles, sourceMap, srcCanon, srcNormRest, tgtBoneForRole, tgtCanon, tgtNormRest))
{
foreach (var role in srcRoles)
transferOneToOne(role);
continue;
}
var srcPhalanges = srcRoles.Where(r => r != metaRole).ToArray();
var tgtPhalanges = tgtRoles.Where(r => r != metaRole).ToArray();
var recipientRoles = tgtPhalanges.Length > 0 ? tgtPhalanges : tgtRoles;
var mode = srcPhalanges.Length == recipientRoles.Length && srcPhalanges.Length > 0
? ChainMode.DirectionMatch
: ChainMode.Proportional;
// Direction matching consumes only the non-meta phalanges (the metacarpal's
// motion is implicit in the proximal's absolute direction); redistribution
// decomposes every mapped source segment including the metacarpal.
var sourceRolesUsed = mode == ChainMode.DirectionMatch ? srcPhalanges : srcRoles;
var sources = sourceRolesUsed.Select(r =>
{
var c = srcCanon.WorldFrameOf(r);
return new SourcePhalanx
{
Slot = registerSlot(sourceMap.RoleToBone[r]),
C = c,
CInv = Quaternion.Conjugate(c),
TakesSplay = r == metaRole || r == proxRole,
};
}).ToArray();
var weights = SegmentWeights(tgtRoles, recipientRoles, tgtBoneForRole, tgtNormRest);
var recipients = recipientRoles.Select((r, i) =>
{
var bone = tgtBoneForRole(r)!.Value;
var c = tgtCanon.WorldFrameOf(r);
return new Recipient
{
TgtBone = bone,
C = c,
CInv = Quaternion.Conjugate(c),
RestRot = tgtNormRest[bone].Rot,
Weight = weights[i],
Splay = i == 0,
};
}).ToArray();
var tgtHand = tgtBoneForRole(handRole);
chains.Add(new Chain
{
Mode = mode,
SrcHandSlot = sourceMap.RoleToBone.TryGetValue(handRole, out var srcHand)
? registerSlot(srcHand)
: -1,
TgtHandBone = tgtHand ?? -1,
TgtHandNormRestRotInv = tgtHand is int h
? Quaternion.Conjugate(tgtNormRest[h].Rot)
: Quaternion.Identity,
Sources = sources,
Recipients = recipients,
});
}
return chains.Count > 0 ? new FingerSolver(chains, chrSrcInv, chrTgt) : null;
}
/// <summary>Same-rig detection: every chain member's canonical frame and normalized rest
/// rotation agree between source and target (within float noise). Only then is the 1:1
/// absolute copy lossless.</summary>
private static bool ChainsCoincide(
BoneRole[] roles, MappingResult sourceMap, CanonicalFrames srcCanon,
IReadOnlyList<XForm> srcNormRest, Func<BoneRole, int?> tgtBoneForRole,
CanonicalFrames tgtCanon, IReadOnlyList<XForm> tgtNormRest)
{
foreach (var role in roles)
{
var srcBone = sourceMap.RoleToBone[role];
var tgtBone = tgtBoneForRole(role)!.Value;
if (MathQ.AngleBetween(srcCanon.WorldFrameOf(role), tgtCanon.WorldFrameOf(role)) > SameRigToleranceRad
|| MathQ.AngleBetween(srcNormRest[srcBone].Rot, tgtNormRest[tgtBone].Rot) > SameRigToleranceRad)
{
return false;
}
}
return true;
}
/// <summary>Normalized rest segment lengths of the recipient phalanges (the proportional
/// curl weights). The distal segment, having no chain child, is estimated as 0.8× its
/// preceding segment.</summary>
private static float[] SegmentWeights(
BoneRole[] tgtRoles, BoneRole[] recipientRoles,
Func<BoneRole, int?> tgtBoneForRole, IReadOnlyList<XForm> tgtNormRest)
{
var positions = tgtRoles.Select(r => tgtNormRest[tgtBoneForRole(r)!.Value].Pos).ToArray();
var weights = new float[recipientRoles.Length];
for (var i = 0; i < recipientRoles.Length; i++)
{
var j = Array.IndexOf(tgtRoles, recipientRoles[i]);
weights[i] = j + 1 < positions.Length
? (positions[j + 1] - positions[j]).Length()
: j > 0 ? 0.8f * (positions[j] - positions[j - 1]).Length() : 1f;
}
var sum = weights.Sum();
if (sum <= 1e-6f)
return Enumerable.Repeat(1f / weights.Length, weights.Length).ToArray();
for (var i = 0; i < weights.Length; i++)
weights[i] /= sum;
return weights;
}
// ---------------------------------------------------------------- per frame
/// <summary>
/// Solves the planned chains for one frame. <paramref name="srcDeltas"/> holds the
/// registered source world rotation deltas (from normalized rest); solved target world
/// rotations are written into <paramref name="rot"/>/<paramref name="solved"/>. The target
/// hands must already be solved (body pass runs first).
/// </summary>
public void Apply(Quaternion[] srcDeltas, bool[] solved, Quaternion[] rot)
{
foreach (var chain in _chains)
{
var acc = chain.TgtHandBone >= 0 && solved[chain.TgtHandBone]
? MathQ.Normalize(rot[chain.TgtHandBone] * chain.TgtHandNormRestRotInv)
: Quaternion.Identity;
if (chain.Mode == ChainMode.DirectionMatch)
ApplyDirectionMatch(chain, srcDeltas, acc, solved, rot);
else
ApplyProportional(chain, srcDeltas, acc, solved, rot);
}
}
private void ApplyDirectionMatch(
Chain chain, Quaternion[] srcDeltas, Quaternion acc, bool[] solved, Quaternion[] rot)
{
for (var i = 0; i < chain.Recipients.Length; i++)
{
var sp = chain.Sources[i];
var rc = chain.Recipients[i];
// Source phalanx direction in character coords; re-expressed in the target world,
// then relative to the already-reconstructed parent delta, then in the phalanx's
// canonical frame — where the rest direction is unit X.
var srcAbs = MathQ.Normalize(_chrSrcInv * srcDeltas[sp.Slot] * sp.C);
var dirChr = Vector3.Transform(Vector3.UnitX, srcAbs);
var dirTgtWorld = Vector3.Transform(dirChr, _chrTgt);
var dirLocal = Vector3.Transform(dirTgtWorld, Quaternion.Conjugate(acc));
var dirCanon = Vector3.Transform(dirLocal, rc.CInv);
// Shortest-arc swing X -> dir: rotation axis ⊥ X, so it carries zero finger-axis
// twist by construction.
var swing = MathQ.FromTo(Vector3.UnitX, dirCanon);
acc = MathQ.Normalize(acc * (rc.C * swing * rc.CInv));
rot[rc.TgtBone] = MathQ.Normalize(acc * rc.RestRot);
solved[rc.TgtBone] = true;
}
}
private static void ApplyProportional(
Chain chain, Quaternion[] srcDeltas, Quaternion acc, bool[] solved, Quaternion[] rot)
{
// Decompose: total local curl over the chain, splay from metacarpal + proximal.
var prev = chain.SrcHandSlot >= 0 ? srcDeltas[chain.SrcHandSlot] : Quaternion.Identity;
float totalCurl = 0f, splay = 0f;
foreach (var sp in chain.Sources)
{
var dr = srcDeltas[sp.Slot];
var local = MathQ.Normalize(Quaternion.Conjugate(prev) * dr);
var canon = MathQ.Normalize(sp.CInv * local * sp.C);
MathQ.SwingTwist(canon, Vector3.UnitY, out var swing, out var curlQ);
totalCurl += SignedAngle(curlQ, Vector3.UnitY);
if (sp.TakesSplay)
{
MathQ.SwingTwist(swing, Vector3.UnitZ, out _, out var splayQ);
splay += SignedAngle(splayQ, Vector3.UnitZ);
}
prev = dr;
}
foreach (var rc in chain.Recipients)
{
var mu = Quaternion.CreateFromAxisAngle(Vector3.UnitY, totalCurl * rc.Weight);
if (rc.Splay)
mu = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, splay) * mu;
acc = MathQ.Normalize(acc * (rc.C * mu * rc.CInv));
rot[rc.TgtBone] = MathQ.Normalize(acc * rc.RestRot);
solved[rc.TgtBone] = true;
}
}
/// <summary>Signed rotation angle of an axis-aligned twist quaternion about
/// <paramref name="axis"/>, wrapped to (−π, π].</summary>
private static float SignedAngle(Quaternion twist, Vector3 axis)
{
var s = twist.X * axis.X + twist.Y * axis.Y + twist.Z * axis.Z;
var angle = 2f * MathF.Atan2(s, twist.W);
if (angle > MathF.PI)
angle -= 2f * MathF.PI;
else if (angle < -MathF.PI)
angle += 2f * MathF.PI;
return angle;
}
}
using System.Collections.Generic;
using HumanoidRetargeter.Mapping;
namespace HumanoidRetargeter.Solve;
/// <summary>How a mapped role's rotation is transferred by the <see cref="GeometricSolver"/>.</summary>
public enum RoleTransferMode
{
/// <summary>
/// Absolute canonical-orientation matching: the target's animated chain direction is
/// driven to <b>equal</b> the source's (in character-frame coordinates). Right for limbs
/// and the spine — the pose IS the direction — but it also imposes the source rig's rest
/// proportions/posture on roles whose rest directions legitimately differ between rigs.
/// </summary>
AbsoluteDirection,
/// <summary>
/// Rest-relative delta: the source's canonical-space rotation <i>delta from its own
/// normalized rest</i> is replayed onto the <b>target's</b> normalized rest
/// (<c>W_t(f) = C_t·ΔC(f)·C_t⁻¹·R_tgtNormRest</c> with
/// <c>ΔC(f) = C_s⁻¹·ΔR(f)·C_s</c>). The target keeps its own rest carriage (shoulder
/// line height, neck-base angle) and moves with the source. Identical to
/// <see cref="AbsoluteDirection"/> when source and target rigs coincide.
/// </summary>
DeltaFromRest,
/// <summary>
/// Character-space delta: the source's world-rotation delta from its normalized rest is
/// re-expressed in character coordinates and applied to the <b>target's</b> normalized
/// rest (<c>W_t(f) = M·ΔR(f)·M⁻¹·R_tgtNormRest</c> with <c>M = Q_tgt·Q_src⁻¹</c>, the
/// same character basis change <see cref="AbsoluteDirection"/> premultiplies). Like
/// <see cref="DeltaFromRest"/> the target keeps its own rest carriage, but the delta
/// keeps its <i>world</i> rotation axes instead of being remapped through the per-role
/// canonical frames — the faithful replay when the rigs' rest chain directions diverge
/// so far that canonical-axis remapping would tilt every rotation axis by that
/// divergence (measured 23–44° on feet: CMU/ARP ankle anatomy vs the s&box rig's
/// steep ankle, where canonical remapping mis-pitched planted feet by up to 47°).
/// Identical to the other modes when source and target rigs coincide.
/// </summary>
CharacterDeltaFromRest,
}
/// <summary>Options controlling a single retarget solve (one clip → one output clip).</summary>
public sealed class SolveOptions
{
/// <summary>
/// Default per-role transfer modes: shoulder girdle and neck carriage are
/// <see cref="RoleTransferMode.DeltaFromRest"/> (each rig's clavicle line / neck-base
/// direction is rig anatomy, not pose — absolute matching was measured to drag the
/// s&box shoulders 6–28° toward the source's flatter/lower clavicle line and is the
/// "low shoulders, hunched neck" artifact), and feet are
/// <see cref="RoleTransferMode.CharacterDeltaFromRest"/> (a rest foot→toe direction is
/// ankle anatomy too — rigs diverge 11–44° from the s&box rig's steep ankle, so
/// absolute matching pitched planted feet up to 25° off flat, the "feet bent
/// upward/inward" artifact; the character-space delta keeps the rotation's world axes,
/// which canonical-frame remapping would tilt by that same divergence). The head is
/// <see cref="RoleTransferMode.CharacterDeltaFromRest"/> for the same reason: the rest
/// neck→head direction is head-joint-placement anatomy (measured 0–27° forward lean
/// across neutral-rest rigs vs the s&box rig's 25.5°), so the target keeps its own
/// neutral skull attitude and replays the source's attitude <i>changes</i> — for the
/// head this computes exactly what the previous virtual-frame absolute matching did.
/// Two solver fallbacks adjust these defaults per rig pair: on a toe-less source the
/// foot entries become <see cref="RoleTransferMode.DeltaFromRest"/> (virtual-foot
/// fallback), and a source whose normalized rest head attitude is implausible as a
/// neutral carriage (a posed bind — e.g. a chin-down/tilted fighting-stance rest,
/// measured 40.7° forward / 16.9° lateral on such a rig where the delta replay read
/// ~12° "looking up at an angle") switches the head to
/// <see cref="RoleTransferMode.AbsoluteDirection"/> so the gaze follows the source
/// absolutely instead of replaying deltas from a posed reference (see the
/// <see cref="GeometricSolver"/> remarks for both). Everything else (limbs, spine,
/// toes, fingers) stays absolute: there the worldspace direction IS the pose.
/// </summary>
public static IReadOnlyDictionary<BoneRole, RoleTransferMode> DefaultTransferModes { get; } =
new Dictionary<BoneRole, RoleTransferMode>
{
[BoneRole.ClavicleL] = RoleTransferMode.DeltaFromRest,
[BoneRole.ClavicleR] = RoleTransferMode.DeltaFromRest,
[BoneRole.Neck] = RoleTransferMode.DeltaFromRest,
[BoneRole.Head] = RoleTransferMode.CharacterDeltaFromRest,
[BoneRole.FootL] = RoleTransferMode.CharacterDeltaFromRest,
[BoneRole.FootR] = RoleTransferMode.CharacterDeltaFromRest,
};
/// <summary>
/// Per-role transfer modes. Null (default) = <see cref="DefaultTransferModes"/> plus the
/// solver's fallback heuristics (a toe-less source's virtual foot direction overrides
/// the foot default to <see cref="RoleTransferMode.DeltaFromRest"/>, and a posed-rest
/// source head overrides the head default to
/// <see cref="RoleTransferMode.AbsoluteDirection"/> — see the
/// <see cref="GeometricSolver"/> remarks). A non-null map REPLACES the defaults entirely
/// and disables every fallback heuristic: each role uses exactly the mode in the map, and
/// roles absent from it are <see cref="RoleTransferMode.AbsoluteDirection"/>. Pass an
/// empty dictionary for fully absolute (legacy) behavior — API callers supplying a map
/// opt out of all heuristics.
/// </summary>
public IReadOnlyDictionary<BoneRole, RoleTransferMode>? TransferModes { get; init; }
/// <summary>
/// Scale applied to the pelvis translation components perpendicular to the character up
/// direction. Null (default) = automatic: target hip height / source hip height, both
/// measured on the normalized rests.
/// </summary>
public float? HipScaleHorizontal { get; init; }
/// <summary>
/// Scale applied to the pelvis translation component along the character up direction.
/// Null (default) = the same automatic hip-height ratio as <see cref="HipScaleHorizontal"/>.
/// </summary>
public float? HipScaleVertical { get; init; }
/// <summary>Whether finger roles are transferred; when false, target finger bones keep
/// their rest locals.</summary>
public bool TransferFingers { get; init; } = true;
/// <summary>Output clip name; null = the source clip's name.</summary>
public string? ClipName { get; init; }
/// <summary>Index of the source clip to retarget (<c>SourceScene.Clips</c>).</summary>
public int ClipIndex { get; init; }
}
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Maths;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
namespace HumanoidRetargeter.Cleanup;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>Tunables for the grounded-foot stance recalibration pass.</summary>
public sealed class FootGroundAlignOptions
{
/// <summary>
/// Dead zone (degrees): measured stance offsets at or below this are genuine planted
/// articulation (heel-roll bias, natural lean — measured 2–4° on well-rested rigs and
/// on citizen clips) and are left untouched, keeping the transfer byte-faithful there.
/// Only offsets beyond it are clearly rest-pose artifacts (measured 12–25° on the
/// repro rig) and get recalibrated.
/// </summary>
public float MinCorrectionDeg { get; set; } = 8f;
/// <summary>
/// Maximum mean sole deviation (degrees) a plant may show and still count as a STANCE
/// for the offset measurement. Plants beyond this are not standing on the sole (crawls,
/// kneels, prone contact — measured 60–90° there) and are excluded; genuine rest-pose
/// stance artifacts measure well below it (largest seen: 27°).
/// </summary>
public float MaxStanceDeviationDeg { get; set; } = 35f;
}
/// <summary>Per-foot results of a <see cref="FootGroundAlign.Apply"/> run.</summary>
public sealed class FootGroundAlignFootReport
{
/// <summary>Plants that contributed to the stance measurement.</summary>
public int StancePlants { get; set; }
/// <summary>Plants excluded as non-stance (mean sole deviation beyond
/// <see cref="FootGroundAlignOptions.MaxStanceDeviationDeg"/>).</summary>
public int SkippedPlants { get; set; }
/// <summary>Measured planted sole offset from the ground plane, degrees (0 when no
/// stance plants exist).</summary>
public float MeasuredOffsetDeg { get; set; }
/// <summary>Foot correction applied to every frame, degrees (0 = inside the dead zone,
/// nothing changed).</summary>
public float AppliedFootDeg { get; set; }
/// <summary>Toe correction applied to every frame, degrees.</summary>
public float AppliedToeDeg { get; set; }
}
/// <summary>Results of a <see cref="FootGroundAlign.Apply"/> run.</summary>
public sealed class FootGroundAlignReport
{
/// <summary>Left-foot results.</summary>
public required FootGroundAlignFootReport Left { get; init; }
/// <summary>Right-foot results.</summary>
public required FootGroundAlignFootReport Right { get; init; }
}
/// <summary>
/// Grounded-foot stance recalibration: measures how far the foot's SOLE sits from the ground
/// plane while planted, and — when that offset is clearly a rest-pose artifact — rotates it
/// out with one constant per foot, applied to every frame of the clip.
/// </summary>
/// <remarks>
/// <para><b>Why a cleanup pass.</b> The solver transfers feet as rest-relative deltas
/// (<see cref="Solve.RoleTransferMode.CharacterDeltaFromRest"/>), so the target keeps its own
/// ankle anatomy — correct whenever the source's rest pose is a flat-footed stance (the delta
/// is then "deviation from standing"). Some rigs ship a NON-stance rest (measured: an
/// Auto-Rig-Pro export whose rest foot sits 12–25° from its planted stance), and that constant
/// offset rides into every frame of the replay — planted feet hover toe-down/heel-up. What a
/// stance actually looks like is animation evidence (planted phases), which a per-frame
/// solver cannot see, so the recalibration lives here.</para>
/// <para><b>Measurement.</b> Per foot: over every planted frame, the sole normal = rest up
/// carried by the foot's world delta from the target bind rest (whose feet stand on the
/// ground by construction); plants whose own mean normal sits beyond
/// <see cref="FootGroundAlignOptions.MaxStanceDeviationDeg"/> are excluded (crawl/kneel/prone
/// contact is not a stance). The pooled mean normal's deviation from up is the stance
/// offset.</para>
/// <para><b>Correction.</b> Offsets inside <see cref="FootGroundAlignOptions.MinCorrectionDeg"/>
/// are genuine articulation — nothing is changed (well-rested rigs and same-rig round trips
/// stay byte-identical through this pass). Beyond it, the shortest-arc rotation taking the
/// pooled normal back to up (pitch+roll only — yaw/toe-out is pose and follows the source)
/// premultiplies the foot's world rotation on EVERY frame: a rest artifact is constant, so
/// the fix is too — within-plant heel-roll, swing styling and frame-to-frame continuity are
/// preserved exactly, and no blending is needed. The toe then receives its own residual
/// constant measured on top of the corrected foot (it neither double-rotates with the foot
/// fix nor inherits the source toe's own rest artifact). Corrections rotate bones about
/// their own joints: ankle positions are untouched, so the pass composes freely with the
/// <see cref="FootPlant"/> position pinning (which preserves foot world rotations).</para>
/// <para><b>Plant intervals come from the caller</b> (the pipeline detects them on the
/// SOURCE clip via <see cref="FootPlant.DetectPlantIntervals"/> — ground truth, immune to
/// the hip-height rescaling that can push target-side trajectories outside the cm-tuned
/// Kovar thresholds). So does the decision to run at all: the pipeline invokes this pass
/// only when the source's normalized rest is implausible as a flat stance (toe at/above
/// ankle level or asymmetric feet — see <c>Retargeter.GroundAlignFeet</c>); on plausible
/// stance rests the solver's rest-relative transfer is already faithful and planted-sole
/// deviations are genuine articulation (boxing stances, heel rolls) that must not be
/// flattened.</para>
/// </remarks>
public static class FootGroundAlign
{
/// <summary>Measures planted stance offsets and recalibrates feet whose offset is a
/// rest-pose artifact; returns what was measured and done.</summary>
/// <param name="frames">Per-frame local transforms (skeleton bone order); modified in place.</param>
/// <param name="skeleton">Bone hierarchy the frames are expressed against; its bind rest
/// is the flat-stance reference.</param>
/// <param name="left">Left leg chain bone indices.</param>
/// <param name="right">Right leg chain bone indices.</param>
/// <param name="up">World up direction of the clip's space.</param>
/// <param name="leftPlants">Left-foot plant intervals (frame indices into
/// <paramref name="frames"/>; out-of-range parts are clamped/ignored).</param>
/// <param name="rightPlants">Right-foot plant intervals.</param>
/// <param name="options">Tunables; defaults used when null.</param>
public static FootGroundAlignReport Apply(
List<XForm[]> frames,
SkeletonModel skeleton,
FootChain left,
FootChain right,
Vector3 up,
IReadOnlyList<FrameRange> leftPlants,
IReadOnlyList<FrameRange> rightPlants,
FootGroundAlignOptions? options = null)
{
ArgumentNullException.ThrowIfNull(frames);
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(right);
ArgumentNullException.ThrowIfNull(leftPlants);
ArgumentNullException.ThrowIfNull(rightPlants);
options ??= new FootGroundAlignOptions();
var report = new FootGroundAlignReport
{
Left = new FootGroundAlignFootReport(),
Right = new FootGroundAlignFootReport(),
};
if (frames.Count == 0 || up.LengthSquared() < 1e-12f)
return report;
up = Vector3.Normalize(up);
RecalibrateFoot(frames, skeleton, left, up, leftPlants, options, report.Left);
RecalibrateFoot(frames, skeleton, right, up, rightPlants, options, report.Right);
return report;
}
private static void RecalibrateFoot(
List<XForm[]> frames, SkeletonModel skeleton, FootChain chain, Vector3 up,
IReadOnlyList<FrameRange> plants, FootGroundAlignOptions options,
FootGroundAlignFootReport report)
{
int n = frames.Count;
var foot = chain.Ankle;
var restFootRotInv = Quaternion.Conjugate(skeleton.RestWorld[foot].Rot);
var maxStanceCos = MathF.Cos(options.MaxStanceDeviationDeg * MathF.PI / 180f);
// ---- measurement: pooled planted sole normal over the stance plants ----
var pooled = Vector3.Zero;
foreach (var plant in plants)
{
int start = Math.Max(plant.Start, 0);
int end = Math.Min(plant.End, n - 1);
if (start > end)
continue;
var plantSum = Vector3.Zero;
for (int f = start; f <= end; f++)
{
var footRot = FkUtil.BoneWorld(frames[f], skeleton, foot).Rot;
plantSum += Vector3.Transform(up, MathQ.Normalize(footRot * restFootRotInv));
}
if (plantSum.LengthSquared() < 1e-8f
|| Vector3.Dot(Vector3.Normalize(plantSum), up) < maxStanceCos)
{
report.SkippedPlants++; // not standing on the sole — crawl/kneel/toe contact
continue;
}
report.StancePlants++;
pooled += plantSum; // frame-count-weighted: longer stances dominate
}
if (pooled.LengthSquared() < 1e-8f)
return;
pooled = Vector3.Normalize(pooled);
var offsetDeg = MathQ.AngleBetween(pooled, up) * (180f / MathF.PI);
report.MeasuredOffsetDeg = offsetDeg;
if (offsetDeg <= options.MinCorrectionDeg)
return; // genuine planted articulation — leave the transfer byte-faithful
// ---- correction: one constant per foot, every frame ----
var footFix = MathQ.FromTo(pooled, up);
report.AppliedFootDeg = offsetDeg;
// Toe residual measured on top of the corrected foot, same dead zone.
var toeFix = Quaternion.Identity;
if (chain.Toe is { } toe && skeleton[toe].ParentIndex == foot)
{
var restToeRotInv = Quaternion.Conjugate(skeleton.RestWorld[toe].Rot);
var toePooled = Vector3.Zero;
foreach (var plant in plants)
{
int start = Math.Max(plant.Start, 0);
int end = Math.Min(plant.End, n - 1);
for (int f = start; f <= end && f >= 0; f++)
{
var toeRot = FkUtil.BoneWorld(frames[f], skeleton, toe).Rot;
toePooled += Vector3.Transform(
up, MathQ.Normalize(footFix * toeRot * restToeRotInv));
}
}
if (toePooled.LengthSquared() > 1e-8f)
{
toePooled = Vector3.Normalize(toePooled);
var toeDeg = MathQ.AngleBetween(toePooled, up) * (180f / MathF.PI);
if (toeDeg > options.MinCorrectionDeg && Vector3.Dot(toePooled, up) >= maxStanceCos)
{
toeFix = MathQ.FromTo(toePooled, up);
report.AppliedToeDeg = toeDeg;
}
}
}
for (int f = 0; f < n; f++)
CorrectFrame(frames[f], skeleton, chain, footFix, toeFix);
}
/// <summary>Premultiplies the foot's world rotation by the constant fix (the joint
/// position is untouched — the rotation pivots the foot about its own head), then gives
/// the toe its own residual on top of the corrected foot.</summary>
private static void CorrectFrame(
XForm[] locals, SkeletonModel skeleton, FootChain chain,
Quaternion footFix, Quaternion toeFix)
{
var foot = chain.Ankle;
var parent = skeleton[foot].ParentIndex;
var parentRot = parent < 0
? Quaternion.Identity
: FkUtil.BoneWorld(locals, skeleton, parent).Rot;
var footWorld = MathQ.Normalize(parentRot * locals[foot].Rot);
var newFootWorld = MathQ.Normalize(footFix * footWorld);
locals[foot] = new XForm(
locals[foot].Pos, MathQ.Normalize(Quaternion.Conjugate(parentRot) * newFootWorld));
if (chain.Toe is { } toe && skeleton[toe].ParentIndex == foot)
{
// Desired toe world = toeFix ∘ footFix ∘ original world; re-derive its local
// against the corrected foot so it does not double-rotate with the foot fix.
var toeWorldOld = MathQ.Normalize(footWorld * locals[toe].Rot);
var desired = MathQ.Normalize(toeFix * footFix * toeWorldOld);
locals[toe] = new XForm(
locals[toe].Pos, MathQ.Normalize(Quaternion.Conjugate(newFootWorld) * desired));
}
}
}
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Skeleton;
namespace HumanoidRetargeter.Formats.Bvh;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>Options for <see cref="BvhImporter.Import"/>.</summary>
public sealed class BvhImportOptions
{
/// <summary>Fixed resampling rate for the motion data, frames per second.</summary>
public float SampleFps { get; init; } = 30f;
}
/// <summary>
/// BVH (Biovision Hierarchy) → <see cref="SourceScene"/> importer.
/// </summary>
/// <remarks>
/// <para><b>Format conventions implemented</b> (verified against Blender's
/// <c>io_anim_bvh</c> importer, which is the project's ground-truth extractor):</para>
/// <list type="bullet">
/// <item><b>Rest pose:</b> each joint's rest local translation is its <c>OFFSET</c>; rest
/// rotation is identity (BVH stores no rest orientation).</item>
/// <item><b>Rotation channels:</b> the channel list order IS the rotation order. The listed
/// rotations apply left-to-right as intrinsic rotations, which in this library's
/// column-vector convention (<c>a * b</c> applies <c>b</c> first) is the product
/// <c>R = R_chan1 * R_chan2 * R_chan3</c> — e.g. <c>Zrotation Yrotation Xrotation</c> gives
/// <c>R = Rz * Ry * Rx</c>. This matches Blender, which builds
/// <c>Euler((x,y,z), reversed(channelOrder))</c> for the same matrix. Angles are degrees.</item>
/// <item><b>Position channels:</b> when a joint has any position channel, the channel values
/// REPLACE the joint's local translation (missing components are 0) — they are not added to
/// the <c>OFFSET</c>. This is Blender's behavior; in practice roots have OFFSET 0 so the two
/// readings only diverge on non-root position channels (e.g. Bandai-Namco exports).</item>
/// <item><b>End Sites:</b> synthesized as a channel-less leaf bone named
/// <c>"<parent>_end"</c> so chain tips keep their direction information (Blender instead
/// folds them into the parent bone's tail).</item>
/// </list>
/// <para><b>Units</b>: BVH files carry no unit declaration. Heuristic: compute the rest
/// skeleton height (max−min world Y over all joints); if it is < 10 the file is assumed
/// to be in meters and all translations (offsets AND position channels, root included) are
/// scaled ×100 to centimeters, otherwise it is assumed to already be centimeters (×1).
/// Millimeter-scale files (height > 400) are not special-cased — they are rare and
/// ambiguous against cm mocap of long ranges; <see cref="SourceScene.UnitScaleCm"/> records
/// whichever factor was applied for diagnostics.</para>
/// <para><b>Resampling</b>: motion frames are resampled from the file's <c>Frame Time</c>
/// grid onto <see cref="BvhImportOptions.SampleFps"/>. Each native frame's euler channels are
/// converted to a quaternion FIRST and bracketing frames are then slerped (positions lerped).
/// Interpolating raw euler angles across frames would mostly work at mocap densities
/// (30–120 fps, small per-frame deltas) but breaks down when an angle wraps ±180° between
/// frames; per-frame quaternion + slerp has no such failure mode, so that is what we do.</para>
/// <para><b>Axes</b>: BVH is conventionally Y-up / Z-forward / X-right. Native axes are
/// preserved (no conversion), matching the FBX importer's policy; the conventional axes are
/// recorded on the <see cref="SourceScene"/> (up = Y, front = Z, coord = X).</para>
/// </remarks>
public static class BvhImporter
{
private const float MeterHeightThreshold = 10f;
/// <summary>Parses BVH bytes and builds the source scene.</summary>
/// <exception cref="FormatException">Malformed or truncated BVH.</exception>
public static SourceScene Import(byte[] data, BvhImportOptions? options = null)
{
ArgumentNullException.ThrowIfNull(data);
options ??= new BvhImportOptions();
if (!(options.SampleFps > 0f) || !float.IsFinite(options.SampleFps))
throw new ArgumentOutOfRangeException(nameof(options), "SampleFps must be positive.");
var cursor = new TokenCursor(Encoding.UTF8.GetString(data));
// ---- HIERARCHY -----------------------------------------------------------------
cursor.ExpectKeyword("HIERARCHY");
var joints = new List<Joint>();
int channelCount = 0;
if (!cursor.PeekIs("ROOT"))
throw new FormatException("BVH: expected ROOT after HIERARCHY.");
while (cursor.PeekIs("ROOT")) // multiple roots are out of spec but harmless to accept
{
cursor.Next();
ParseJoint(cursor, joints, parent: -1, ref channelCount);
}
// ---- MOTION ---------------------------------------------------------------------
cursor.ExpectKeyword("MOTION");
cursor.ExpectKeyword("FRAMES:");
int frameCount = cursor.NextInt();
if (frameCount < 0)
throw new FormatException($"BVH: negative frame count {frameCount}.");
cursor.ExpectKeyword("FRAME");
cursor.ExpectKeyword("TIME:");
float frameTime = cursor.NextFloat();
if (!(frameTime > 0f) || !float.IsFinite(frameTime))
throw new FormatException($"BVH: invalid Frame Time {frameTime}.");
var motion = new float[frameCount][];
for (int f = 0; f < frameCount; f++)
{
var row = new float[channelCount];
for (int c = 0; c < channelCount; c++)
row[c] = cursor.NextFloat();
motion[f] = row;
}
// ---- units heuristic --------------------------------------------------------------
float unitScale = HeuristicUnitScale(joints);
// ---- skeleton ----------------------------------------------------------------------
var defs = new List<BoneDefinition>(joints.Count);
foreach (var j in joints)
{
defs.Add(new BoneDefinition(
j.Name,
j.Parent < 0 ? null : joints[j.Parent].Name,
new XForm(j.Offset * unitScale, Quaternion.Identity)));
}
var skeleton = Skeleton.Skeleton.Create(defs);
// ---- clip ----------------------------------------------------------------------------
var clips = new List<Clip>();
if (frameCount > 0)
clips.Add(ResampleClip(joints, skeleton, motion, frameTime, unitScale, options.SampleFps));
// BVH conventional axes: Y-up (1), Z-front (2), X-coord (0) — recorded, not converted.
return new SourceScene(
skeleton, clips, unitScale,
upAxis: 1, upAxisSign: 1,
frontAxis: 2, frontAxisSign: 1,
coordAxis: 0, coordAxisSign: 1,
originalUpAxis: -1);
}
// =====================================================================================
// hierarchy parsing
// =====================================================================================
private sealed class Joint
{
public required string Name;
public required int Parent; // index into the joint list, -1 for roots
public Vector3 Offset; // raw file units
public int PosX = -1, PosY = -1, PosZ = -1; // motion column per position axis
public List<(int Axis, int Column)> Rot = new(); // rotation channels in file order
public bool HasPos => PosX >= 0 || PosY >= 0 || PosZ >= 0;
}
private static void ParseJoint(TokenCursor cursor, List<Joint> joints, int parent, ref int channelCount)
{
// Joint name: tokens up to '{', joined with '_' (mirrors Blender's handling of
// names containing spaces).
var nameParts = new List<string>();
while (!cursor.PeekIs("{"))
{
if (cursor.AtEnd)
throw new FormatException("BVH: unexpected end of file in joint name.");
nameParts.Add(cursor.Next());
}
if (nameParts.Count == 0)
throw new FormatException("BVH: joint with no name.");
string name = UniqueName(string.Join('_', nameParts), joints);
cursor.ExpectKeyword("{");
cursor.ExpectKeyword("OFFSET");
var joint = new Joint { Name = name, Parent = parent };
joint.Offset = new Vector3(cursor.NextFloat(), cursor.NextFloat(), cursor.NextFloat());
int index = joints.Count;
joints.Add(joint);
if (cursor.PeekIs("CHANNELS"))
{
cursor.Next();
int n = cursor.NextInt();
if (n < 0 || n > 6)
throw new FormatException($"BVH: joint '{name}' has invalid channel count {n}.");
for (int i = 0; i < n; i++)
{
string channel = cursor.Next();
int column = channelCount++;
switch (channel.ToUpperInvariant())
{
case "XPOSITION": joint.PosX = column; break;
case "YPOSITION": joint.PosY = column; break;
case "ZPOSITION": joint.PosZ = column; break;
case "XROTATION": joint.Rot.Add((0, column)); break;
case "YROTATION": joint.Rot.Add((1, column)); break;
case "ZROTATION": joint.Rot.Add((2, column)); break;
default:
throw new FormatException($"BVH: unknown channel '{channel}' on joint '{name}'.");
}
}
}
while (!cursor.PeekIs("}"))
{
if (cursor.AtEnd)
throw new FormatException($"BVH: unexpected end of file inside joint '{name}'.");
if (cursor.PeekIs("JOINT"))
{
cursor.Next();
ParseJoint(cursor, joints, index, ref channelCount);
}
else if (cursor.PeekIs("END"))
{
cursor.Next();
cursor.ExpectKeyword("SITE");
while (!cursor.PeekIs("{")) // a name after "End Site" is out of spec; skip it
{
if (cursor.AtEnd)
throw new FormatException("BVH: unexpected end of file in End Site.");
cursor.Next();
}
cursor.ExpectKeyword("{");
cursor.ExpectKeyword("OFFSET");
var endOffset = new Vector3(cursor.NextFloat(), cursor.NextFloat(), cursor.NextFloat());
cursor.ExpectKeyword("}");
// Synthesize a channel-less leaf so the chain tip's direction is kept.
joints.Add(new Joint
{
Name = UniqueName(name + "_end", joints),
Parent = index,
Offset = endOffset,
});
}
else
{
throw new FormatException(
$"BVH: unexpected token '{cursor.Next()}' inside joint '{name}'.");
}
}
cursor.ExpectKeyword("}");
}
private static string UniqueName(string name, List<Joint> joints)
{
bool Taken(string candidate)
{
foreach (var j in joints)
if (string.Equals(j.Name, candidate, StringComparison.Ordinal))
return true;
return false;
}
if (!Taken(name))
return name;
for (int i = 1; ; i++)
{
string candidate = $"{name}#{i}";
if (!Taken(candidate))
return candidate;
}
}
// =====================================================================================
// units
// =====================================================================================
/// <summary>
/// Meters-vs-centimeters heuristic: rest skeleton height (max−min world Y over all
/// joints, end sites included) < 10 → meters → ×100; otherwise centimeters → ×1.
/// </summary>
private static float HeuristicUnitScale(List<Joint> joints)
{
Span<float> worldY = joints.Count <= 256 ? stackalloc float[joints.Count] : new float[joints.Count];
float min = float.MaxValue, max = float.MinValue;
for (int i = 0; i < joints.Count; i++)
{
worldY[i] = (joints[i].Parent < 0 ? 0f : worldY[joints[i].Parent]) + joints[i].Offset.Y;
min = MathF.Min(min, worldY[i]);
max = MathF.Max(max, worldY[i]);
}
float height = max - min;
return height > 0f && height < MeterHeightThreshold ? 100f : 1f;
}
// =====================================================================================
// motion sampling
// =====================================================================================
/// <summary>
/// Decodes every native frame to per-joint local transforms (quaternions built per frame
/// from the joint's channel order), then resamples onto the <paramref name="fps"/> grid —
/// positions lerped, rotations slerped between the bracketing native frames.
/// </summary>
private static Clip ResampleClip(
List<Joint> joints, Skeleton.Skeleton skeleton, float[][] motion,
float frameTime, float unitScale, float fps)
{
int jointCount = joints.Count;
int nativeCount = motion.Length;
// Joint order may differ from skeleton bone order (topological sort) — map.
var toSkeleton = new int[jointCount];
for (int i = 0; i < jointCount; i++)
toSkeleton[i] = skeleton.IndexOf(joints[i].Name);
// Native-frame locals.
var native = new XForm[nativeCount][];
for (int f = 0; f < nativeCount; f++)
{
var row = motion[f];
var locals = new XForm[jointCount];
for (int i = 0; i < jointCount; i++)
locals[i] = EvaluateLocal(joints[i], row, unitScale);
native[f] = locals;
}
double duration = (nativeCount - 1) * (double)frameTime;
int outCount = Math.Max(1, (int)Math.Round(duration * fps) + 1);
var frames = new List<XForm[]>(outCount);
for (int f = 0; f < outCount; f++)
{
double s = f / (double)fps / frameTime; // position on the native frame grid
int i0 = Math.Clamp((int)Math.Floor(s), 0, nativeCount - 1);
int i1 = Math.Min(i0 + 1, nativeCount - 1);
float u = Math.Clamp((float)(s - i0), 0f, 1f);
var frame = new XForm[skeleton.Count];
var a = native[i0];
var b = native[i1];
for (int i = 0; i < jointCount; i++)
{
frame[toSkeleton[i]] = new XForm(
Vector3.Lerp(a[i].Pos, b[i].Pos, u),
MathQ.Normalize(Quaternion.Slerp(a[i].Rot, b[i].Rot, u)));
}
frames.Add(frame);
}
// NativeFps records the file's authored frame rate (1 / FrameTime): external frame
// ranges (Unity .meta clipAnimations) are expressed in it.
float nativeFps = frameTime > 0f ? (float)(1.0 / frameTime) : fps;
return new Clip("motion", fps, looping: false, frames, nativeFps);
}
/// <summary>One joint's local transform from one motion row (see class remarks).</summary>
private static XForm EvaluateLocal(Joint joint, float[] row, float unitScale)
{
// Position channels replace the OFFSET; absent channels (or no position channels at
// all) fall back per Blender's semantics described in the class remarks.
Vector3 pos = joint.HasPos
? new Vector3(
joint.PosX >= 0 ? row[joint.PosX] : 0f,
joint.PosY >= 0 ? row[joint.PosY] : 0f,
joint.PosZ >= 0 ? row[joint.PosZ] : 0f)
: joint.Offset;
// R = R_chan1 * R_chan2 * R_chan3 (column-vector convention; degrees in the file).
var rot = Quaternion.Identity;
foreach (var (axis, column) in joint.Rot)
{
float radians = row[column] * (MathF.PI / 180f);
var axisVector = axis switch
{
0 => Vector3.UnitX,
1 => Vector3.UnitY,
_ => Vector3.UnitZ,
};
rot *= Quaternion.CreateFromAxisAngle(axisVector, radians);
}
return new XForm(pos * unitScale, MathQ.Normalize(rot));
}
// =====================================================================================
// tokenizer
// =====================================================================================
/// <summary>Whitespace token stream over the BVH text (BVH is line-format agnostic).</summary>
private sealed class TokenCursor
{
private readonly string[] _tokens;
private int _pos;
public TokenCursor(string text)
=> _tokens = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
public bool AtEnd => _pos >= _tokens.Length;
public bool PeekIs(string keywordUpper)
=> _pos < _tokens.Length &&
string.Equals(_tokens[_pos], keywordUpper, StringComparison.OrdinalIgnoreCase);
public string Next()
{
if (AtEnd)
throw new FormatException("BVH: unexpected end of file.");
return _tokens[_pos++];
}
public void ExpectKeyword(string keywordUpper)
{
string token = Next();
if (!string.Equals(token, keywordUpper, StringComparison.OrdinalIgnoreCase))
throw new FormatException($"BVH: expected '{keywordUpper}', found '{token}'.");
}
public int NextInt()
{
string token = Next();
if (!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
throw new FormatException($"BVH: expected an integer, found '{token}'.");
return value;
}
public float NextFloat()
{
string token = Next();
if (!float.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out float value) ||
!float.IsFinite(value))
throw new FormatException($"BVH: expected a number, found '{token}'.");
return value;
}
}
}
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using HumanoidRetargeter.Skeleton;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
namespace HumanoidRetargeter.Formats.Dmx;
/// <summary>Options for <see cref="DmxWriter.Write"/>.</summary>
public sealed class DmxWriteOptions
{
/// <summary>Model/clip name written into the DmeModel element (e.g. the sequence name).</summary>
public string Name { get; set; } = "";
/// <summary>Free-form provenance note written as the DmeDCCMakefile source name
/// (fbx2dmx writes the source .fbx path here).</summary>
public string SourceNote { get; set; } = "";
/// <summary>When true (default, matching fbx2dmx output) the file declares a Y-up axis
/// system; when false it declares Z-up. Data is written as-is either way.</summary>
public bool UpAxisY { get; set; } = true;
/// <summary>
/// Skeleton bone indices that get NO DmeChannel pair: the bones keep their DmeJoint and
/// bind (rest) transform, but no animation channels are written for them — the engine then
/// drives them itself (e.g. ConstraintDriven twist/helper bones, design §3). Null (default)
/// writes channels for every bone.
/// </summary>
public IReadOnlySet<int>? ChannelExcludedBones { get; set; }
}
/// <summary>
/// Writes an animation DMX in <c>keyvalues2_noids</c> text encoding, replicating the exact
/// element/attribute shape of fbx2dmx output (authoritative reference:
/// <c>dev/m0/ref_idlepose.dmx</c>): a root DmElement holding an inline DmeModel (joint GUID
/// refs + bind base state), a top-level DmeAnimationList with one DmeChannelsClip carrying a
/// position and an orientation channel per bone, and top-level DmeTransform/DmeJoint elements
/// the channels and joint lists reference by GUID. Output is fully deterministic: GUIDs are
/// MD5-derived from the options name and an element path, and export tags use fixed
/// placeholder strings.
/// </summary>
public static class DmxWriter
{
private const string Header = "<!-- dmx encoding keyvalues2_noids 4 format model 22 -->";
/// <summary>
/// Serializes <paramref name="clip"/> on <paramref name="skeleton"/> to DMX text.
/// Frames must contain one local transform per bone in skeleton order.
/// </summary>
/// <exception cref="ArgumentException">Thrown when the clip is empty or a frame's bone
/// count does not match the skeleton.</exception>
public static string Write(SkeletonModel skeleton, Clip clip, DmxWriteOptions options)
{
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(clip);
ArgumentNullException.ThrowIfNull(options);
if (clip.FrameCount == 0)
throw new ArgumentException("Clip has no frames.", nameof(clip));
for (var f = 0; f < clip.FrameCount; f++)
{
if (clip.Frames[f].Length != skeleton.Count)
throw new ArgumentException(
$"Frame {f} has {clip.Frames[f].Length} bone transforms, skeleton has {skeleton.Count}.",
nameof(clip));
}
var w = new Emitter();
var animListGuid = GuidString(options.Name, "animationList");
var jointGuids = new string[skeleton.Count];
var transformGuids = new string[skeleton.Count];
for (var i = 0; i < skeleton.Count; i++)
{
jointGuids[i] = GuidString(options.Name, "joint:" + skeleton[i].Name);
transformGuids[i] = GuidString(options.Name, "transform:" + skeleton[i].Name);
}
w.Raw(Header);
// ---- root DmElement -------------------------------------------------
w.BeginTopLevel("DmElement");
w.Attr("name", "string", "root");
w.BeginInlineAttr("skeleton", "DmeModel");
w.Attr("name", "string", options.Name);
w.BeginInlineAttr("transform", "DmeTransform");
w.Attr("position", "vector3", "0 0 0");
w.Attr("orientation", "quaternion", "0 0 0 1");
w.EndInlineAttr();
w.Attr("shape", "element", "");
w.Attr("visible", "bool", "1");
w.BeginArray("children");
var roots = new List<int>();
for (var i = 0; i < skeleton.Count; i++)
{
if (skeleton[i].ParentIndex < 0)
roots.Add(i);
}
for (var r = 0; r < roots.Count; r++)
w.ElementRef(jointGuids[roots[r]], last: r == roots.Count - 1);
w.EndArray();
w.BeginArray("jointList");
for (var i = 0; i < skeleton.Count; i++)
w.ElementRef(jointGuids[i], last: i == skeleton.Count - 1);
w.EndArray();
w.BeginArray("baseStates");
w.BeginArrayElement("DmeTransformList");
w.Attr("name", "string", "bind");
w.BeginArray("transforms");
for (var i = 0; i < skeleton.Count; i++)
{
w.BeginArrayElement("DmeTransform");
w.Attr("name", "string", skeleton[i].Name);
w.Attr("position", "vector3", Vec(skeleton[i].RestLocal));
w.Attr("orientation", "quaternion", Quat(skeleton[i].RestLocal));
w.EndArrayElement(last: i == skeleton.Count - 1);
}
w.EndArray();
w.EndArrayElement(last: true);
w.EndArray();
w.Attr("upAxis", "string", options.UpAxisY ? "Y" : "Z");
w.BeginInlineAttr("axisSystem", "DmeAxisSystem");
w.Attr("upAxis", "int", options.UpAxisY ? "2" : "3");
w.Attr("forwardParity", "int", "2");
w.Attr("coordSys", "int", "0");
w.EndInlineAttr();
w.Attr("animationList", "element", animListGuid);
w.EndInlineAttr(); // skeleton DmeModel
w.BeginInlineAttr("makefile", "DmeDCCMakefile");
w.Attr("name", "string", "makefile");
w.BeginArray("sources");
w.BeginArrayElement("DmeSource");
w.Attr("name", "string", options.SourceNote);
w.EndArrayElement(last: true);
w.EndArray();
w.EndInlineAttr();
// Deterministic placeholders — never wall-clock/user data, so output is reproducible.
w.BeginInlineAttr("exportTags", "DmeExportTags");
w.Attr("name", "string", "exportTags");
w.Attr("date", "string", "2026/01/01");
w.Attr("time", "string", "12:00:00 am");
w.Attr("user", "string", "retargeter");
w.Attr("machine", "string", "retargeter");
w.Attr("app", "string", "humanoid-retargeter");
w.Attr("appVersion", "string", "1.0");
w.Attr("cmdLine", "string", "humanoid-retargeter");
w.Attr("pwd", "string", "");
w.EndInlineAttr();
w.Attr("animationList", "element", animListGuid);
w.EndTopLevel();
// ---- DmeAnimationList ----------------------------------------------
w.BeginTopLevel("DmeAnimationList");
w.Attr("id", "elementid", animListGuid);
w.Attr("name", "string", "anim");
w.BeginArray("animations");
w.BeginArrayElement("DmeChannelsClip");
w.Attr("name", "string", "anim");
w.BeginInlineAttr("timeFrame", "DmeTimeFrame");
w.Attr("start", "time", Time(0.0));
w.Attr("duration", "time", Time((clip.FrameCount - 1) / (double)clip.Fps));
w.Attr("offset", "time", Time(0.0));
w.Attr("scale", "float", "1");
w.EndInlineAttr();
w.Attr("color", "color", "0 0 0 0");
w.Attr("text", "string", "");
w.Attr("mute", "bool", "0");
w.BeginArray("trackGroups");
w.EndArray();
w.Attr("displayScale", "float", "1");
var channelBones = new List<int>(skeleton.Count);
for (var i = 0; i < skeleton.Count; i++)
{
if (options.ChannelExcludedBones is null || !options.ChannelExcludedBones.Contains(i))
channelBones.Add(i);
}
w.BeginArray("channels");
for (var n = 0; n < channelBones.Count; n++)
{
var i = channelBones[n];
WriteChannel(w, skeleton, clip, i, transformGuids[i], position: true, last: false);
WriteChannel(w, skeleton, clip, i, transformGuids[i], position: false,
last: n == channelBones.Count - 1);
}
w.EndArray();
w.Attr("frameRate", "int",
((int)MathF.Round(clip.Fps)).ToString(CultureInfo.InvariantCulture));
w.EndArrayElement(last: true);
w.EndArray();
w.EndTopLevel();
// ---- top-level channel-target DmeTransforms (rest values) -----------
for (var i = 0; i < skeleton.Count; i++)
{
w.BeginTopLevel("DmeTransform");
w.Attr("id", "elementid", transformGuids[i]);
w.Attr("name", "string", skeleton[i].Name);
w.Attr("position", "vector3", Vec(skeleton[i].RestLocal));
w.Attr("orientation", "quaternion", Quat(skeleton[i].RestLocal));
w.EndTopLevel();
}
// ---- top-level DmeJoints --------------------------------------------
for (var i = 0; i < skeleton.Count; i++)
{
w.BeginTopLevel("DmeJoint");
w.Attr("id", "elementid", jointGuids[i]);
w.Attr("name", "string", skeleton[i].Name);
w.Attr("transform", "element", transformGuids[i]);
w.Attr("shape", "element", "");
w.Attr("visible", "bool", "1");
w.BeginArray("children");
var children = new List<int>();
for (var c = 0; c < skeleton.Count; c++)
{
if (skeleton[c].ParentIndex == i)
children.Add(c);
}
for (var c = 0; c < children.Count; c++)
w.ElementRef(jointGuids[children[c]], last: c == children.Count - 1);
w.EndArray();
w.EndTopLevel();
}
return w.ToString();
}
/// <summary>
/// Deterministic element GUID: MD5 over <c>"<name>\n<path>"</c> (UTF-8)
/// interpreted as <see cref="Guid"/> bytes. Exposed so tests can verify the scheme.
/// </summary>
public static Guid ElementGuid(string name, string path)
=> new(MD5.HashData(Encoding.UTF8.GetBytes(name + "\n" + path)));
private static string GuidString(string name, string path)
=> ElementGuid(name, path).ToString("D", CultureInfo.InvariantCulture);
// ---------------------------------------------------------------- channels
private static void WriteChannel(Emitter w, SkeletonModel skeleton, Clip clip, int bone,
string transformGuid, bool position, bool last)
{
var logClass = position ? "DmeVector3Log" : "DmeQuaternionLog";
var layerClass = position ? "DmeVector3LogLayer" : "DmeQuaternionLogLayer";
var logName = position ? "vector3 log" : "quaternion log";
w.BeginArrayElement("DmeChannel");
w.Attr("name", "string", skeleton[bone].Name + (position ? "_p" : "_o"));
w.Attr("fromElement", "element", "");
w.Attr("fromAttribute", "string", "");
w.Attr("fromIndex", "int", "0");
w.Attr("toElement", "element", transformGuid);
w.Attr("toAttribute", "string", position ? "position" : "orientation");
w.Attr("toIndex", "int", "0");
w.Attr("mode", "int", "3");
w.BeginInlineAttr("log", logClass);
w.Attr("name", "string", logName);
w.BeginArray("layers");
w.BeginArrayElement(layerClass);
w.Attr("name", "string", logName);
w.BeginArray("times", "time_array");
for (var f = 0; f < clip.FrameCount; f++)
w.ArrayValue(Time(f / (double)clip.Fps), last: f == clip.FrameCount - 1);
w.EndArray();
w.BeginArray("curvetypes", "int_array");
w.EndArray();
w.BeginArray("values", position ? "vector3_array" : "quaternion_array");
// Orientation values are hemisphere-aligned on the fly (q and -q are the same
// rotation, but the engine interpolates between DMX samples numerically — see
// QuaternionContinuity). The clip itself is never mutated.
var prev = System.Numerics.Quaternion.Identity;
for (var f = 0; f < clip.FrameCount; f++)
{
var x = clip.Frames[f][bone];
string value;
if (position)
{
value = Vec(x);
}
else
{
var q = x.Rot;
if (f > 0 && System.Numerics.Quaternion.Dot(prev, q) < 0f)
q = System.Numerics.Quaternion.Negate(q);
prev = q;
value = Quat(q);
}
w.ArrayValue(value, last: f == clip.FrameCount - 1);
}
w.EndArray();
w.EmptyBinaryAttr("compressed");
w.EndArrayElement(last: true);
w.EndArray(); // layers
w.Attr("curveinfo", "element", "");
w.Attr("usedefaultvalue", "bool", "0");
w.Attr("defaultvalue", position ? "vector3" : "quaternion", position ? "0 0 0" : "0 0 0 1");
w.BeginArray("bookmarksX", "time_array");
w.EndArray();
w.BeginArray("bookmarksY", "time_array");
w.EndArray();
w.BeginArray("bookmarksZ", "time_array");
w.EndArray();
w.EndInlineAttr(); // log
w.EndArrayElement(last);
}
// ---------------------------------------------------------------- formatting
/// <summary>fbx2dmx float style: up to 10 decimal places, trailing zeros stripped,
/// invariant culture, negative zero normalized.</summary>
private static string F(float value)
{
if (value == 0f)
return "0";
return ((double)value).ToString("0.##########", CultureInfo.InvariantCulture);
}
private static string Time(double seconds)
=> seconds.ToString("0.0000", CultureInfo.InvariantCulture);
private static string Vec(in Maths.XForm x)
=> $"{F(x.Pos.X)} {F(x.Pos.Y)} {F(x.Pos.Z)}";
private static string Quat(in Maths.XForm x) => Quat(x.Rot);
private static string Quat(in System.Numerics.Quaternion q)
=> $"{F(q.X)} {F(q.Y)} {F(q.Z)} {F(q.W)}";
// ---------------------------------------------------------------- emitter
/// <summary>
/// Low-level keyvalues2 text emitter reproducing fbx2dmx layout quirks: CRLF endings,
/// tab indentation, a trailing space after array-typed attribute names, and an
/// indentation-only line after every inline element attribute closes.
/// </summary>
private sealed class Emitter
{
private readonly StringBuilder _sb = new();
private int _indent;
public void Raw(string text)
{
_sb.Append(text).Append("\r\n");
}
private void Line(string text)
{
_sb.Append('\t', _indent).Append(text).Append("\r\n");
}
public void Attr(string name, string type, string value)
=> Line($"\"{name}\" \"{type}\" \"{value}\"");
public void BeginTopLevel(string className)
{
Line($"\"{className}\"");
Line("{");
_indent++;
}
public void EndTopLevel()
{
_indent--;
Line("}");
_sb.Append("\r\n"); // blank separator after every top-level element (incl. the last)
}
public void BeginInlineAttr(string name, string className)
{
Line($"\"{name}\" \"{className}\"");
Line("{");
_indent++;
}
public void EndInlineAttr()
{
_indent--;
Line("}");
Line(""); // indentation-only line, as fbx2dmx emits
}
public void BeginArrayElement(string className)
{
Line($"\"{className}\"");
Line("{");
_indent++;
}
public void EndArrayElement(bool last)
{
_indent--;
Line(last ? "}" : "},");
}
public void BeginArray(string name, string type = "element_array")
{
Line($"\"{name}\" \"{type}\" ");
Line("[");
_indent++;
}
public void EndArray()
{
_indent--;
Line("]");
}
public void ElementRef(string guid, bool last)
=> Line($"\"element\" \"{guid}\"" + (last ? "" : ","));
public void ArrayValue(string value, bool last)
=> Line($"\"{value}\"" + (last ? "" : ","));
public void EmptyBinaryAttr(string name)
{
Line($"\"{name}\" \"binary\" ");
Line("\"");
Line("\"");
}
public override string ToString() => _sb.ToString();
}
}
using System;
using System.Collections.Generic;
using HumanoidRetargeter.Cleanup;
using HumanoidRetargeter.Formats;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Solve;
using HumanoidRetargeter.Target;
namespace HumanoidRetargeter;
/// <summary>Which solver retargets a request's clips (design §10).</summary>
public enum SolverKind
{
/// <summary>The deterministic <see cref="Solve.GeometricSolver"/> (default; better
/// wherever a role mapping exists).</summary>
Geometric,
/// <summary>The experimental skeleton-agnostic deep-learning solver
/// (<see cref="Dl.DlSolver"/>, SAME pretrained checkpoint) — the no-profile fallback.
/// Requires <see cref="RetargetTargetSpec.DlWeights"/>; ignores per-role mapping
/// (only hips/alignment heuristics consult it) and leaves fingers at rest.</summary>
DeepLearning,
}
/// <summary>
/// One source animation file to retarget (engine-agnostic: bytes in, no file IO). Every
/// request runs its OWN profile detection, so a single batch may mix Mixamo + ActorCore +
/// BVH sources — unless <see cref="MappingOverride"/> supplies a mapping explicitly.
/// </summary>
public sealed class RetargetRequest
{
/// <summary>Solver choice for this request's clips. <see cref="SolverKind.DeepLearning"/>
/// requires the batch's <see cref="RetargetTargetSpec.DlWeights"/> to be set; the
/// conversion fails per-clip with a clear error otherwise.</summary>
public SolverKind Solver { get; init; } = SolverKind.Geometric;
/// <summary>Raw bytes of the source file (.fbx, .bvh, .glb, .gltf or .vrm).</summary>
public required byte[] SourceData { get; init; }
/// <summary>
/// Source file name (used for the report and DMX provenance). The extension drives the
/// format choice (<c>.fbx</c> / <c>.bvh</c> / <c>.glb</c> / <c>.gltf</c> / <c>.vrm</c> —
/// a VRM is a glTF container whose authored humanoid bone map becomes the mapping);
/// when the extension is unknown the content is sniffed (FBX binary magic /
/// "FBXHeaderExtension" / BVH "HIERARCHY" / GLB 'glTF' magic / glTF JSON).
/// </summary>
public required string SourceFileName { get; init; }
/// <summary>
/// Caller-supplied identity of this request, echoed verbatim on every produced
/// <see cref="ClipResult.SourceId"/> so callers can join results back to their own
/// entries unambiguously (e.g. the editor window passes the FULL file path here, since
/// two files in different folders may share the same <see cref="SourceFileName"/>).
/// Null = <see cref="SourceFileName"/>.
/// </summary>
public string? SourceId { get; init; }
/// <summary>
/// Import sample rate the source clips are resampled to (BVH native frames / FBX curves
/// are evaluated on this grid). Null = the importer default (30 fps).
/// </summary>
public float? SampleFps { get; init; }
/// <summary>
/// Restricts the conversion to ONE take of the source file (0-based index into the
/// imported scene's clips). Null = convert all takes. Out of range fails the request's
/// clip result with a clear error (the batch continues). UI listings that expand a
/// multi-take file into one entry per take submit one request per selected take.
/// When <see cref="ClipDefinitions"/> is set this index addresses the DEFINITIONS
/// instead (each definition is what a UI row represents then).
/// </summary>
public int? TakeIndex { get; init; }
/// <summary>
/// Optional external clip definitions, parsed from a Unity <c><file>.fbx.meta</c>
/// sidecar (<see cref="UnityMeta.ParseClipAnimations"/>): Unity animation packs ship FBX
/// files whose clips are sub-ranges of ONE source timeline. When set (non-empty), the
/// conversion produces one output clip per definition instead of one per take: the
/// definition's take (matched by <see cref="ExternalClipDef.TakeName"/>, falling back to
/// the file's first take) is sliced to the definition's native-frame range
/// (<see cref="UnityMeta.Slice"/>), named <see cref="ExternalClipDef.Name"/> (sanitized
/// like take names, collision-suffixed across the batch) and looped per
/// <see cref="ExternalClipDef.Loop"/> unless <see cref="LoopingOverride"/> is set.
/// <see cref="TakeIndex"/> then indexes INTO this list. Null = no definitions.
/// </summary>
public IReadOnlyList<ExternalClipDef>? ClipDefinitions { get; init; }
/// <summary>
/// UI-supplied mapping (manual mapping table or a user preset loaded Editor-side).
/// Null = auto-detect per request: preset profiles via <see cref="ProfileDetector"/>,
/// then the <see cref="AutoMapper"/> as best-effort fallback.
/// </summary>
public MappingResult? MappingOverride { get; init; }
/// <summary>Solver tunables (hip scales, finger transfer). ClipIndex/ClipName are managed
/// by the pipeline per take and ignored here. Null = defaults.</summary>
public SolveOptions? Solve { get; init; }
/// <summary>
/// Root-motion handling. <see cref="RootMotionMode.Extract"/> on a target without a
/// dedicated animated root bone (the s&box rig: pelvis is parentless, root_IK is
/// IkBaked) leaves the frames untouched and instead sets the ExtractMotion flag on the
/// clip's vmdl AnimFile entry — Source 2's compile-time extraction replaces the missing
/// bone-level extraction. <see cref="RootMotionMode.InPlace"/> always operates on the
/// hips directly.
/// </summary>
public RootMotionMode RootMotion { get; init; } = RootMotionMode.Off;
/// <summary>Run the Kovar foot-plant cleanup pass on the solved frames (default on).</summary>
public bool FootPlantCleanup { get; init; } = true;
/// <summary>
/// Optional arm end-effector IK pass pulling the wrists onto limb-length-normalized
/// source hand positions. Default OFF: the geometric solver already matches anatomical
/// directions, so arm IK only helps reach-critical work (props, contact poses) and can
/// otherwise disturb elbow styling.
/// </summary>
public bool ArmEffectorIk { get; init; }
/// <summary>
/// Generate <c>AE_FOOTSTEP</c> AnimEvent nodes on each produced clip's vmdl AnimFile
/// entry (default OFF). After solving and cleanup, foot-plant intervals are detected on
/// the SOLVED target clip (<see cref="Cleanup.FootPlant.DetectPlantIntervals"/>); each
/// plant's start frame is a touchdown and becomes one footstep event, in the exact node
/// shape the shipped citizen data uses (see <see cref="Target.FootstepEvents"/>).
/// Skipped (with a report note) when the target rig lacks complete leg chains.
/// </summary>
public bool GenerateFootstepEvents { get; init; }
/// <summary>
/// Additionally produce a mirrored twin of every converted clip (default OFF), named
/// <c><clip>_M</c> (collision-suffixed across the batch as usual). Mirroring runs
/// in TARGET space on the solved clip (<see cref="Solve.ClipMirror"/>): left/right role
/// bone channels swap and everything is reflected across the target character's sagittal
/// plane; IK-baked helper bones are re-baked from the mirrored body afterwards.
/// </summary>
public bool CreateMirroredVariant { get; init; }
/// <summary>
/// Additionally register an additive (delta) twin of every converted clip in the
/// generated/augmented vmdl (default OFF), named <c><clip>_delta</c> (the shipped
/// citizen naming; collision-suffixed across the batch as usual). The twin is a second
/// AnimFile entry REUSING the clip's DMX with an <c>AnimSubtract</c> child
/// (<c>anim_name</c> = the base sequence, <c>frame</c> = 0) — exactly the shipped
/// <c>IdleLayer_01</c>/<c>IdleLayer_01_delta</c> pattern, where resourcecompiler
/// subtracts the reference frame at compile time (no frame math happens here). The
/// resulting <c>_delta</c> sequence is what s&box layered animation additively
/// blends on top of a base pose.
/// </summary>
public bool CreateAdditiveVariant { get; init; }
/// <summary>Output clip name override; with multiple takes an index suffix is appended.
/// Null = the source take name.</summary>
public string? ClipNameOverride { get; init; }
/// <summary>Force the looping flag on the output sequence(s); null = the source clip's flag.</summary>
public bool? LoopingOverride { get; init; }
}
/// <summary>
/// Axis/unit convention of a <see cref="RetargetTargetSpec"/>'s rig data — drives the DMX
/// axis-system declaration, foot-plant threshold units, and the editor preview's
/// rig-space → engine-space conversion.
/// </summary>
public enum TargetUpAxis
{
/// <summary>
/// The s&box source convention: rig authored in centimeters, Y-up (the shipped
/// citizen rig, FBX targets). The vmdl's ScaleAndMirror 0.3937 + resourcecompiler's
/// Y-up→Z-up conversion take it to engine space at compile time. Default.
/// </summary>
YUpCm,
/// <summary>
/// Engine space already: rig read from a compiled model's <c>Model.Bones</c>
/// (inches, Z-up). The DMX declares a Z-up axis system so the compiler performs no
/// further axis conversion.
/// </summary>
ZUpEngine,
}
/// <summary>
/// The conversion target shared by all requests of one <see cref="Retargeter.Convert"/> /
/// <see cref="Retargeter.ConvertBatch"/> call: the rig plus the vmdl generation parameters.
/// </summary>
public sealed class RetargetTargetSpec
{
/// <summary>The s&box-source → engine-units vmdl scale (cm rigs like the citizen).</summary>
public const float SboxSourceScale = 0.3937f;
/// <summary>The committed asset path of the s&box human male model.</summary>
public const string SboxHumanMalePath = "models/citizen_human/citizen_human_male.vmdl";
/// <summary>The committed asset path of the classic (4-finger) s&box citizen model.</summary>
public const string SboxCitizenPath = "models/citizen/citizen.vmdl";
/// <summary>Target rig (skeleton + bone classes + roles).</summary>
public required TargetRig Rig { get; init; }
/// <summary>ModelModifier_ScaleAndMirror scale written into standalone vmdls:
/// <c>0.3937</c> for cm-authored s&box-source rigs, <c>1.0</c> for engine-unit rigs
/// (the modifier node is omitted at 1.0).</summary>
public required float VmdlScale { get; init; }
/// <summary>base_model_name of generated standalone vmdls (the model that owns the mesh).</summary>
public string BaseModelPath { get; init; } = "";
/// <summary>default_root_bone_name of the generated AnimationList (also the bone vmdl
/// ExtractMotion nodes operate on).</summary>
public string DefaultRootBone { get; init; } = "pelvis";
/// <summary>
/// Axis/unit convention of <see cref="Rig"/>. <see cref="TargetUpAxis.YUpCm"/> (default)
/// for cm Y-up source-space rigs (DMX declares Y-up, compiler converts);
/// <see cref="TargetUpAxis.ZUpEngine"/> for rigs read from compiled engine models
/// (DMX declares Z-up so no double conversion happens at compile, and cm-tuned cleanup
/// thresholds are rescaled to inches).
/// </summary>
public TargetUpAxis UpAxis { get; init; } = TargetUpAxis.YUpCm;
/// <summary>
/// Raw bytes of the committed SAME weight blob
/// (<c>Assets/humanoid_retargeter/dl/same_v1.weights</c>; callers do the file IO).
/// Required only when a request selects <see cref="SolverKind.DeepLearning"/>; the
/// solver instance is built once per batch from these bytes.
/// </summary>
public byte[]? DlWeights { get; init; }
/// <summary>
/// The shipped s&box default target: rig parsed from the committed
/// <c>Assets/humanoid_retargeter/target_rig_sbox.json</c> text (callers do the file IO),
/// 0.3937 vmdl scale, citizen human male base model, pelvis root. Pass the committed
/// SAME weight bytes as <paramref name="dlWeights"/> to enable the deep-learning solver.
/// </summary>
public static RetargetTargetSpec SboxDefault(string targetRigJson, byte[]? dlWeights = null) => new()
{
Rig = TargetRig.SboxDefault(targetRigJson),
VmdlScale = SboxSourceScale,
BaseModelPath = SboxHumanMalePath,
DefaultRootBone = "pelvis",
DlWeights = dlWeights,
};
/// <summary>
/// The classic (4-finger) s&box citizen target: rig parsed from the committed
/// <c>Assets/humanoid_retargeter/target_rig_sbox_citizen.json</c> text (callers do the
/// file IO), 0.3937 vmdl scale, citizen base model, pelvis root, Y-up cm. The rig has no
/// pinky bones, so pinky roles stay unassigned — the engine's own constraints handle the
/// pinky at runtime for models that have one. Pass the committed SAME weight bytes as
/// <paramref name="dlWeights"/> to enable the deep-learning solver.
/// </summary>
public static RetargetTargetSpec SboxCitizen(string targetRigJson, byte[]? dlWeights = null) => new()
{
Rig = TargetRig.Load(targetRigJson),
VmdlScale = SboxSourceScale,
BaseModelPath = SboxCitizenPath,
DefaultRootBone = "pelvis",
UpAxis = TargetUpAxis.YUpCm,
DlWeights = dlWeights,
};
}
/// <summary>Options for <see cref="Retargeter.ConvertBatch"/> output assembly.</summary>
public sealed class BatchOptions
{
/// <summary>
/// When set, the batch additionally augments this existing vmdl text (all successful
/// clips spliced into its AnimationList via <see cref="VmdlAugmenter"/>) and returns the
/// result in <see cref="RetargetBatchResult.AugmentedVmdl"/>.
/// </summary>
public string? AugmentVmdlText { get; init; }
/// <summary>Assets-relative folder the DMX files will be written to by the caller; used
/// to build each AnimFile's <c>source_filename</c>.</summary>
public string DmxFolderRelative { get; init; } = "animations/retargeted";
/// <summary>Auto-suffix colliding clip names (<c>_2</c>, <c>_3</c>, …) across the whole
/// batch (default on). When off, duplicate names are kept as-is.</summary>
public bool AutoSuffixCollisions { get; init; } = true;
/// <summary>
/// After conversion, scan the batch's successful clip names for directional locomotion
/// families (default OFF): <c>_N</c>/<c>_NE</c>/…/<c>_NW</c> compass suffixes and
/// <c>_Forward</c>/<c>_Backward</c>(/<c>_Back</c>)/<c>_Left</c>/<c>_Right</c> word forms
/// sharing a stem. Each complete family (all four cardinals) is grouped under a Folder
/// node with a <c>2DBlend</c> wired to the citizen <c>move_x</c>/<c>move_y</c> pose
/// parameters, replicating the shipped citizen locomotion layout (see
/// <see cref="Target.LocomotionSetDetector"/>); detection results land on
/// <see cref="RetargetBatchResult.LocomotionSets"/>. Custom (non-citizen) base models
/// must declare <c>move_x</c>/<c>move_y</c> pose parameters themselves for the blends to
/// be drivable.
/// </summary>
public bool DetectLocomotionSets { get; init; }
}
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
namespace HumanoidRetargeter.Solve;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>
/// Hand rest-geometry helpers shared by <see cref="CanonicalFrames"/> (finger secondary axes)
/// and <see cref="RestNormalizer"/> (palm-down roll correction). Everything derives from joint
/// positions only — bone local axes carry no anatomical meaning on the s&box rig.
/// </summary>
internal static class HandGeometry
{
private static readonly BoneRole[] LeftProximals =
{
BoneRole.ThumbProxL, BoneRole.IndexProxL, BoneRole.MiddleProxL, BoneRole.RingProxL, BoneRole.PinkyProxL,
};
private static readonly BoneRole[] RightProximals =
{
BoneRole.ThumbProxR, BoneRole.IndexProxR, BoneRole.MiddleProxR, BoneRole.RingProxR, BoneRole.PinkyProxR,
};
// Index → pinky order; the knuckle line is taken from the first and last mapped of these.
private static readonly BoneRole[] LeftNonThumbProximals =
{
BoneRole.IndexProxL, BoneRole.MiddleProxL, BoneRole.RingProxL, BoneRole.PinkyProxL,
};
private static readonly BoneRole[] RightNonThumbProximals =
{
BoneRole.IndexProxR, BoneRole.MiddleProxR, BoneRole.RingProxR, BoneRole.PinkyProxR,
};
/// <summary>
/// Midpoint of all mapped finger proximal heads of one hand (the hand's anatomical
/// "chain child" point), or null when no finger proximal is mapped.
/// </summary>
public static Vector3? FingerProximalMidpoint(MappingResult map, IReadOnlyList<XForm> worldRest, bool left)
{
var sum = Vector3.Zero;
var count = 0;
foreach (var role in left ? LeftProximals : RightProximals)
{
if (map.RoleToBone.TryGetValue(role, out var index))
{
sum += worldRest[index].Pos;
count++;
}
}
return count > 0 ? sum / count : null;
}
/// <summary>
/// Dorsal palm normal of one hand: the unit vector pointing out of the <b>back</b> of the
/// hand (away from the palm), or null when the hand/finger geometry is unmapped or
/// degenerate.
/// </summary>
/// <remarks>
/// Formula (mirror-consistent by construction, verified on the ActorCore fixture by the
/// finger-curl test): <c>dorsal = sideSign · cross(knuckle, fingerDir)</c> with
/// <c>sideSign = +1</c> left / <c>−1</c> right, <c>knuckle = IndexProx.head −
/// PinkyProx.head</c> (first/last mapped non-thumb proximal), and <c>fingerDir =
/// FingerProximalMidpoint − Hand.head</c>. On every fixture rig the thumb proximal lies on
/// the −dorsal (palmar) side of the hand plane, grounding the sign anatomically. A positive
/// rotation about a finger frame's hinge axis (frame Y = cross(dorsal, fingerChainDir))
/// curls the fingertip toward the palm on <b>both</b> hands.
/// </remarks>
public static Vector3? Dorsal(MappingResult map, IReadOnlyList<XForm> worldRest, bool left)
{
if (!map.RoleToBone.TryGetValue(left ? BoneRole.HandL : BoneRole.HandR, out var handIndex))
return null;
var hand = worldRest[handIndex].Pos;
var nonThumb = left ? LeftNonThumbProximals : RightNonThumbProximals;
Vector3? first = null, last = null;
foreach (var role in nonThumb)
{
if (!map.RoleToBone.TryGetValue(role, out var index))
continue;
first ??= worldRest[index].Pos;
last = worldRest[index].Pos;
}
if (first is null || last is null || (first.Value - last.Value).LengthSquared() < 1e-8f)
return null;
var midpoint = FingerProximalMidpoint(map, worldRest, left);
if (midpoint is null)
return null;
var knuckle = first.Value - last.Value;
var fingerDir = midpoint.Value - hand;
var raw = Vector3.Cross(knuckle, fingerDir) * (left ? 1f : -1f);
return raw.LengthSquared() < 1e-8f ? null : Vector3.Normalize(raw);
}
}
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "goo" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "goo" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "xaz" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "xaz.goo" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "6/14/2026 7:34:04 PM" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "25" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.116.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.116.0")]using System;
using Sandbox;
namespace Goo.Animation;
public record struct SmoothFloat
{
public float Current;
public float Target;
public float Velocity;
public float SmoothTime;
public SmoothFloat(float initial, float smoothTime)
{
Current = initial;
Target = initial;
Velocity = 0f;
SmoothTime = smoothTime;
}
public void Update(float dt) =>
Current = MathX.SmoothDamp(Current, Target, ref Velocity, SmoothTime, dt);
public bool IsSettled =>
MathF.Abs(Target - Current) < 0.0001f && MathF.Abs(Velocity) < 0.0001f;
/// <summary>Advances by dt and returns true while still moving; chain calls with | (not ||) so every damper advances each frame.</summary>
public bool Tick(float dt) { Update(dt); return !IsSettled; }
}
using System;
using Sandbox;
namespace Goo.Animation;
public record struct SpringColor
{
public Color Current;
public Color Target;
public Color Velocity;
public float Frequency;
public float Damping;
public SpringColor(Color initial, float frequency, float damping)
{
Current = initial;
Target = initial;
Velocity = default;
Frequency = frequency;
Damping = damping;
}
public void Update(float dt)
{
float vr = Velocity.r, vg = Velocity.g, vb = Velocity.b, va = Velocity.a;
Current = new Color(
MathX.SpringDamp(Current.r, Target.r, ref vr, dt, Frequency, Damping),
MathX.SpringDamp(Current.g, Target.g, ref vg, dt, Frequency, Damping),
MathX.SpringDamp(Current.b, Target.b, ref vb, dt, Frequency, Damping),
MathX.SpringDamp(Current.a, Target.a, ref va, dt, Frequency, Damping));
Velocity = new Color(vr, vg, vb, va);
}
public bool IsSettled =>
MathF.Abs(Target.r - Current.r) < 0.0001f &&
MathF.Abs(Target.g - Current.g) < 0.0001f &&
MathF.Abs(Target.b - Current.b) < 0.0001f &&
MathF.Abs(Target.a - Current.a) < 0.0001f &&
MathF.Abs(Velocity.r) < 0.0001f &&
MathF.Abs(Velocity.g) < 0.0001f &&
MathF.Abs(Velocity.b) < 0.0001f &&
MathF.Abs(Velocity.a) < 0.0001f;
/// <summary>Advances by dt and returns true while still moving; chain calls with | (not ||) so every damper advances each frame.</summary>
public bool Tick(float dt) { Update(dt); return !IsSettled; }
}
using System;
using Sandbox;
namespace Goo.Animation;
public record struct SpringColor
{
public Color Current;
public Color Target;
public Color Velocity;
public float Frequency;
public float Damping;
public SpringColor(Color initial, float frequency, float damping)
{
Current = initial;
Target = initial;
Velocity = default;
Frequency = frequency;
Damping = damping;
}
public void Update(float dt)
{
float vr = Velocity.r, vg = Velocity.g, vb = Velocity.b, va = Velocity.a;
Current = new Color(
MathX.SpringDamp(Current.r, Target.r, ref vr, dt, Frequency, Damping),
MathX.SpringDamp(Current.g, Target.g, ref vg, dt, Frequency, Damping),
MathX.SpringDamp(Current.b, Target.b, ref vb, dt, Frequency, Damping),
MathX.SpringDamp(Current.a, Target.a, ref va, dt, Frequency, Damping));
Velocity = new Color(vr, vg, vb, va);
}
public bool IsSettled =>
MathF.Abs(Target.r - Current.r) < 0.0001f &&
MathF.Abs(Target.g - Current.g) < 0.0001f &&
MathF.Abs(Target.b - Current.b) < 0.0001f &&
MathF.Abs(Target.a - Current.a) < 0.0001f &&
MathF.Abs(Velocity.r) < 0.0001f &&
MathF.Abs(Velocity.g) < 0.0001f &&
MathF.Abs(Velocity.b) < 0.0001f &&
MathF.Abs(Velocity.a) < 0.0001f;
/// <summary>Advances by dt and returns true while still moving; chain calls with | (not ||) so every damper advances each frame.</summary>
public bool Tick(float dt) { Update(dt); return !IsSettled; }
}
namespace Goo.Animation;
public record struct TimelineAnimator
{
public Timeline Timeline;
public float Elapsed;
public bool Paused;
public float Speed;
public TimelineAnimator(Timeline timeline)
{
Timeline = timeline;
Elapsed = 0f;
Paused = false;
Speed = 1f;
}
public void Update(float dt) { if (!Paused) Elapsed += dt * Speed; }
public void Pause() { Paused = true; }
public void Resume() { Paused = false; }
public void Restart() { Elapsed = 0f; Paused = false; }
public void Seek(float t) { Elapsed = t; }
public readonly TimelineSample Sample => Timeline.Eval(Elapsed);
public readonly bool IsFinished
{
get
{
if (Timeline.Iterations <= 0) return false;
if (Timeline.Duration <= 0f) return true;
return Elapsed >= Timeline.Duration * Timeline.Iterations;
}
}
}
namespace Goo;
/// <summary>Compile-time constraint for blob struct types. Never use as storage, return, or parameter type (boxes the struct, destroys per-Rebuild allocation profile); only valid in where T : struct, IBlob.</summary>
public interface IBlob
{
static abstract BlobKind Kind { get; }
string? Key { get; }
internal void WriteTo(ref Frame frame);
}
/// <summary>Returns the single root Blob for a GooView build. A named delegate rather than
/// Func<IBlob> because IBlob has a static-abstract member (Kind) and C# bars such interfaces
/// as generic type arguments (CS8920). Consequence for Razor markup: a bare method group cannot
/// bind (its natural type is the illegal Func<IBlob>), so write
/// <c>Build=@(new BlobBuilder(MyBuild))</c>. See docs/site/docs/gotchas.md.</summary>
public delegate IBlob BlobBuilder();
using Sandbox.UI;
namespace Goo;
/// <summary>Length helpers for typography properties whose engine unit semantics are non-obvious.</summary>
public static class Typography
{
/// <summary>CSS-style unitless LineHeight multiplier (e.g. 1.5 = 1.5x of FontSize).</summary>
public static Length LineHeightMultiplier(float multiplier)
=> Length.Percent(multiplier * 100f).Value;
/// <summary>CSS-style em-relative LetterSpacing (e.g. -0.02 for tight headings).</summary>
public static Length LetterSpacingEm(float em)
=> Length.Em(em);
}
using System;
using System.Collections.Generic;
using Sandbox.UI;
namespace Goo.Input;
// Lazily caches one stable Action<MousePanelEvent> per key so successive Builds
// compare equal (Delegate.Equals) and emit no SetEvents op. 0 alloc on cache hit.
public sealed class HandlerTable<TKey> where TKey : notnull
{
readonly Action<TKey, MousePanelEvent> _action;
readonly Dictionary<TKey, Action<MousePanelEvent>> _cache = new();
public HandlerTable(Action<TKey> action) : this((k, _) => action(k)) { }
public HandlerTable(Action<TKey, MousePanelEvent> action) => _action = action;
public Action<MousePanelEvent> this[TKey key]
{
get
{
if (!_cache.TryGetValue(key, out var h))
{
var k = key;
h = e => _action(k, e);
_cache[key] = h;
}
return h;
}
}
}
using System;
using Sandbox;
using Sandbox.UI;
namespace Goo;
// Composed control factories (compose-list widgets). Stateless: each returns a
// Container subtree, following the Shapes/Skins idiom.
public static partial class Controls
{
/// <summary>Goo primitive button: unstyled click target wrapping a text label. Pass a null onClick for a display-only button (no handler is wired). Distinct from Components.Button in the app project, which applies brand tokens and visual chrome. Use this to build custom-styled buttons without inheriting app-level styling. Style fields the factory sets (PointerEvents, FlexDirection, AlignItems, JustifyContent, and conditionally HoverBackgroundColor) cannot be overridden via <c>with</c> - first-declared wins; see Hud.Fill for the same constraint.</summary>
public static Container Button(
string label,
Action? onClick = null,
Color? hoverColor = null,
string? key = null )
{
return new Container
{
Key = key,
PointerEvents = PointerEvents.All,
FlexDirection = FlexDirection.Row,
AlignItems = Align.Center,
JustifyContent = Justify.Center,
HoverBackgroundColor = hoverColor,
OnClick = onClick is null ? null : _ => onClick(),
Children = { new Text( label ) },
};
}
// Maps a cursor X (in the root's rendered pixel frame) to a snapped, clamped value.
// Ratio-based (localX / width) so it is scale-invariant; engine UI scaling changes
// the absolute pixels but not the ratio (engine-fact-mousepanelevent-rendered-frame).
internal static float ValueAt(float localX, float width, float min, float max, float step)
{
if (width <= 0f || max <= min) return min;
float norm = Math.Clamp(localX / width, 0f, 1f);
float v = min + norm * (max - min);
if (step > 0f) v = MathF.Round(v / step) * step;
return Math.Clamp(v, min, max);
}
// Opacity multiplier applied to a Container when Disabled = true.
public const float DisabledOpacity = 0.45f;
// Canonical disabled dimming; multiplies any declared opacity.
internal static float ResolveDisabledOpacity(float? declaredOpacity)
=> (declaredOpacity ?? 1f) * DisabledOpacity;
// Dev-diagnostic sink for the degenerate max<=min case (mirrors Skins.OnZeroBorder).
public static Action<string>? OnDegenerateRange;
static readonly Color TrackBg = new( 0.28f, 0.28f, 0.34f, 1f );
static readonly Color FillBg = new( 0.55f, 0.78f, 0.95f, 1f );
static readonly Color ThumbBg = new( 0.95f, 0.96f, 1.00f, 1f );
// Controlled, stateless horizontal slider: the caller owns value, updates it in onChanged, and re-renders. Press-to-jump + drag within the bar (no engine move-capture; engine-fact-sbox-ui-input-and-drag); key pins reconciler identity so the Active pointer survives per-move re-renders.
// disabled: when true, sets Disabled on the root container (forces pointer-off and opacity dim); onChanged is not called. Callers that wrap this in their own disabled container must pass disabled=true here and omit Disabled on the wrapper to avoid double-dim (0.45 x 0.45).
public static Container Slider(
float value, float min, float max, float step,
Action<float> onChanged, string? key = null, bool disabled = false )
{
if ( max <= min )
OnDegenerateRange?.Invoke( $"Goo.Controls.Slider: max ({max}) <= min ({min}); rendering an inert track." );
float pct = (max > min ? Math.Clamp( (value - min) / (max - min), 0f, 1f ) : 0f) * 100f;
void Set( MousePanelEvent e )
=> onChanged( ValueAt( e.LocalPosition.x, e.Target.Box.Rect.Size.x, min, max, step ) );
return new Container
{
Key = key,
Disabled = disabled ? (bool?)true : null,
PointerEvents = PointerEvents.All,
Width = Length.Percent( 100 ),
Height = 20f,
FlexDirection = FlexDirection.Column,
JustifyContent = Justify.Center,
OnMouseDown = Set, // press jumps to position
OnMouseMove = e => { if ( e.Target.HasActive ) Set( e ); }, // drag while pressed
Children =
{
// track/fill/thumb are inert: handler-less, variant-less panels resolve to
// PointerEvents.None, so the slider parent stays e.Target for press/drag.
new Container
{
Key = "track",
Position = PositionMode.Relative, // positioned ancestor for fill/thumb
Width = Length.Percent( 100 ),
Height = 7f,
BorderRadius = 4f,
BackgroundColor = TrackBg,
Children =
{
new Container
{
Key = "fill",
Position = PositionMode.Absolute,
Left = 0f,
Height = Length.Percent( 100 ),
Width = Length.Percent( pct ),
BorderRadius = 4f,
BackgroundColor = FillBg,
},
new Container
{
Key = "thumb",
Position = PositionMode.Absolute,
Left = Length.Percent( pct ),
Top = Length.Percent( 50 ),
Width = 16f,
Height = 16f,
BorderRadius = Length.Percent( 50 ),
BackgroundColor = ThumbBg,
// Center the thumb on the (x = value, y = track-mid) point.
// Length.Percent returns Length?, never null here; ?? default unwraps
// it the same way Px.Of does (the codebase's nullable-Length idiom).
Transform = PanelTransform.Translate( Length.Percent( -50 ) ?? default, Length.Percent( -50 ) ?? default ),
},
},
},
},
};
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using Sandbox;
namespace Goo;
internal sealed class Fiber
{
public BlobKind Kind;
public string? Key;
public string Content = "";
public StyleList Style = StyleList.Empty;
public List<Fiber>? Children;
public Texture? Texture;
public string? Path; // Image (texture path), ScenePanel (.scene path), SvgPanel (svg path), or WebPanel (URL)
public Scene? Scene;
public bool RenderOnce;
public bool Paused; // WebPanel only
public string? Color;
// TextEntry-only carry-fields. Path slot is reused for the text value.
public string? Placeholder;
public int? MaxLength;
public bool Disabled;
public bool Numeric;
public float? MinValue;
public float? MaxValue;
public string? NumberFormat;
public bool Multiline;
public int? MinLength;
public string? CharacterRegex;
public string? StringRegex;
public Func<char, bool>? CanEnterChar;
public Func<string, bool>? Validate;
public Action<bool>? OnValidationChanged;
public Action<string>? OnChange;
public Action<string>? OnSubmit;
public Action? OnFocus;
public Action<string>? OnBlur;
public Action? OnCancel;
public bool IsControlled;
public BlobEvents PrevEvents;
public ShapeParams Shape; // Sector / Arc only
public Vector2[]? Points; // Polygon only
// Custom-shader channel (Container only). Previous-render snapshot for delta comparison.
public ShaderEffect? Effect;
// Custom-draw callback (Container only). Previous-render snapshot for delta comparison.
public DrawCallback? Draw;
// Declared layout-move transition (Container only). Previous-render snapshot for delta comparison.
public LayoutTransition? LayoutTransition;
public object? Instance; // Cell only: the persistent self-owning state instance
}
// <auto-generated />
// Generated by tools/StyleFacadeEmit. Do not edit by hand.
// Source of truth: tools/StyleFacadeEmit/style-manifest.json
using Sandbox;
using Sandbox.Rendering;
using Sandbox.UI;
namespace Goo;
public readonly partial record struct Sector
{
public Length? Width { init => _style = StyleAccumulator.Add(_style, StyleField.Width, value); }
public Length? Height { init => _style = StyleAccumulator.Add(_style, StyleField.Height, value); }
public Length? Margin { init => _style = StyleAccumulator.Add(_style, StyleField.Margin, value); }
public Length? MarginLeft { init => _style = StyleAccumulator.Add(_style, StyleField.MarginLeft, value); }
public Length? MarginTop { init => _style = StyleAccumulator.Add(_style, StyleField.MarginTop, value); }
public Length? MarginRight { init => _style = StyleAccumulator.Add(_style, StyleField.MarginRight, value); }
public Length? MarginBottom { init => _style = StyleAccumulator.Add(_style, StyleField.MarginBottom, value); }
public Color? BackgroundColor { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundColor, value, StyleValue.FromColor); }
public Color? BackgroundTint { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundTint, value, StyleValue.FromColor); }
public Length? Bottom { init => _style = StyleAccumulator.Add(_style, StyleField.Bottom, value); }
public Length? Left { init => _style = StyleAccumulator.Add(_style, StyleField.Left, value); }
public Length? Right { init => _style = StyleAccumulator.Add(_style, StyleField.Right, value); }
public Length? Top { init => _style = StyleAccumulator.Add(_style, StyleField.Top, value); }
public Length? FlexBasis { init => _style = StyleAccumulator.Add(_style, StyleField.FlexBasis, value); }
public float? FlexGrow { init => _style = StyleAccumulator.Add(_style, StyleField.FlexGrow, value); }
public float? FlexShrink { init => _style = StyleAccumulator.Add(_style, StyleField.FlexShrink, value); }
public Length? MaxHeight { init => _style = StyleAccumulator.Add(_style, StyleField.MaxHeight, value); }
public Length? MaxWidth { init => _style = StyleAccumulator.Add(_style, StyleField.MaxWidth, value); }
public Length? MinHeight { init => _style = StyleAccumulator.Add(_style, StyleField.MinHeight, value); }
public Length? MinWidth { init => _style = StyleAccumulator.Add(_style, StyleField.MinWidth, value); }
public float? Opacity { init => _style = StyleAccumulator.Add(_style, StyleField.Opacity, value); }
public PointerEvents? PointerEvents { init => _style = StyleAccumulator.Add(_style, StyleField.PointerEvents, value, StyleValue.FromPointerEvents); }
public PositionMode? Position { init => _style = StyleAccumulator.Add(_style, StyleField.Position, value, StyleValue.FromPositionMode); }
public Goo.PanelTransform? Transform { init => _style = StyleAccumulator.Add(_style, StyleField.Transform, value, StyleValue.FromPanelTransform); }
}
using System;
using Sandbox;
using Sandbox.Rendering;
using Sandbox.UI;
namespace Goo;
internal enum StyleField : ushort
{
None = 0,
FlexDirection, JustifyContent, AlignItems, Display,
Width, Height,
Padding, PaddingLeft, PaddingTop, PaddingRight, PaddingBottom,
Margin, MarginLeft, MarginTop, MarginRight, MarginBottom,
Gap, RowGap, ColumnGap,
BackgroundColor,
BorderRadius, BorderTopLeftRadius, BorderTopRightRadius,
BorderBottomRightRadius, BorderBottomLeftRadius,
// Extended to cover remaining settable PanelStyle properties.
AlignContent, AlignSelf,
AspectRatio,
BackdropFilterBlur, BackdropFilterBrightness, BackdropFilterContrast,
BackdropFilterHueRotate, BackdropFilterInvert, BackdropFilterSaturate, BackdropFilterSepia,
BackgroundAngle, BackgroundBlendMode, BackgroundImage, BackgroundPlaybackPaused,
BackgroundPositionX, BackgroundPositionY, BackgroundRepeat,
BackgroundSizeX, BackgroundSizeY, BackgroundTint,
BorderBottomColor, BorderBottomWidth,
BorderColor,
BorderImageFill, BorderImageRepeat, BorderImageSource, BorderImageTint,
BorderImageWidthBottom, BorderImageWidthLeft, BorderImageWidthRight, BorderImageWidthTop,
BorderLeftColor, BorderLeftWidth,
BorderRightColor, BorderRightWidth,
BorderTopColor, BorderTopWidth,
BorderWidth,
Bottom,
CaretColor,
Cursor,
FilterBlur, FilterBorderColor, FilterBorderWidth,
FilterBrightness, FilterContrast, FilterHueRotate, FilterInvert, FilterSaturate,
FilterSepia, FilterTint,
FlexBasis, FlexGrow, FlexShrink, FlexWrap,
FontColor, FontFamily, FontSize,
FontSmooth, FontStyle, FontVariantNumeric, FontWeight,
ImageRendering,
Left, LetterSpacing, LineHeight,
MaskAngle, MaskImage, MaskMode, MaskPositionX, MaskPositionY,
MaskRepeat, MaskScope, MaskSizeX, MaskSizeY,
MaxHeight, MaxWidth, MinHeight, MinWidth,
MixBlendMode,
ObjectFit,
Opacity,
Order,
OutlineColor, OutlineOffset, OutlineWidth,
Overflow, OverflowX, OverflowY,
PerspectiveOriginX, PerspectiveOriginY,
PointerEvents,
Position,
Right,
SoundIn, SoundOut,
TextAlign,
TextBackgroundAngle,
TextDecorationColor, TextDecorationLine, TextDecorationSkipInk,
TextDecorationStyle, TextDecorationThickness,
TextFilter,
TextLineThroughOffset,
TextOverflow, TextOverlineOffset,
TextStrokeColor, TextStrokeWidth,
TextTransform,
TextUnderlineOffset,
Top,
Transform,
TransformOriginX, TransformOriginY,
WhiteSpace,
WordBreak, WordSpacing,
ZIndex,
HoverBackgroundColor, ActiveBackgroundColor, FocusBackgroundColor,
HoverFontColor, ActiveFontColor, FocusFontColor,
TransitionMs,
Disabled,
}
internal enum StyleValueKind : byte
{
None = 0,
Length,
Color,
FlexDirection,
Justify,
Align,
DisplayMode,
String,
Single,
Boolean,
Texture,
Int32,
Wrap,
BackgroundRepeat,
BorderImageFill,
BorderImageRepeat,
FontSmooth,
FontStyle,
FontVariantNumeric,
ImageRendering,
MaskMode,
MaskScope,
ObjectFit,
OverflowMode,
PointerEvents,
PositionMode,
TextAlign,
TextDecoration,
TextDecorationStyle,
TextSkipInk,
FilterMode,
TextOverflow,
TextTransform,
WhiteSpace,
WordBreak,
PanelTransform,
}
internal struct StyleValue : IEquatable<StyleValue>
{
public StyleValueKind Kind;
public Length LengthVal; // valid when Kind == Length
public Color ColorVal; // valid when Kind == Color
public int EnumVal; // packed enum value for any enum-typed kind
public int RefSlot; // packed value for Single (float bits), Boolean (0/1), Int32 (raw)
public object? RefVal; // String or Texture reference
public static StyleValue FromLength(Length v) => new() { Kind = StyleValueKind.Length, LengthVal = v };
public static StyleValue FromColor(Color v) => new() { Kind = StyleValueKind.Color, ColorVal = v };
public static StyleValue FromFlexDirection(FlexDirection v) => new() { Kind = StyleValueKind.FlexDirection, EnumVal = (int)v };
public static StyleValue FromJustify(Justify v) => new() { Kind = StyleValueKind.Justify, EnumVal = (int)v };
public static StyleValue FromAlign(Align v) => new() { Kind = StyleValueKind.Align, EnumVal = (int)v };
public static StyleValue FromDisplay(DisplayMode v) => new() { Kind = StyleValueKind.DisplayMode, EnumVal = (int)v };
public static StyleValue FromString(string v) => new() { Kind = StyleValueKind.String, RefVal = v };
public static StyleValue FromSingle(float v) => new() { Kind = StyleValueKind.Single, RefSlot = BitConverter.SingleToInt32Bits(v) };
public static StyleValue FromBoolean(bool v) => new() { Kind = StyleValueKind.Boolean, RefSlot = v ? 1 : 0 };
public static StyleValue FromInt32(int v) => new() { Kind = StyleValueKind.Int32, RefSlot = v };
public static StyleValue FromTexture(Texture v) => new() { Kind = StyleValueKind.Texture, RefVal = v };
public static StyleValue FromWrap(Wrap v) => new() { Kind = StyleValueKind.Wrap, EnumVal = (int)v };
public static StyleValue FromBackgroundRepeat(BackgroundRepeat v) => new() { Kind = StyleValueKind.BackgroundRepeat, EnumVal = (int)v };
public static StyleValue FromBorderImageFill(BorderImageFill v) => new() { Kind = StyleValueKind.BorderImageFill, EnumVal = (int)v };
public static StyleValue FromBorderImageRepeat(BorderImageRepeat v) => new() { Kind = StyleValueKind.BorderImageRepeat, EnumVal = (int)v };
public static StyleValue FromFontSmooth(FontSmooth v) => new() { Kind = StyleValueKind.FontSmooth, EnumVal = (int)v };
public static StyleValue FromFontStyle(FontStyle v) => new() { Kind = StyleValueKind.FontStyle, EnumVal = (int)v };
public static StyleValue FromFontVariantNumeric(FontVariantNumeric v) => new() { Kind = StyleValueKind.FontVariantNumeric, EnumVal = (int)v };
public static StyleValue FromImageRendering(ImageRendering v) => new() { Kind = StyleValueKind.ImageRendering, EnumVal = (int)v };
public static StyleValue FromMaskMode(MaskMode v) => new() { Kind = StyleValueKind.MaskMode, EnumVal = (int)v };
public static StyleValue FromMaskScope(MaskScope v) => new() { Kind = StyleValueKind.MaskScope, EnumVal = (int)v };
public static StyleValue FromObjectFit(ObjectFit v) => new() { Kind = StyleValueKind.ObjectFit, EnumVal = (int)v };
public static StyleValue FromOverflowMode(OverflowMode v) => new() { Kind = StyleValueKind.OverflowMode, EnumVal = (int)v };
public static StyleValue FromPointerEvents(PointerEvents v) => new() { Kind = StyleValueKind.PointerEvents, EnumVal = (int)v };
public static StyleValue FromPositionMode(PositionMode v) => new() { Kind = StyleValueKind.PositionMode, EnumVal = (int)v };
public static StyleValue FromTextAlign(TextAlign v) => new() { Kind = StyleValueKind.TextAlign, EnumVal = (int)v };
public static StyleValue FromTextDecoration(TextDecoration v) => new() { Kind = StyleValueKind.TextDecoration, EnumVal = (int)v };
public static StyleValue FromTextDecorationStyle(TextDecorationStyle v) => new() { Kind = StyleValueKind.TextDecorationStyle, EnumVal = (int)v };
public static StyleValue FromTextSkipInk(TextSkipInk v) => new() { Kind = StyleValueKind.TextSkipInk, EnumVal = (int)v };
public static StyleValue FromFilterMode(FilterMode v) => new() { Kind = StyleValueKind.FilterMode, EnumVal = (int)v };
public static StyleValue FromTextOverflow(TextOverflow v) => new() { Kind = StyleValueKind.TextOverflow, EnumVal = (int)v };
public static StyleValue FromTextTransform(TextTransform v) => new() { Kind = StyleValueKind.TextTransform, EnumVal = (int)v };
public static StyleValue FromWhiteSpace(WhiteSpace v) => new() { Kind = StyleValueKind.WhiteSpace, EnumVal = (int)v };
public static StyleValue FromWordBreak(WordBreak v) => new() { Kind = StyleValueKind.WordBreak, EnumVal = (int)v };
public static StyleValue FromPanelTransform(Goo.PanelTransform v)
=> new() { Kind = StyleValueKind.PanelTransform, RefVal = v._entries };
public bool Equals(StyleValue other)
{
if (Kind != other.Kind) return false;
return Kind switch
{
StyleValueKind.Length => LengthVal.Equals(other.LengthVal),
StyleValueKind.Color => ColorVal.Equals(other.ColorVal),
StyleValueKind.String => ReferenceEquals(RefVal, other.RefVal),
StyleValueKind.Texture => ReferenceEquals(RefVal, other.RefVal),
StyleValueKind.Single => RefSlot == other.RefSlot,
StyleValueKind.Boolean => RefSlot == other.RefSlot,
StyleValueKind.Int32 => RefSlot == other.RefSlot,
StyleValueKind.PanelTransform => Goo.PanelTransform.EntriesEqual(
RefVal as System.Collections.Immutable.ImmutableList<Sandbox.UI.PanelTransform.Entry>,
other.RefVal as System.Collections.Immutable.ImmutableList<Sandbox.UI.PanelTransform.Entry>),
_ => EnumVal == other.EnumVal,
};
}
public override bool Equals(object? obj) => obj is StyleValue v && Equals(v);
public override int GetHashCode() => Kind switch
{
// PanelTransform stores an ImmutableList in RefVal; Equals is structural via
// EntriesEqual, so the hash must be structural too, hashing RefVal as object
// would give reference identity and break the Equals/GetHashCode contract.
StyleValueKind.PanelTransform => HashCode.Combine(
(byte)Kind,
Goo.PanelTransform.ComputeEntriesHash(
RefVal as System.Collections.Immutable.ImmutableList<Sandbox.UI.PanelTransform.Entry>)),
_ => HashCode.Combine((byte)Kind, LengthVal.GetHashCode(), ColorVal.GetHashCode(), EnumVal, RefSlot, RefVal),
};
}
internal struct StyleEntry
{
public StyleField Field;
public StyleValue Value;
}
internal sealed class StyleList
{
internal static readonly StyleList Empty = new(readOnly: true);
StyleEntry[] _items;
int _count;
readonly bool _readonly;
public StyleList() : this(readOnly: false) { }
StyleList(bool readOnly) { _items = new StyleEntry[4]; _count = 0; _readonly = readOnly; }
public int Count => _count;
public ref StyleEntry this[int i] => ref _items[i];
public void Add(StyleField field, StyleValue value)
{
if (_readonly) throw new InvalidOperationException("Cannot mutate StyleList.Empty sentinel.");
if (_count == _items.Length) Array.Resize(ref _items, _items.Length * 2);
_items[_count++] = new StyleEntry { Field = field, Value = value };
}
public bool TryGet(StyleField field, out StyleValue value)
{
for (int i = 0; i < _count; i++)
if (_items[i].Field == field) { value = _items[i].Value; return true; }
value = default;
return false;
}
internal void Reset()
{
if (_readonly) return;
Array.Clear(_items, 0, _count);
_count = 0;
}
internal void CopyFrom(StyleList src)
{
if (_readonly) throw new InvalidOperationException("Cannot mutate StyleList.Empty sentinel.");
if (_items.Length < src._count) _items = new StyleEntry[src._items.Length];
Array.Copy(src._items, _items, src._count);
_count = src._count;
}
public static bool ContentsEqual(StyleList a, StyleList b)
{
if (ReferenceEquals(a, b)) return true;
if (a._count != b._count) return false;
for (int i = 0; i < a._count; i++)
if (a._items[i].Field != b._items[i].Field || !a._items[i].Value.Equals(b._items[i].Value))
return false;
return true;
}
}
using System;
using System.Collections.Generic;
namespace Goo;
// Lexically-scoped dynamic token lookup. Push a dict via Scope, read via Get inside the body.
public static class Tokens
{
[ThreadStatic] private static Stack<IReadOnlyDictionary<string, object>>? _stack;
public static T Scope<T>(IReadOnlyDictionary<string, object> tokens, Func<T> body)
{
var stack = _stack ??= new Stack<IReadOnlyDictionary<string, object>>();
stack.Push(tokens);
try { return body(); }
finally { stack.Pop(); }
}
public static T Get<T>(string key)
{
if (_stack != null)
{
foreach (var dict in _stack)
{
if (dict.TryGetValue(key, out var value))
return (T)value;
}
}
throw new KeyNotFoundException($"Token '{key}' not found in any active Scope.");
}
public static bool TryGet<T>(string key, out T value)
{
if (_stack != null)
{
foreach (var dict in _stack)
{
if (dict.TryGetValue(key, out var obj) && obj is T typed)
{
value = typed;
return true;
}
}
}
value = default!;
return false;
}
}
using System;
using System.Linq;
using Sandbox.UI;
namespace Goo.Internal;
internal sealed class StatefulTextEntry : Sandbox.UI.TextEntry, IStatefulEventHost
{
internal Action<string>? _onChange;
internal Action<string>? _onSubmit;
internal Action? _onFocus;
internal Action<string>? _onBlur;
internal Action? _onCancel;
internal Func<char, bool>? _canEnterChar;
internal Func<string, bool>? _validate;
internal Action<bool>? _onValidationChanged;
bool _lastInvalid;
internal Action<MousePanelEvent>? _onClick;
internal Action<MousePanelEvent>? _onRightClick;
internal Action<MousePanelEvent>? _onMiddleClick;
internal Action<MousePanelEvent>? _onMouseEnter;
internal Action<MousePanelEvent>? _onMouseLeave;
internal Action<MousePanelEvent>? _onMouseDown;
internal Action<MousePanelEvent>? _onMouseUp;
internal Action<MousePanelEvent>? _onMouseMove;
internal bool _userSetPointerEvents;
internal Action? _requestRebuild;
public Action? RequestRebuild { set => _requestRebuild = value; }
public StatefulTextEntry()
{
// Wire the engine's OnTextEdited through _onChange for per-keystroke notifications.
OnTextEdited = newValue => { _onChange?.Invoke(newValue); if (_onChange != null) _requestRebuild?.Invoke(); };
}
// AND-compose the Goo predicate after the engine's rules (CharacterRegex / Numeric / Multiline).
public override bool CanEnterCharacter(char c)
=> base.CanEnterCharacter(c) && (_canEnterChar?.Invoke(c) ?? true);
// Engine OnPaste has two MaxLength bugs (negative-length crash + under-limit bypass); reimplemented with a correct room clamp.
public override void OnPaste(string text)
{
if (Label.HasSelection())
Label.ReplaceSelection("");
// Filter per-character through CanEnterCharacter (also runs the Goo CanEnterChar predicate).
var pasteResult = new string(text.Where(CanEnterCharacter).ToArray());
if (MaxLength.HasValue)
{
// TextLength here reflects the post-selection-removal length, matching engine ordering.
int room = MaxLength.Value - TextLength;
if (room <= 0)
return;
if (pasteResult.Length > room)
pasteResult = pasteResult.Substring(0, room);
}
Text ??= "";
Label.InsertText(pasteResult, CaretPosition);
Label.MoveCaretPos(pasteResult.Length);
OnValueChanged();
}
// Engine runs UpdateValidation() + OnTextEdited() here; merge the Goo Validate predicate and fire OnValidationChanged on a flip.
public override void OnValueChanged()
{
base.OnValueChanged();
ApplyPredicateAndNotify();
}
// Tighten HasValidationErrors with the Goo predicate, then fire OnValidationChanged (and rebuild) only on a validity transition.
internal void ApplyPredicateAndNotify()
{
if (_validate != null && !_validate(Text ?? string.Empty))
{
HasValidationErrors = true;
SetClass("invalid", true);
}
if (HasValidationErrors != _lastInvalid)
{
_lastInvalid = HasValidationErrors;
_onValidationChanged?.Invoke(HasValidationErrors);
if (_onValidationChanged != null) _requestRebuild?.Invoke();
}
}
// Recompute validity (engine rules + predicate) without an edit event; the Applier calls this after props change.
internal void RecomputeValidation()
{
UpdateValidation();
ApplyPredicateAndNotify();
}
// Engine fires "onsubmit" itself on Enter (no Submit method); hook OnEvent to react.
protected override void OnEvent(PanelEvent e)
{
base.OnEvent(e);
if (e.Name == "onsubmit") { _onSubmit?.Invoke(Text ?? string.Empty); if (_onSubmit != null) _requestRebuild?.Invoke(); }
// Escape fires "oncancel" via the engine's Cancel(); value-less, same path as onsubmit.
if (e.Name == "oncancel") { _onCancel?.Invoke(); if (_onCancel != null) _requestRebuild?.Invoke(); }
}
// Call base first so the engine's focus/blur work runs before we observe the committed Text.
protected override void OnFocus(PanelEvent e)
{
base.OnFocus(e);
if (_onFocus != null) { _onFocus.Invoke(); _requestRebuild?.Invoke(); }
}
protected override void OnBlur(PanelEvent e)
{
base.OnBlur(e);
if (_onBlur != null) { _onBlur.Invoke(Text ?? string.Empty); _requestRebuild?.Invoke(); }
}
public void ApplyEvents(in BlobEvents events)
{
_onClick = events.OnClick;
_onRightClick = events.OnRightClick;
_onMiddleClick = events.OnMiddleClick;
_onMouseEnter = events.OnMouseEnter;
_onMouseLeave = events.OnMouseLeave;
_onMouseDown = events.OnMouseDown;
_onMouseUp = events.OnMouseUp;
_onMouseMove = events.OnMouseMove;
}
public bool HasEventHandlers =>
_onClick != null || _onRightClick != null || _onMiddleClick != null || _onMouseEnter != null || _onMouseLeave != null ||
_onMouseDown != null || _onMouseUp != null || _onMouseMove != null ||
_onChange != null || _onSubmit != null ||
_onFocus != null || _onBlur != null || _onCancel != null;
public bool UserSetPointerEvents
{
get => _userSetPointerEvents;
set => _userSetPointerEvents = value;
}
protected override void OnClick(MousePanelEvent e) { base.OnClick(e); EventDispatch.Fire(_onClick, e, _requestRebuild); }
protected override void OnRightClick(MousePanelEvent e) { base.OnRightClick(e); EventDispatch.Fire(_onRightClick, e, _requestRebuild); }
protected override void OnMiddleClick(MousePanelEvent e) { base.OnMiddleClick(e); EventDispatch.Fire(_onMiddleClick, e, _requestRebuild); }
protected override void OnMouseOver(MousePanelEvent e) { base.OnMouseOver(e); EventDispatch.Fire(_onMouseEnter, e, _requestRebuild); }
protected override void OnMouseOut(MousePanelEvent e) { base.OnMouseOut(e); EventDispatch.Fire(_onMouseLeave, e, _requestRebuild); }
protected override void OnMouseDown(MousePanelEvent e) { base.OnMouseDown(e); EventDispatch.Fire(_onMouseDown, e, _requestRebuild); }
protected override void OnMouseUp(MousePanelEvent e) { base.OnMouseUp(e); EventDispatch.Fire(_onMouseUp, e, _requestRebuild); }
protected override void OnMouseMove(MousePanelEvent e) { base.OnMouseMove(e); EventDispatch.Fire(_onMouseMove, e, _requestRebuild); }
}
namespace LobbySystem;
/// <summary>
/// Auto-hosts a lobby so Steam friends can join, and keeps one networked pawn per connection plus optional
/// bots by cloning <see cref="PlayerPrefab"/>. The pawn only has to implement <see cref="ILobbyAgent"/>.
/// Spawning is de-duped and runs in OnUpdate so a join can't fire mid-enumeration.
/// </summary>
public sealed class LobbyNetworkManager : Component, Component.INetworkListener
{
[Property] public GameObject PlayerPrefab { get; set; }
[Property] public int BotCount { get; set; } = 1;
/// <summary>When true, bots only exist during an active round.</summary>
[Property] public bool BotsOnlyDuringRound { get; set; } = true;
[Property] public Color BotTint { get; set; } = new Color( 1f, 0.35f, 0.3f );
// Lobby spawn ring, used before a round map loads.
readonly Vector3[] _spawns =
{
new Vector3( 0f, -300f, 40f ), new Vector3( 300f, 0f, 40f ),
new Vector3( 0f, 300f, 40f ), new Vector3( -300f, 0f, 40f ),
new Vector3( 250f, 250f, 40f ), new Vector3( -250f, -250f, 40f ),
};
int _spawnIndex;
readonly Dictionary<Guid, GameObject> _pawns = new();
readonly List<GameObject> _bots = new();
bool _reconcileNow;
TimeUntil _nextReconcile;
TimeUntil _nextSweep;
protected override async Task OnLoad()
{
// When joining a friend the engine is mid-connect and IsActive is briefly false, so poll for a
// moment before hosting. Otherwise a joiner would spin up its own solo lobby.
if ( Networking.IsActive ) return;
for ( int i = 0; i < 6 && !Networking.IsActive; i++ )
await Task.DelayRealtimeSeconds( 0.1f );
if ( !Networking.IsActive )
Networking.CreateLobby( new() );
}
void INetworkListener.OnActive( Connection channel ) => _reconcileNow = true;
protected override void OnUpdate()
{
if ( !Networking.IsHost || PlayerPrefab is null ) return;
if ( !_reconcileNow && _nextReconcile > 0f ) return;
_reconcileNow = false;
_nextReconcile = 0.25f;
try
{
bool wantBots = !BotsOnlyDuringRound || (LobbyDirector.Current?.State == LobbyState.Active);
ReconcileBots( wantBots ? Math.Max( 0, BotCount ) : 0 );
foreach ( var conn in Connection.All.ToList() )
{
if ( conn is null || !conn.IsActive ) continue;
if ( _pawns.TryGetValue( conn.Id, out var pawn ) && pawn.IsValid() ) continue;
var id = conn.Id;
_pawns[id] = FindConnectionPawn( id ) ?? SpawnPawn( false, conn.DisplayName, conn );
}
Sweep();
}
catch
{
// Connection or scene list changed during the pass; retry next frame.
}
}
void ReconcileBots( int target )
{
_bots.RemoveAll( b => !b.IsValid() );
while ( _bots.Count > target )
{
var b = _bots[_bots.Count - 1];
_bots.RemoveAt( _bots.Count - 1 );
if ( b.IsValid() ) b.Destroy();
}
while ( _bots.Count < target )
_bots.Add( SpawnPawn( true, "Bot", null ) );
}
GameObject FindConnectionPawn( Guid id )
{
foreach ( var a in Scene.GetAllComponents<ILobbyAgent>() )
{
if ( !a.IsValid() || a.IsBot ) continue;
if ( a is Component c && c.Network.OwnerId == id ) return c.GameObject;
}
return null;
}
void Sweep()
{
if ( _nextSweep > 0f ) return;
_nextSweep = 1f;
foreach ( var key in _pawns.Where( kv => !kv.Value.IsValid() ).Select( kv => kv.Key ).ToList() )
_pawns.Remove( key );
}
GameObject SpawnPawn( bool isBot, string displayName, Connection owner )
{
var go = PlayerPrefab.Clone( NextSpawn() );
go.Name = isBot ? "Bot" : $"Player - {displayName}";
go.Enabled = true;
var agent = go.Components.Get<ILobbyAgent>() ?? go.Components.GetInChildren<ILobbyAgent>();
agent?.InitAgent( isBot, displayName );
if ( isBot )
{
var rend = go.Components.GetInChildren<SkinnedModelRenderer>();
if ( rend is not null ) rend.Tint = BotTint;
}
if ( owner is not null ) go.NetworkSpawn( owner );
else go.NetworkSpawn();
return go;
}
Vector3 NextSpawn()
{
int idx = _spawnIndex++;
var dir = LobbyDirector.Current;
if ( dir is not null && dir.UseRoundMap && dir.MapReady )
return dir.RoundSpawnPoint( idx );
return _spawns[idx % _spawns.Length];
}
}
namespace LobbySystem;
/// <summary>Lifecycle of the lobby: Lobby, then Active, then Ended before looping back.</summary>
public enum LobbyState
{
/// <summary>Free roam before and after a round. The mode button works here.</summary>
Lobby,
/// <summary>A round is running.</summary>
Active,
/// <summary>Round finished; results show before returning to the lobby.</summary>
Ended
}
namespace LobbySystem;
/// <summary>
/// In-world button that opens the mode menu for the host, or a local suggestion menu for a client. It needs
/// a ModelRenderer to be visible and is hidden while a round is live. When the local player is within
/// <see cref="UseRange"/> and presses Use, the menu opens.
/// </summary>
public sealed class LobbyModeButton : Component
{
[Property] public float UseRange { get; set; } = 130f;
[Property] public bool GlowWhenInRange { get; set; } = true;
[Property] public Color IdleTint { get; set; } = new Color( 0.85f, 0.4f, 0.15f );
[Property] public Color ActiveTint { get; set; } = new Color( 1f, 0.85f, 0.3f );
ModelRenderer _renderer;
ILobbyAgent _me;
protected override void OnStart()
{
_renderer = Components.Get<ModelRenderer>() ?? Components.GetInChildren<ModelRenderer>();
if ( _renderer is not null ) _renderer.Tint = IdleTint;
}
protected override void OnUpdate()
{
var dir = LobbyDirector.Current;
bool inLobby = dir is null || !dir.RoundLive;
if ( _renderer is not null && _renderer.Enabled != inLobby )
_renderer.Enabled = inLobby;
if ( !inLobby ) return;
var me = LocalPlayer();
bool inRange = me is not null && WorldPosition.Distance( me.WorldPosition ) <= UseRange;
if ( GlowWhenInRange && _renderer is not null )
_renderer.Tint = inRange ? ActiveTint : IdleTint;
if ( inRange && Input.Pressed( "Use" ) )
dir?.RequestModeMenu();
}
ILobbyAgent LocalPlayer()
{
if ( _me is not null && _me.IsValid() && !_me.IsBot && !_me.IsProxy ) return _me;
try { _me = Scene.GetAllComponents<ILobbyAgent>().FirstOrDefault( c => c.IsValid() && !c.IsBot && !c.IsProxy ); }
catch { _me = null; }
return _me;
}
}
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "MC Clouds" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "mcclouds" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "trend" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "trend.mcclouds" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "26" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-06-16T17:04:05.4666731Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.124.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.124.0")]using Sandbox;
using Sandbox.UI;
using System;
namespace SbTween;
public static class LightExtensions
{
public static BaseTween TweenLightColor( this Light Light, Color target, float duration )
{
Color start = Light.LightColor;
var tween = new BaseTween( duration );
tween.Target = Light.GameObject;
return TweenManager.Instance.AddTween( tween
.OnStart( () => start = Light.LightColor )
.OnUpdate( p => Light.LightColor = Color.Lerp( start, target, p ) ) );
}
public static BaseTween TweenRadius( this SpotLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.Radius;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.Radius )
.OnUpdate( p => light.Radius = MathX.Lerp( start, target, p ) ) );
}
public static BaseTween TweenConeOuter( this SpotLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.ConeOuter;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.ConeOuter )
.OnUpdate( p => light.ConeOuter = MathX.Lerp( start, target, p ) ) );
}
public static BaseTween TweenInnerCone( this SpotLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.ConeInner;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.ConeInner )
.OnUpdate( p => light.ConeInner = MathX.Lerp( start, target, p ) ) );
}
public static BaseTween TweenAttenuation( this PointLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.Attenuation;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.Attenuation )
.OnUpdate( p => light.Attenuation = MathX.Lerp( start, target, p ) ) );
}
public static BaseTween TweenRadius( this PointLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.Radius;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.Radius )
.OnUpdate( p => light.Radius = MathX.Lerp( start, target, p ) ) );
}
public static BaseTween TweenAttenuation( this SpotLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.Attenuation;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.Attenuation )
.OnUpdate( p => light.Attenuation = MathX.Lerp( start, target, p ) ) );
}
//FLICKERING LIGHT
public static BaseTween TweenFlickerLight( this PointLight light, float minBrightness, float maxBrightness, float duration, float speed = 10f )
{
if ( !light.IsValid() ) return null;
float time = 0f;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnUpdate( _ =>
{
time += Time.Delta * speed;
float noise = MathF.Sin( time * 1.3f ) * MathF.Sin( time * 2.7f ) * MathF.Sin( time * 0.9f );
float t = (noise + 1f) * 0.5f;
light.Attenuation = MathX.Lerp( minBrightness, maxBrightness, t );
} ) );
}
public static BaseTween TweenFlickerLight( this SpotLight light, float minBrightness, float maxBrightness, float duration, float speed = 10f )
{
if ( !light.IsValid() ) return null;
float time = 0f;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnUpdate( _ =>
{
time += Time.Delta * speed;
float noise = MathF.Sin( time * 1.3f ) * MathF.Sin( time * 2.7f ) * MathF.Sin( time * 0.9f );
float t = (noise + 1f) * 0.5f;
light.Attenuation = MathX.Lerp( minBrightness, maxBrightness, t );
} ) );
}
//FLICKERING Color
public static BaseTween TweenFlickerColor( this PointLight light, Color colorA, Color colorB, float duration, float speed = 10f )
{
if ( !light.IsValid() ) return null;
float time = 0f;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnUpdate( _ =>
{
time += Time.Delta * speed;
float noise = MathF.Sin( time * 1.3f ) * MathF.Sin( time * 2.7f ) * MathF.Sin( time * 0.9f );
float t = (noise + 1f) * 0.5f;
light.LightColor = Color.Lerp( colorA, colorB, t );
} ) );
}
public static BaseTween TweenFlickerColor( this SpotLight light, Color colorA, Color colorB, float duration, float speed = 10f )
{
if ( !light.IsValid() ) return null;
float time = 0f;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnUpdate( _ =>
{
time += Time.Delta * speed;
float noise = MathF.Sin( time * 1.3f ) * MathF.Sin( time * 2.7f ) * MathF.Sin( time * 0.9f );
float t = (noise + 1f) * 0.5f;
light.LightColor = Color.Lerp( colorA, colorB, t );
} ) );
}
}
using Sandbox;
using System;
namespace SbTween;
public static class AudioExtensions
{
public static BaseTween TweenVolume( this SoundPointComponent sound, float targetVolume, float duration )
{
float startVolume = sound.Volume;
var tween = new BaseTween( duration );
tween.Target = sound.GameObject;
return TweenManager.Instance.AddTween( tween
.OnStart( () => startVolume = sound.Volume )
.OnUpdate( p =>
{
if ( !sound.IsValid() ) return;
sound.Volume = MathX.Lerp( startVolume, targetVolume, p );
} )
);
}
public static BaseTween TweenPitch( this SoundPointComponent sound, float targetPitch, float duration )
{
float startPitch = sound.Pitch;
var tween = new BaseTween( duration );
tween.Target = sound.GameObject;
return TweenManager.Instance.AddTween( tween
.OnStart( () => startPitch = sound.Pitch )
.OnUpdate( p =>
{
if ( !sound.IsValid() ) return;
sound.Pitch = MathX.Lerp( startPitch, targetPitch, p );
} )
);
}
}
using Sandbox;
using System;
namespace SbTween;
public static class MathTweenExtensions
{
public static BaseTween TweenInCircle( this GameObject obj, float duration, Vector3 axis, float range, float speed, bool snapping = false )
{
Vector3 centerPos = obj.WorldPosition;
var tween = new BaseTween( duration );
tween.Target = obj;
Vector3 normal = axis.Normal;
Vector3 v1 = Vector3.Cross( normal, MathF.Abs( normal.z ) < 0.9f ? Vector3.Up : Vector3.Forward ).Normal;
Vector3 v2 = Vector3.Cross( normal, v1 ).Normal;
return TweenManager.Instance.AddTween( tween
.OnUpdate( p =>
{
if ( !obj.IsValid() ) return;
float angleDegrees = p * 360f * speed;
float angleRadians = angleDegrees * (MathF.PI / 180f);
float cos = MathF.Cos( angleRadians ) * range;
float sin = MathF.Sin( angleRadians ) * range;
Vector3 rotatedOffset = (v1 * cos) + (v2 * sin);
obj.WorldPosition = centerPos + rotatedOffset;
} )
);
}
public static BaseTween TweenSpiral( this GameObject obj, float duration, Vector3 axis, float speed, float frequency )
{
Vector3 startPos = obj.WorldPosition;
var tween = new BaseTween( duration );
tween.Target = obj;
return TweenManager.Instance.AddTween( tween
.OnStart( () => startPos = obj.WorldPosition )
.OnUpdate( p =>
{
if ( !obj.IsValid() ) return;
float angle = p * MathF.PI * 2f * frequency;
float currentRadius = p * speed;
float x = MathF.Cos( angle ) * currentRadius;
float y = MathF.Sin( angle ) * currentRadius;
Vector3 axisOffset = axis * p;
Vector3 circleOffset = new Vector3( x, y, 0 );
obj.WorldPosition = startPos + axisOffset + circleOffset;
} )
);
}
public static BaseTween TweenPunchFloat( this GameObject obj, float v, float amplitude, float duration, int vibrations = 5, float elasticity = 1f, Action<float> setter = null )
{
var tween = new BaseTween( duration );
tween.Target = obj;
return TweenManager.Instance.AddTween( tween
.OnUpdate( p =>
{
if ( !obj.IsValid() ) return;
if ( p >= 1.0f )
{
setter?.Invoke( v );
return;
}
float decay = MathF.Pow( 1f - p, elasticity * 3f );
float omega = vibrations * MathF.PI * 2f;
float oscillation = MathF.Sin( p * omega );
float currentOffset = amplitude * oscillation * decay;
setter?.Invoke( v + currentOffset );
} )
.OnComplete( () => setter?.Invoke( v ) )
);
}
public static BaseTween TweenShakeFloat( this GameObject obj, float baseline, float strength, float duration, Action<float> setter = null )
{
var tween = new BaseTween( duration );
tween.Target = obj;
return TweenManager.Instance.AddTween( tween
.OnUpdate( p =>
{
if ( !obj.IsValid() ) return;
if ( p >= 1.0f )
{
setter?.Invoke( baseline );
return;
}
float currentStrength = strength * (1.0f - p);
float randomOffset = Game.Random.Float( -currentStrength, currentStrength );
setter?.Invoke( baseline + randomOffset );
} )
.OnComplete( () => setter?.Invoke( baseline ) )
);
}
}
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 Sandbox;
[TestClass]
public partial class LibraryTests
{
[TestMethod]
public void SceneTest()
{
var scene = new Scene();
using ( scene.Push() )
{
var go = new GameObject();
Assert.AreEqual( 1, scene.Directory.GameObjectCount );
}
}
}
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}" } );
}
}
}
namespace Sandbox.UiPro;
public enum HorizontalAlignment
{
Left,
Center,
Right
}
public enum VerticalAlignment
{
Top,
Center,
Bottom
}
[Title( "Text Node - UI Pro" ), Category( "UI Pro" ), Icon( "text_fields" )]
public class TextNode : NodeComponent
{
[Property, InlineEditor, Group( "Layout Settings" ), Order( -999 )] public override NodeStyle Style { get; set; } = GetDefaultStyle();
[Property, Group("Text Settings")] public HorizontalAlignment HorizontalAlignment { get; set; } = HorizontalAlignment.Center;
[Property, Group( "Text Settings" )] public VerticalAlignment VerticalAlignment { get; set; } = VerticalAlignment.Center;
[Property, InlineEditor, Group( "Text Settings" )] public TextRendering.Scope TextScope { get; set; } = TextRendering.Scope.Default;
private static NodeStyle GetDefaultStyle()
{
return new NodeStyle()
{
Anchor = NodePoint.CenterMiddle,
Pivot = NodePoint.CenterMiddle,
Offset = Vector2.Zero,
Size = new Vector2( 100, 100 ),
ChildPadding = Vector2.Zero,
ClipChildren = false,
StretchHorizontal = false,
StretchVertical = false,
CornerRadius = 0,
BorderWidth = 0,
BorderColor = Color.Black,
Texture = Texture.White,
Tint = Color.White,
UvScale = Vector2.One,
UvOffset = Vector2.Zero
};
}
protected override void UpdateStyle(float scaleFactor)
{
Style.BorderWidth = 0; // for debugging
Style.BorderColor = Color.White;
TextRendering.Scope scope = TextScope;
scope.FontSize *= scaleFactor;
Texture texture = TextRendering.GetOrCreateTexture( scope );
Style.Texture = texture;
Vector2 scale = (Layout.Outer.Size * scaleFactor) / texture.Size;
Style.UvScale = scale;
float alignX = HorizontalAlignment switch
{
HorizontalAlignment.Left => 0f,
HorizontalAlignment.Center => 0.5f,
HorizontalAlignment.Right => 1f,
_ => 0.5f,
};
float alignY = VerticalAlignment switch
{
VerticalAlignment.Top => 0f,
VerticalAlignment.Center => 0.5f,
VerticalAlignment.Bottom => 1f,
_ => 0.5f,
};
float offsetX = alignX * (1f / scale.x - 1f);
float offsetY = alignY * (1f / scale.y - 1f);
Style.UvOffset = new Vector2( offsetX, offsetY );
}
}
using Sandbox.UiPro;
namespace Sandbox;
// Hooks up the Button's OnClick event and responds
// by updating the TextNode
public class ExampleButtonController : Component
{
[Property] public Button MyButton { get; set; }
[Property] public TextNode MyText { get; set; }
[Property, ReadOnly] public int ClickCount { get; set; } = 0;
protected override void OnStart()
{
if ( !MyButton.IsValid() ) return;
MyButton.OnClick = OnButtonClicked;
}
private void OnButtonClicked()
{
ClickCount++;
if ( !MyText.IsValid() ) return;
TextRendering.Scope scope = MyText.TextScope;
scope.Text = $"Clicked {ClickCount} Times";
MyText.TextScope = scope;
}
}
using System;
namespace Sandbox.UiPro;
public enum NodePoint
{
TopLeft, TopMiddle, TopRight,
CenterLeft, CenterMiddle, CenterRight,
BottomLeft, BottomMiddle, BottomRight,
}
public class NodeStyle
{
[Property] public NodePoint Anchor { get; set; }
[Property] public NodePoint Pivot { get; set; }
[Property] public Vector2 Offset { get; set; }
[Property] public Vector2 Size { get; set; }
[Property] public Vector4 ChildPadding { get; set; }
[Property] public bool ClipChildren { get; set; }
[Property] public bool StretchHorizontal { get; set; }
[Property] public bool StretchVertical { get; set; }
[Property, Hide] public float CornerRadius { get; set; }
[Property, Hide] public float BorderWidth { get; set; }
[Property, Hide] public Color BorderColor { get; set; }
[Property, Hide] public Texture Texture { get; set; }
[Property, Hide] public Color Tint { get; set; }
[Property, Hide] public Vector2 UvScale { get; set; }
[Property, Hide] public Vector2 UvOffset { get; set; }
}
public class NodeLayout
{
public Rect Outer { get; private set; }
public Rect Inner { get; private set; }
public Rect ClipRect { get; private set; }
public float ClipRadius { get; private set; }
public Rect ChildClipRect { get; private set; }
public float ChildClipRadius { get; private set; }
public static NodeLayout GetRootLayout( Vector2 size )
{
Rect rootRect = new Rect( Vector2.Zero, size );
NodeLayout layout = new NodeLayout()
{
Outer = rootRect,
Inner = rootRect,
ClipRect = rootRect,
ClipRadius = 0,
ChildClipRect = rootRect,
ChildClipRadius = 0
};
return layout;
}
public void Compute( NodeLayout parentLayout, NodeStyle style )
{
Vector2 anchor = AnchorFraction( style.Anchor );
Vector2 pivot = AnchorFraction( style.Pivot );
float width = style.StretchHorizontal ? parentLayout.Inner.Size.x : style.Size.x;
float height = style.StretchVertical ? parentLayout.Inner.Size.y : style.Size.y;
float x = style.StretchHorizontal
? parentLayout.Inner.Position.x + style.Offset.x
: parentLayout.Inner.Position.x + anchor.x * parentLayout.Inner.Size.x - pivot.x * width + style.Offset.x;
float y = style.StretchVertical
? parentLayout.Inner.Position.y + style.Offset.y
: parentLayout.Inner.Position.y + anchor.y * parentLayout.Inner.Size.y - pivot.y * height + style.Offset.y;
Outer = new Rect( new Vector2( x, y ), new Vector2( width, height ) );
float innerW = Math.Max( 0f, width - style.ChildPadding.x - style.ChildPadding.z );
float innerH = Math.Max( 0f, height - style.ChildPadding.y - style.ChildPadding.w );
Inner = new Rect( new Vector2( x + style.ChildPadding.x, y + style.ChildPadding.y ), new Vector2( innerW, innerH ) );
ClipRect = parentLayout.ChildClipRect;
ClipRadius = parentLayout.ChildClipRadius;
if ( style.ClipChildren )
{
float bw = Math.Max( 0f, style.BorderWidth );
float clipW = Math.Max( 0f, Outer.Size.x - bw * 2f );
float clipH = Math.Max( 0f, Outer.Size.y - bw * 2f );
ChildClipRect = new Rect( new Vector2( Outer.Position.x + bw, Outer.Position.y + bw ), new Vector2( clipW, clipH ) );
ChildClipRadius = Math.Max( 0f, style.CornerRadius - bw );
}
else
{
ChildClipRect = parentLayout.ChildClipRect;
ChildClipRadius = parentLayout.ChildClipRadius;
}
}
private static Vector2 AnchorFraction( NodePoint point )
{
float fx = point switch
{
NodePoint.TopLeft or NodePoint.CenterLeft or NodePoint.BottomLeft => 0f,
NodePoint.TopMiddle or NodePoint.CenterMiddle or NodePoint.BottomMiddle => 0.5f,
_ => 1f,
};
float fy = point switch
{
NodePoint.TopLeft or NodePoint.TopMiddle or NodePoint.TopRight => 0f,
NodePoint.CenterLeft or NodePoint.CenterMiddle or NodePoint.CenterRight => 0.5f,
_ => 1f,
};
return new Vector2( fx, fy );
}
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
using System;
using Sandbox;
using Sandbox.ui;
public sealed class SceneGrassComponent : Component
{
public static bool EditorPainterActive { get; set; }
public GrassRenderObject Renderer { get; private set; }
[Property] public GrassDensityMapResource DensityMapResource { get; set; }
[Property] public float ChunkSize { get; set; } = 256.0f;
[Property] public int ChunkResolution { get; set; } = 64;
[Property] public int RenderRadius { get; set; } = 6;
[Property] public float StreamingRadius { get; set; } = 1536.0f;
[Property] public float LodCutoff { get; set; } = 2048.0f;
[Property] public float DistanceCutoff { get; set; } = 4096.0f;
[Property] public float LodTransitionRange { get; set; } = 200.0f;
[Property] public float DistanceTransitionRange { get; set; } = 200.0f;
[Property] public float DisplacementStrength { get; set; } = 200.0f;
[Property] public float TerrainProbeTop { get; set; } = 4096.0f;
[Property] public float TerrainProbeBottom { get; set; } = -4096.0f;
[Property] public float TerrainHeightOffset { get; set; } = 0.0f;
[Property] public float GrassHeightPadding { get; set; } = 128.0f;
[Property] public float FallbackHeight { get; set; } = 0.0f;
[Property] public float InteractionStrength { get; set; } = 8.0f;
[Property] public float InteractionStampRate { get; set; } = 36.0f;
[Property] public float InteractionBendHoldDuration { get; set; } = 0.5f;
[Property] public float InteractionDecayUpdateInterval { get; set; } = 0.05f;
[Property] public float CutDuration { get; set; } = 8.0f;
protected override void OnAwake()
{
base.OnAwake();
using ( Scene.Push() )
{
Renderer = new GrassRenderObject( Scene.SceneWorld );
}
SyncRendererSettings();
}
private void SyncRendererSettings()
{
if ( Renderer == null )
return;
float streamingRadius = StreamingRadius;
if ( EditorPainterActive )
streamingRadius *= 10.0f;
Renderer.ChunkSize = ChunkSize;
Renderer.ChunkResolution = ChunkResolution;
float requiredStreamingRadius = Math.Max( streamingRadius, Math.Max( LodCutoff, DistanceCutoff ) + ChunkSize );
Renderer.RenderRadius = Math.Max( 1, MathX.CeilToInt( requiredStreamingRadius / Math.Max( ChunkSize, 0.001f ) ) );
Renderer.LodCutoff = LodCutoff;
Renderer.LodTransitionRange = LodTransitionRange;
Renderer.DistanceTransitionRange = DistanceTransitionRange;
Renderer.DistanceCutoff = DistanceCutoff;
Renderer.DisplacementStrength = DisplacementStrength;
Renderer.TerrainProbeTop = TerrainProbeTop;
Renderer.TerrainProbeBottom = TerrainProbeBottom;
Renderer.TerrainHeightOffset = TerrainHeightOffset;
Renderer.GrassHeightPadding = GrassHeightPadding;
Renderer.FallbackHeight = FallbackHeight;
Renderer.InteractionStrength = InteractionStrength;
Renderer.InteractionStampRate = InteractionStampRate;
Renderer.InteractionBendHoldDuration = InteractionBendHoldDuration;
Renderer.CutDuration = CutDuration;
Renderer.InteractionDecayUpdateInterval = InteractionDecayUpdateInterval;
Renderer.SetDensityResource( DensityMapResource );
Renderer.CullingCamera = Scene.Camera;
}
protected override void OnUpdate()
{
base.OnUpdate();
UpdateRenderer();
}
private void UpdateRenderer()
{
if ( Renderer == null )
return;
if ( Scene.Camera == null )
return;
SyncRendererSettings();
Renderer.SetDensityResource( DensityMapResource );
Renderer.CullingCamera = Scene.Camera;
Renderer.UpdateInteractionField( Scene.GetAllComponents<GrassInteractionSourceComponent>(), Time.Delta );
Vector3 camPos = Scene.Camera.WorldPosition;
Renderer.UpdateStreaming( camPos );
Renderer.ProcessPendingDestroy();
}
protected override void OnDisabled()
{
base.OnDisabled();
Renderer?.Disable();
Renderer = null;
}
protected override void DrawGizmos()
{
base.DrawGizmos();
UpdateRenderer();
if ( !EditorPainterActive )
return;
if ( Renderer == null )
return;
Gizmo.Draw.Color = Color.Yellow;
foreach ( var pair in Renderer.ActiveChunks )
{
BBox bounds = BBox.FromPositionAndSize( pair.Value.Bounds.Center, pair.Value.Bounds.Size.WithZ( 0 ) );
Gizmo.Draw.LineBBox( bounds );
}
}
}
using System;
using System.Collections.Generic;
using System.Text;
namespace Saandy.Tilemapper;
public interface ITilemapSceneEvent : ISceneEvent<ITilemapSceneEvent>
{
/// <summary>
/// Called when tilemap was changed this frame.
/// </summary>
void OnTilemapChanged() { }
/// <summary>
/// Called when the tilemap has been updated recently and stopped being updated.
/// </summary>
void OnTilemapStable() { }
}
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();
}
}