🔍 s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (19 total matches found)
bluedock.modelpro / Editor/VmdlEditTab.cs
Editor library
using System;
using System.Globalization;
using System.IO;
using ModelPro.Vmdl;

namespace ModelPro.Editor;

/// <summary>
/// The VMDL tab of Model Pro. Pick a folder, it recursively finds every .vmdl
/// file, and you can apply uniform scale and align-origin X/Y/Z changes to all
/// their mesh entries at once.
/// </summary>
public class VmdlEditTab : Widget
{
	TreeView _folderTree;
	TreeView _fileList;
	Label _statusLabel;

	LineEdit _scaleEdit;
	ComboBox _scaleUnit;
	ScaleUnit _lastScaleUnit = ScaleUnit.Inches;
	ComboBox _alignX;
	ComboBox _alignY;
	ComboBox _alignZ;
	ControlSheet _materialSheet;
	DefaultMaterialModel _materialModel = new();
	Button _applyButton;
	Label _resultLabel;

	List<string> _foundVmdlFiles = new();

	public VmdlEditTab( Widget parent ) : base( parent )
	{
		BuildUI();
		LoadSettings();
	}

	private void BuildUI()
	{
		Layout = Layout.Column();
		Layout.Spacing = 4;

		var split = Layout.AddRow();
		split.Spacing = 8;

		var left = split.AddColumn();
		left.Spacing = 4;
		left.Add( new Label( "<b>Folder</b>" ) );

		var treeScroll = left.Add( new ScrollArea( this ), 1 );
		treeScroll.Canvas = new Widget();
		treeScroll.Canvas.Layout = Layout.Column();
		treeScroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 2 );

		_folderTree = new TreeView( treeScroll.Canvas );
		treeScroll.Canvas.Layout.Add( _folderTree );
		_folderTree.ExpandForSelection = true;

		AddFolderNodes();

		var right = split.AddColumn();
		right.Spacing = 4;

		_statusLabel = right.Add( new Label( "Select a folder to search for models." ) );
		_statusLabel.WordWrap = true;

		var listScroll = right.Add( new ScrollArea( this ), 1 );
		listScroll.Canvas = new Widget();
		listScroll.Canvas.Layout = Layout.Column();
		listScroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 2 );

		_fileList = new TreeView( listScroll.Canvas );
		listScroll.Canvas.Layout.Add( _fileList );

		var group = right.Add( new Widget() );
		var grid = Layout.Grid();
		grid.Spacing = 4;
		group.Layout = grid;

		int row = 0;

		grid.AddCell( 0, row, new Label( "Scale" ) );

		var scaleRow = Layout.Row();
		scaleRow.Spacing = 4;
		_scaleEdit = new LineEdit() { Text = "1.0", FixedWidth = 90 };
		_scaleUnit = new ComboBox() { FixedWidth = 130 };
		foreach ( var unit in Enum.GetValues<ScaleUnit>() )
		{
			var u = unit;
			_scaleUnit.AddItem( u.ToDisplayString(), onSelected: () => OnScaleUnitChanged( u ) );
		}
		_scaleUnit.TrySelectNamed( ScaleUnit.Inches.ToDisplayString() );
		scaleRow.Add( _scaleEdit );
		scaleRow.Add( _scaleUnit );
		grid.AddCell( 1, row, scaleRow );
		row++;

		grid.AddCell( 0, row, new Label( "Align Origin X" ) );
		_alignX = CreateAlignCombo();
		grid.AddCell( 1, row, _alignX );
		row++;

		grid.AddCell( 0, row, new Label( "Align Origin Y" ) );
		_alignY = CreateAlignCombo();
		grid.AddCell( 1, row, _alignY );
		row++;

		grid.AddCell( 0, row, new Label( "Align Origin Z" ) );
		_alignZ = CreateAlignCombo();
		grid.AddCell( 1, row, _alignZ );
		row++;

		// The material picker has its own "Material" label, so give it a fixed
		// width row below the grid - otherwise it stretches the whole window.
		var materialWrap = new Widget() { FixedWidth = 280 };
		materialWrap.Layout = Layout.Column();
		_materialSheet = new ControlSheet();
		_materialSheet.AddProperty( _materialModel, x => x.Material );
		materialWrap.Layout.Add( _materialSheet );
		right.Add( materialWrap );

		_applyButton = new Button( "Apply to N models", "check" );
		_applyButton.Clicked += ApplyEdits;
		right.Add( _applyButton );

		_resultLabel = new Label( "" );
		_resultLabel.WordWrap = true;
		right.Add( _resultLabel );
	}

	private ComboBox CreateAlignCombo()
	{
		var combo = new ComboBox() { FixedWidth = 120 };
		combo.AddItem( "None" );
		combo.AddItem( "BoundsCenter" );
		combo.AddItem( "BoundsMin" );
		combo.AddItem( "BoundsMax" );
		combo.TrySelectNamed( "None" );
		return combo;
	}

	/// <summary>Restore the last used values into the controls.</summary>
	private void LoadSettings()
	{
		var s = ModelProSettings.Current;

		_scaleEdit.Text = s.VmdlScale;
		_lastScaleUnit = s.VmdlScaleUnit;
		_scaleUnit.TrySelectNamed( s.VmdlScaleUnit.ToDisplayString() );
		_alignX.TrySelectNamed( s.VmdlAlignX.ToKv3Value() );
		_alignY.TrySelectNamed( s.VmdlAlignY.ToKv3Value() );
		_alignZ.TrySelectNamed( s.VmdlAlignZ.ToKv3Value() );
		_materialModel.Material = s.VmdlDefaultMaterial;
	}

	/// <summary>Remember the current values for next time.</summary>
	private void SaveSettings()
	{
		var s = ModelProSettings.Current;
		s.VmdlScale = _scaleEdit.Text;
		s.VmdlScaleUnit = ParseScaleUnit( _scaleUnit.CurrentText );
		s.VmdlAlignX = ParseAlign( _alignX.CurrentText ).Value;
		s.VmdlAlignY = ParseAlign( _alignY.CurrentText ).Value;
		s.VmdlAlignZ = ParseAlign( _alignZ.CurrentText ).Value;
		s.VmdlDefaultMaterial = _materialModel.Material ?? "";
		s.Save();
	}

	private void AddFolderNodes()
	{
		var rootDir = Sandbox.Project.Current?.RootDirectory;
		if ( rootDir is null )
			return;

		var root = new FolderNode( rootDir.FullName, OnFolderSelected );
		_folderTree.AddItem( root );
		_folderTree.Open( root );
	}

	private void OnFolderSelected( string folder )
	{
		_statusLabel.Text = $"Searching <b>{folder}</b>...";
		_resultLabel.Text = "";

		_foundVmdlFiles = Directory.GetFiles( folder, "*.vmdl", SearchOption.AllDirectories )
			.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
			.ToList();

		_fileList.Clear();

		int meshEntryTotal = 0;
		foreach ( var file in _foundVmdlFiles )
		{
			int count = 0;
			try
			{
				var editor = VmdlBulkEditor.LoadFile( file );
				count = editor.MeshEntryCount;
				meshEntryTotal += count;
			}
			catch ( Exception e )
			{
				Log.Warning( e, $"Model Pro: failed to parse {file}" );
			}

			_fileList.AddItem( new FileNode( file, count ) );
		}

		_statusLabel.Text = $"Found <b>{_foundVmdlFiles.Count}</b> models in <b>{folder}</b> — {meshEntryTotal} mesh entries.";

		_applyButton.Text = _foundVmdlFiles.Count == 0
			? "Apply to 0 models"
			: $"Apply to {_foundVmdlFiles.Count} models";
	}

	private void ApplyEdits()
	{
		if ( _foundVmdlFiles.Count == 0 )
			return;

		var props = new MeshEntryProperties
		{
			ImportScale = ReadScale(),
			AlignOriginX = ParseAlign( _alignX.CurrentText ),
			AlignOriginY = ParseAlign( _alignY.CurrentText ),
			AlignOriginZ = ParseAlign( _alignZ.CurrentText ),
			GlobalDefaultMaterial = string.IsNullOrWhiteSpace( _materialModel.Material ) ? null : _materialModel.Material.Trim()
		};

		Log.Info( $"Model Pro: applying scale={props.ImportScale?.ToString( "0.0########", CultureInfo.InvariantCulture ) ?? "off"} " +
			$"align=({_alignX.CurrentText},{_alignY.CurrentText},{_alignZ.CurrentText}) to {_foundVmdlFiles.Count} file(s)" );

		int modified = 0;
		int totalEntries = 0;
		int noMeshEntries = 0;
		int alreadyMatched = 0;
		var errors = new List<string>();

		foreach ( var file in _foundVmdlFiles )
		{
			try
			{
				var editor = VmdlBulkEditor.LoadFile( file );
				if ( editor.MeshEntryCount == 0 )
				{
					noMeshEntries++;
					Log.Info( $"Model Pro: {Path.GetFileName( file )} - no RenderMeshFile entries found" );
				}
				else if ( editor.Apply( props ) )
				{
					File.WriteAllText( file, editor.Source );
					modified++;
					Log.Info( $"Model Pro: updated {Path.GetFileName( file )}" );

					RecompileModel( file );
				}
				else
				{
					alreadyMatched++;
					Log.Info( $"Model Pro: {Path.GetFileName( file )} - values already match" );
				}

				totalEntries += editor.MeshEntryCount;
			}
			catch ( Exception e )
			{
				errors.Add( $"{Path.GetFileName( file )}: {e.Message}" );
				Log.Warning( e, $"Model Pro: failed to process {file}" );
			}
		}

		var msg = $"Updated <b>{modified}</b> of {_foundVmdlFiles.Count} models ({totalEntries} mesh entries).";
		if ( modified == 0 && errors.Count == 0 )
		{
			var reason = noMeshEntries > 0 && alreadyMatched == 0
				? "None of the files contain RenderMeshFile entries."
				: "The values already match what's set above. Change a value and try again.";
			msg = $"No models needed updating — {reason}";
		}

		if ( errors.Count > 0 )
			msg += $"\n\nErrors ({errors.Count}):\n{string.Join( "\n", errors.Take( 8 ) )}";

		_resultLabel.Text = msg;

		SaveSettings();
	}

	private static AlignOrigin? ParseAlign( string text )
	{
		return text switch
		{
			"Center" => AlignOrigin.Center,
			"Mins" => AlignOrigin.Mins,
			"Maxs" => AlignOrigin.Maxs,
			"BoundsCenter" => AlignOrigin.BoundsCenter,
			"BoundsMin" => AlignOrigin.BoundsMin,
			"BoundsMax" => AlignOrigin.BoundsMax,
			_ => AlignOrigin.None
		};
	}

	/// <summary>
	/// Force the editor to recompile the model from its (just-written) source file
	/// so the asset system and any open ModelDoc pick up the change.
	/// </summary>
	private static void RecompileModel( string absolutePath )
	{
		try
		{
			var project = Sandbox.Project.Current;
			var assetsRoot = project?.GetAssetsPath();
			if ( string.IsNullOrEmpty( assetsRoot ) )
				return;

			// Find the asset by its content-relative path - FindByPath expects the
			// relative asset path (e.g. "models/vehicle_01_a.vmdl"), not a Windows path.
			var relative = absolutePath.Replace( '\\', '/' );
			if ( relative.StartsWith( assetsRoot.Replace( '\\', '/' ), StringComparison.OrdinalIgnoreCase ) )
				relative = relative.Substring( assetsRoot.Replace( '\\', '/' ).Length ).TrimStart( '/' );

			var asset = AssetSystem.FindByPath( relative );
			if ( asset is null )
			{
				// Fall back to matching by absolute path.
				asset = AssetSystem.FindByPath( absolutePath );
			}

			if ( asset is null )
			{
				Log.Warning( $"Model Pro: couldn't find asset for '{absolutePath}' to recompile" );
				return;
			}

			asset.Compile( true );
		}
		catch ( Exception e )
		{
			Log.Warning( e, $"Model Pro: failed to recompile '{absolutePath}'" );
		}
	}

	private float? ReadScale()
	{
		if ( !float.TryParse( _scaleEdit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) )
			return null;

		var unit = ParseScaleUnit( _scaleUnit.CurrentText );
		return new ScaleInput { Value = value, Unit = unit }.ToImportScale();
	}

	/// <summary>
	/// When the unit dropdown changes, convert the current scale value so the
	/// import scale stays the same - just like the create-model popup does.
	/// </summary>
	private void OnScaleUnitChanged( ScaleUnit newUnit )
	{
		if ( newUnit == _lastScaleUnit )
			return;

		if ( float.TryParse( _scaleEdit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var current ) )
		{
			var converted = new ScaleInput { Value = current, Unit = _lastScaleUnit }.ConvertTo( newUnit );
			_scaleEdit.Text = converted.Value.ToString( "0.0########", CultureInfo.InvariantCulture );
		}

		_lastScaleUnit = newUnit;
	}

	private static ScaleUnit ParseScaleUnit( string text )
	{
		foreach ( var unit in Enum.GetValues<ScaleUnit>() )
		{
			if ( string.Equals( unit.ToDisplayString(), text, StringComparison.OrdinalIgnoreCase ) )
				return unit;
		}

		return ScaleUnit.Inches;
	}
}
bluedock.modelpro / Editor/Vmdl/Kv3Parser.cs
Editor library
using System;
using System.Text;

namespace ModelPro.Vmdl;

internal enum Kv3TokenType
{
	LBrace,
	RBrace,
	LBracket,
	RBracket,
	Equals,
	Comma,
	String,
	Ident,
	End
}

internal readonly struct Kv3Token
{
	public readonly Kv3TokenType Type;
	public readonly int Start;
	public readonly int End;
	public readonly string Text;

	public Kv3Token( Kv3TokenType type, int start, int end, string text )
	{
		Type = type;
		Start = start;
		End = end;
		Text = text;
	}
}

/// <summary>
/// Tokenizes KV3 text (the format model .vmdl source files are stored in).
/// Handles the <!-- ... --> header comment, // and /* */ comments, quoted strings
/// with escapes, and barewords (identifiers and numbers).
/// </summary>
internal sealed class Kv3Tokenizer
{
	readonly string src;
	int pos;

	public Kv3Tokenizer( string src )
	{
		this.src = src;
	}

	public List<Kv3Token> Tokenize()
	{
		var tokens = new List<Kv3Token>();

		while ( true )
		{
			SkipTrivia();
			if ( pos >= src.Length )
			{
				tokens.Add( new Kv3Token( Kv3TokenType.End, pos, pos, "" ) );
				break;
			}

			int start = pos;
			char c = src[pos];

			switch ( c )
			{
				case '{':
					pos++;
					tokens.Add( new Kv3Token( Kv3TokenType.LBrace, start, pos, "{" ) );
					break;
				case '}':
					pos++;
					tokens.Add( new Kv3Token( Kv3TokenType.RBrace, start, pos, "}" ) );
					break;
				case '[':
					pos++;
					tokens.Add( new Kv3Token( Kv3TokenType.LBracket, start, pos, "[" ) );
					break;
				case ']':
					pos++;
					tokens.Add( new Kv3Token( Kv3TokenType.RBracket, start, pos, "]" ) );
					break;
				case '=':
					pos++;
					tokens.Add( new Kv3Token( Kv3TokenType.Equals, start, pos, "=" ) );
					break;
				case ',':
					pos++;
					tokens.Add( new Kv3Token( Kv3TokenType.Comma, start, pos, "," ) );
					break;
				case '"':
					ReadString( tokens, start );
					break;
				default:
					ReadBareword( tokens, start );
					break;
			}
		}

		return tokens;
	}

	void ReadString( List<Kv3Token> tokens, int start )
	{
		pos++; // opening quote
		while ( pos < src.Length && src[pos] != '"' )
		{
			if ( src[pos] == '\\' && pos + 1 < src.Length )
				pos += 2;
			else
				pos++;
		}

		if ( pos < src.Length )
			pos++; // closing quote

		tokens.Add( new Kv3Token( Kv3TokenType.String, start, pos, src.Substring( start, pos - start ) ) );
	}

	void ReadBareword( List<Kv3Token> tokens, int start )
	{
		while ( pos < src.Length )
		{
			char c = src[pos];
			if ( char.IsWhiteSpace( c ) || c is '{' or '}' or '[' or ']' or '=' or ',' )
				break;
			if ( c == '/' && pos + 1 < src.Length && src[pos + 1] is '/' or '*' )
				break;
			if ( c == '<' && pos + 3 < src.Length && src.AsSpan( pos ).StartsWith( "<!--" ) )
				break;
			pos++;
		}

		tokens.Add( new Kv3Token( Kv3TokenType.Ident, start, pos, src.Substring( start, pos - start ) ) );
	}

	void SkipTrivia()
	{
		while ( pos < src.Length )
		{
			char c = src[pos];

			if ( char.IsWhiteSpace( c ) )
			{
				pos++;
				continue;
			}

			if ( c == '/' && pos + 1 < src.Length && src[pos + 1] == '/' )
			{
				pos += 2;
				while ( pos < src.Length && src[pos] != '\n' )
					pos++;
				continue;
			}

			if ( c == '/' && pos + 1 < src.Length && src[pos + 1] == '*' )
			{
				var end = src.IndexOf( "*/", pos + 2, StringComparison.Ordinal );
				pos = end < 0 ? src.Length : end + 2;
				continue;
			}

			if ( c == '<' && pos + 3 < src.Length && src.AsSpan( pos ).StartsWith( "<!--" ) )
			{
				var end = src.IndexOf( "-->", pos, StringComparison.Ordinal );
				pos = end < 0 ? src.Length : end + 3;
				continue;
			}

			break;
		}
	}
}

/// <summary>A parsed node in a KV3 document.</summary>
public abstract class Kv3Node
{
	public int Start;
	public int End;
}

/// <summary>An object - a list of key/value fields.</summary>
public sealed class Kv3Object : Kv3Node
{
	public List<Kv3Field> Fields = new();

	public Kv3Field FindField( string key ) => Fields.FirstOrDefault( f => f.Key == key );

	/// <summary>The value of the "_class" field, used to identify node types in modeldoc files.</summary>
	public string Class => (FindField( "_class" )?.Value as Kv3Scalar)?.Value;
}

public sealed class Kv3Field
{
	public string Key;
	public int KeyStart;
	public int KeyEnd;
	public Kv3Node Value;
}

/// <summary>An array of values.</summary>
public sealed class Kv3Array : Kv3Node
{
	public List<Kv3Node> Items = new();
}

/// <summary>A scalar value - a string, number, or bareword.</summary>
public sealed class Kv3Scalar : Kv3Node
{
	public bool IsString;
	public string Raw;
	public string Value;
}

/// <summary>
/// A parsed KV3 text document. Keeps a reference to the original source so edits
/// can be applied surgically without reformatting the whole file.
/// </summary>
public sealed class Kv3Document
{
	public string Source { get; }
	public Kv3Object Root { get; }

	Kv3Document( string source )
	{
		Source = source;
		Root = Kv3Parser.Parse( source );
	}

	public static Kv3Document Parse( string source ) => new( source );

	/// <summary>
	/// Walk the tree and return every object whose "_class" field matches the given name.
	/// </summary>
	public List<Kv3Object> FindObjects( string className )
	{
		var result = new List<Kv3Object>();
		Walk( Root, result, className );
		return result;
	}

	static void Walk( Kv3Node node, List<Kv3Object> result, string className )
	{
		if ( node is Kv3Object obj )
		{
			if ( obj.Class == className )
				result.Add( obj );

			foreach ( var f in obj.Fields )
				Walk( f.Value, result, className );
		}
		else if ( node is Kv3Array arr )
		{
			foreach ( var item in arr.Items )
				Walk( item, result, className );
		}
	}
}

internal sealed class Kv3Parser
{
	readonly string src;
	readonly List<Kv3Token> tokens;
	int index;

	Kv3Parser( string src, List<Kv3Token> tokens )
	{
		this.src = src;
		this.tokens = tokens;
	}

	public static Kv3Object Parse( string source )
	{
		var tokens = new Kv3Tokenizer( source ).Tokenize();
		var parser = new Kv3Parser( source, tokens );
		return parser.ParseObject();
	}

	Kv3Token Peek => tokens[index];
	Kv3Token Next() => tokens[index++];
	bool AtEnd => Peek.Type == Kv3TokenType.End;

	Kv3Node ParseValue()
	{
		var tok = Peek;
		if ( tok.Type == Kv3TokenType.LBrace )
			return ParseObject();
		if ( tok.Type == Kv3TokenType.LBracket )
			return ParseArray();
		return ParseScalar();
	}

	Kv3Object ParseObject()
	{
		var open = Next(); // {
		var obj = new Kv3Object { Start = open.Start };

		while ( true )
		{
			if ( AtEnd )
				break;

			if ( Peek.Type == Kv3TokenType.RBrace )
			{
				var close = Next();
				obj.End = close.End;
				break;
			}

			if ( Peek.Type == Kv3TokenType.Comma )
			{
				Next();
				continue;
			}

			var keyTok = Peek;
			if ( keyTok.Type is not (Kv3TokenType.String or Kv3TokenType.Ident) )
			{
				Next();
				continue;
			}
			Next(); // consume key

			string key = keyTok.Type == Kv3TokenType.String
				? Unquote( keyTok.Text )
				: keyTok.Text;

			if ( Peek.Type == Kv3TokenType.Equals )
				Next();

			var value = ParseValue();
			obj.Fields.Add( new Kv3Field { Key = key, KeyStart = keyTok.Start, KeyEnd = keyTok.End, Value = value } );
		}

		return obj;
	}

	Kv3Array ParseArray()
	{
		var open = Next(); // [
		var arr = new Kv3Array { Start = open.Start };

		while ( true )
		{
			if ( AtEnd )
				break;

			if ( Peek.Type == Kv3TokenType.RBracket )
			{
				var close = Next();
				arr.End = close.End;
				break;
			}

			if ( Peek.Type == Kv3TokenType.Comma )
			{
				Next();
				continue;
			}

			arr.Items.Add( ParseValue() );
		}

		return arr;
	}

	Kv3Scalar ParseScalar()
	{
		var tok = Next();
		return new Kv3Scalar
		{
			Start = tok.Start,
			End = tok.End,
			IsString = tok.Type == Kv3TokenType.String,
			Raw = tok.Text,
			Value = tok.Type == Kv3TokenType.String ? Unquote( tok.Text ) : tok.Text
		};
	}

	static string Unquote( string s )
	{
		if ( s.Length < 2 || s[0] != '"' )
			return s;

		var inner = s.Substring( 1, s.Length - 2 );
		var sb = new StringBuilder( inner.Length );

		for ( int i = 0; i < inner.Length; i++ )
		{
			if ( inner[i] == '\\' && i + 1 < inner.Length )
			{
				char n = inner[++i];
				switch ( n )
				{
					case 'n':
						sb.Append( '\n' );
						break;
					case 't':
						sb.Append( '\t' );
						break;
					case 'r':
						sb.Append( '\r' );
						break;
					case '"':
						sb.Append( '"' );
						break;
					case '\'':
						sb.Append( '\'' );
						break;
					case '\\':
						sb.Append( '\\' );
						break;
					default:
						sb.Append( n );
						break;
				}
			}
			else
			{
				sb.Append( inner[i] );
			}
		}

		return sb.ToString();
	}
}
bluedock.modelpro / Code/Assembly.cs
Game library
global using Sandbox;
global using System.Collections.Generic;
global using System.Linq;
bluedock.modelpro / Editor/DefaultMaterialModel.cs
Editor library
using System;
using System.ComponentModel;

namespace ModelPro.Editor;

/// <summary>
/// A concrete <see cref="AssetPathAttribute"/> that restricts picking to a single
/// asset type extension. Used so the material field only accepts .vmat files.
/// </summary>
[AttributeUsage( AttributeTargets.Property )]
public class AssetPathTypeAttribute : AssetPathAttribute
{
	readonly string _extension;

	public AssetPathTypeAttribute( string extension )
	{
		_extension = extension;
	}

	public override string AssetTypeExtension => _extension;
}

/// <summary>
/// A tiny serialized object used to render a material picker control. The
/// [AssetPathType("vmat")] attribute makes the editor use its material picker,
/// so only .vmat assets can be selected.
/// </summary>
public class DefaultMaterialModel
{
	/// <summary>The selected default material path, e.g. "materials/default.vmat".</summary>
	[AssetPathType( "vmat" )]
	public string Material { get; set; } = "";
}
bluedock.modelpro / UnitTests/VmdlGeneratorTests.cs
UnitTest library
using System;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ModelPro.Vmdl;

[TestClass]
public class VmdlGeneratorTests
{
	[TestMethod]
	public void GeneratesValidVmdl_HullCollision()
	{
		var options = new MeshToModelOptions
		{
			Collision = CollisionMode.Hull,
			ImportScale = 0.3937f,
			AlignOriginX = AlignOrigin.BoundsCenter,
			AlignOriginY = AlignOrigin.BoundsCenter,
			AlignOriginZ = AlignOrigin.BoundsMin
		};

		var source = VmdlGenerator.Generate( "models/foo.fbx", options );

		// Parseable, finds all nodes.
		var doc = Kv3Document.Parse( source );
		Assert.AreEqual( 1, doc.FindObjects( "RenderMeshFile" ).Count );
		Assert.AreEqual( 1, doc.FindObjects( "PhysicsHullFromRender" ).Count );
		Assert.AreEqual( 1, doc.FindObjects( "RenderMeshList" ).Count );
		Assert.AreEqual( 1, doc.FindObjects( "PhysicsShapeList" ).Count );

		var mesh = doc.FindObjects( "RenderMeshFile" ).Single();
		Assert.AreEqual( "models/foo.fbx", ((Kv3Scalar)mesh.FindField( "filename" ).Value).Value );
		Assert.AreEqual( "0.3937", ((Kv3Scalar)mesh.FindField( "import_scale" ).Value).Value );
		Assert.AreEqual( "BoundsCenter", ((Kv3Scalar)mesh.FindField( "align_origin_x_type" ).Value).Value );
		Assert.AreEqual( "BoundsMin", ((Kv3Scalar)mesh.FindField( "align_origin_z_type" ).Value).Value );

		// Collision must carry the same align origin.
		var hull = doc.FindObjects( "PhysicsHullFromRender" ).Single();
		Assert.AreEqual( "BoundsCenter", ((Kv3Scalar)hull.FindField( "align_origin_x_type" ).Value).Value );
		Assert.AreEqual( "BoundsCenter", ((Kv3Scalar)hull.FindField( "align_origin_y_type" ).Value).Value );
		Assert.AreEqual( "BoundsMin", ((Kv3Scalar)hull.FindField( "align_origin_z_type" ).Value).Value );
	}

	[TestMethod]
	public void GeneratesMeshCollision()
	{
		var options = new MeshToModelOptions
		{
			Collision = CollisionMode.Mesh,
			ImportScale = 1.0f
		};

		var source = VmdlGenerator.Generate( "models/foo.fbx", options );
		var doc = Kv3Document.Parse( source );
		Assert.AreEqual( 1, doc.FindObjects( "PhysicsMeshFromRender" ).Count );
		Assert.AreEqual( 0, doc.FindObjects( "PhysicsHullFromRender" ).Count );
	}

	[TestMethod]
	public void GeneratesFileCollision()
	{
		var options = new MeshToModelOptions
		{
			Collision = CollisionMode.File,
			ImportScale = 0.3937f
		};

		var source = VmdlGenerator.Generate( "models/foo.fbx", options );
		var doc = Kv3Document.Parse( source );
		Assert.AreEqual( 1, doc.FindObjects( "PhysicsHullFile" ).Count );

		var hull = doc.FindObjects( "PhysicsHullFile" ).Single();
		Assert.AreEqual( "models/foo.fbx", ((Kv3Scalar)hull.FindField( "filename" ).Value).Value );
		Assert.AreEqual( "0.3937", ((Kv3Scalar)hull.FindField( "import_scale" ).Value).Value );
	}

	[TestMethod]
	public void NoCollisionWhenNone()
	{
		var options = new MeshToModelOptions
		{
			Collision = CollisionMode.None,
			ImportScale = 1.0f
		};

		var source = VmdlGenerator.Generate( "models/foo.fbx", options );
		var doc = Kv3Document.Parse( source );
		Assert.AreEqual( 0, doc.FindObjects( "PhysicsShapeList" ).Count );
		Assert.AreEqual( 1, doc.FindObjects( "RenderMeshFile" ).Count );
	}

	[TestMethod]
	public void GeneratedVmdlIsEditableByBulkEditor()
	{
		var options = new MeshToModelOptions
		{
			Collision = CollisionMode.Hull,
			ImportScale = 0.3937f,
			AlignOriginX = AlignOrigin.BoundsCenter,
			AlignOriginY = AlignOrigin.BoundsCenter,
			AlignOriginZ = AlignOrigin.BoundsMin
		};

		var source = VmdlGenerator.Generate( "models/foo.fbx", options );

		// The generated file should load in the bulk editor and find its mesh entry.
		var editor = VmdlBulkEditor.Load( source );
		Assert.AreEqual( 1, editor.MeshEntryCount );

		// And a further bulk edit should work on it.
		var props = new MeshEntryProperties { ImportScale = 2.0f };
		Assert.IsTrue( editor.Apply( props ) );

		var parsed = Kv3Document.Parse( editor.Source );
		var mesh = parsed.FindObjects( "RenderMeshFile" ).Single();
		Assert.AreEqual( "2.0", ((Kv3Scalar)mesh.FindField( "import_scale" ).Value).Value );
	}
}
bluedock.modelpro / UnitTests/LibraryTest.cs
UnitTest library
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 );
		}
	}

}
bluedock.modelpro / Editor/ModelProApp.cs
Editor library
using System;

namespace ModelPro.Editor;

/// <summary>
/// Model Pro - an editor app for bulk editing model (.vmdl) properties and
/// converting mesh files (.fbx) into models. Two tabs: VMDL bulk editing and
/// FBX to model conversion.
/// </summary>
[EditorApp( "Model Pro", "view_in_ar", "Bulk edit model properties and convert meshes to models" )]
public class ModelProApp : Window
{
	public ModelProApp()
	{
		WindowTitle = "Model Pro";
		MinimumSize = new Vector2( 800, 500 );
		SetWindowIcon( "view_in_ar" );

		BuildUI();

		Show();
		StateCookie = "ModelPro";
	}

	private void BuildUI()
	{
		Canvas = new Widget( null );
		Canvas.Layout = Layout.Column();
		Canvas.Layout.Margin = 4;

		var tabs = new TabWidget( Canvas );
		tabs.StateCookie = "ModelProTabs";
		Canvas.Layout.Add( tabs, 1 );

		tabs.AddPage( "DMX/FBX/OBJ", "transform", new MeshConvertTab( tabs ) );
		tabs.AddPage( "VMDL", "view_in_ar", new VmdlEditTab( tabs ) );
	}

	[Menu( "Editor", "Model Pro/Open Model Pro" )]
	public static void OpenModelPro()
	{
		var existing = Window.All.OfType<ModelProApp>().FirstOrDefault();
		if ( existing.IsValid() )
		{
			existing.Show();
			existing.Focus();
			return;
		}

		new ModelProApp();
	}
}
bluedock.modelpro / Editor/ModelProSettings.cs
Editor library
using ModelPro.Vmdl;

namespace ModelPro.Editor;

/// <summary>
/// Remembers the last values used in Model Pro so the next time the app is
/// opened the controls are pre-filled. Persisted to the editor config folder.
/// </summary>
public class ModelProSettings
{
	const string Path = "modelpro/modelpro.json";

	// VMDL tab
	public string VmdlScale { get; set; } = "1.0";
	public ScaleUnit VmdlScaleUnit { get; set; } = ScaleUnit.Inches;
	public AlignOrigin VmdlAlignX { get; set; } = AlignOrigin.None;
	public AlignOrigin VmdlAlignY { get; set; } = AlignOrigin.None;
	public AlignOrigin VmdlAlignZ { get; set; } = AlignOrigin.None;
	public string VmdlDefaultMaterial { get; set; } = "";

	// FBX tab
	public CollisionMode FbxCollision { get; set; } = CollisionMode.Hull;
	public string FbxScale { get; set; } = "1.0";
	public ScaleUnit FbxScaleUnit { get; set; } = ScaleUnit.Inches;
	public AlignOrigin FbxAlignX { get; set; } = AlignOrigin.None;
	public AlignOrigin FbxAlignY { get; set; } = AlignOrigin.None;
	public AlignOrigin FbxAlignZ { get; set; } = AlignOrigin.None;

	private static readonly ModelProSettings _current = new();

	/// <summary>The single shared instance, loaded from disk on first access.</summary>
	public static ModelProSettings Current
	{
		get
		{
			if ( _loaded ) return _current;
			_loaded = true;

			try
			{
				global::Editor.FileSystem.Config.CreateDirectory( "modelpro" );
				var loaded = global::Editor.FileSystem.Config.ReadJsonOrDefault( Path, _current );
				if ( loaded is not null )
				{
					// Keep the path the same - only copy values over.
					_current.VmdlScale = loaded.VmdlScale;
					_current.VmdlScaleUnit = loaded.VmdlScaleUnit;
					_current.VmdlAlignX = loaded.VmdlAlignX;
					_current.VmdlAlignY = loaded.VmdlAlignY;
					_current.VmdlAlignZ = loaded.VmdlAlignZ;
					_current.VmdlDefaultMaterial = loaded.VmdlDefaultMaterial;

					_current.FbxCollision = loaded.FbxCollision;
					_current.FbxScale = loaded.FbxScale;
					_current.FbxScaleUnit = loaded.FbxScaleUnit;
					_current.FbxAlignX = loaded.FbxAlignX;
					_current.FbxAlignY = loaded.FbxAlignY;
					_current.FbxAlignZ = loaded.FbxAlignZ;
				}
			}
			catch ( System.Exception e )
			{
				Log.Warning( e, "Model Pro: failed to load settings" );
			}

			return _current;
		}
	}

	static bool _loaded;

	/// <summary>Save the current settings to the editor config folder.</summary>
	public void Save()
	{
		try
		{
			global::Editor.FileSystem.Config.CreateDirectory( "modelpro" );
			global::Editor.FileSystem.Config.WriteJson( Path, _current );
		}
		catch ( System.Exception e )
		{
			Log.Warning( e, "Model Pro: failed to save settings" );
		}
	}
}
bluedock.modelpro / Editor/Vmdl/VmdlBulkEditor.cs
Editor library
using System;
using System.Globalization;
using System.IO;
using System.Text;

namespace ModelPro.Vmdl;

/// <summary>
/// Align origin options for each axis of a RenderMeshFile entry.
/// These match the values used in ModelDoc's align_origin_*_type fields.
/// </summary>
public enum AlignOrigin
{
	None,
	Center,
	Mins,
	Maxs,
	BoundsCenter,
	BoundsMin,
	BoundsMax
}

public static class AlignOriginExtensions
{
	public static string ToKv3Value( this AlignOrigin value )
	{
		return value switch
		{
			AlignOrigin.Center => "Center",
			AlignOrigin.Mins => "Mins",
			AlignOrigin.Maxs => "Maxs",
			AlignOrigin.BoundsCenter => "BoundsCenter",
			AlignOrigin.BoundsMin => "BoundsMin",
			AlignOrigin.BoundsMax => "BoundsMax",
			_ => "None"
		};
	}
}

/// <summary>
/// Collision modes for converting a mesh file into a model, matching the
/// options in the asset browser's create-model popup.
/// </summary>
public enum CollisionMode
{
	/// <summary>A convex hull generated from the render geometry (PhysicsHullFromRender).</summary>
	Hull,

	/// <summary>An exact triangle mesh from the render geometry (PhysicsMeshFromRender).</summary>
	Mesh,

	/// <summary>A hull file that references the source mesh file, with the import scale baked in (PhysicsHullFile).</summary>
	File,

	/// <summary>No collision.</summary>
	None
}

public static class CollisionModeExtensions
{
	public static string ToDisplayString( this CollisionMode mode )
	{
		return mode switch
		{
			CollisionMode.Hull => "Convex Hull",
			CollisionMode.Mesh => "Exact Mesh",
			CollisionMode.File => "File (Hull from FBX)",
			_ => "None"
		};
	}
}

/// <summary>
/// The unit the scale value is specified in, matching the units dropdown in the
/// asset browser's create-model popup. Import scale stored in the vmdl is always
/// in inches, so the entered value is multiplied by the conversion factor.
/// </summary>
public enum ScaleUnit
{
	Inches,
	Feet,
	Meters,
	Centimeters,
	Millimeters,
	Custom
}

public static class ScaleUnitExtensions
{
	/// <summary>How many inches one of these units is. Custom returns 1 - the value is used as-is.</summary>
	public static float ToInches( this ScaleUnit unit )
	{
		return unit switch
		{
			ScaleUnit.Feet => 12.0f,
			ScaleUnit.Meters => 39.3701f,
			ScaleUnit.Centimeters => 0.3937f,
			ScaleUnit.Millimeters => 0.03937f,
			_ => 1.0f
		};
	}

	public static string ToDisplayString( this ScaleUnit unit )
	{
		return unit switch
		{
			ScaleUnit.Feet => "Feet (ft)",
			ScaleUnit.Meters => "Meters (m)",
			ScaleUnit.Centimeters => "Centimeters (cm)",
			ScaleUnit.Millimeters => "Millimeters (mm)",
			ScaleUnit.Custom => "Custom",
			_ => "Inches (in)"
		};
	}
}

/// <summary>
/// The properties that can be bulk-edited on every RenderMeshFile entry
/// in a model's RenderMeshList, plus the model's default material group.
/// </summary>
public readonly struct MeshEntryProperties
{
	/// <summary>The final import scale in inches, already converted from the chosen unit.</summary>
	public float? ImportScale { get; init; }
	public AlignOrigin? AlignOriginX { get; init; }
	public AlignOrigin? AlignOriginY { get; init; }
	public AlignOrigin? AlignOriginZ { get; init; }

	/// <summary>
	/// The global default material for the model's DefaultMaterialGroup
	/// (e.g. "materials/default.vmat"). Null leaves it unchanged.
	/// </summary>
	public string GlobalDefaultMaterial { get; init; }
}

/// <summary>A scale typed in a chosen unit, plus the unit it's in.</summary>
public readonly struct ScaleInput
{
	public float Value { get; init; }
	public ScaleUnit Unit { get; init; }

	/// <summary>
	/// The value is the import_scale multiplier directly - what gets written to the
	/// vmdl. The unit is a display helper: it rescales the number when you switch
	/// units (1 inch shows as 0.3937 in cm, since 1 cm = 0.3937 inches).
	/// </summary>
	public float ToImportScale() => Value;

	/// <summary>
	/// Re-express this scale in another unit so the import scale stays the same.
	/// E.g. 1 inch converts to 0.3937 centimeters (1 cm = 0.3937 inches).
	/// </summary>
	public ScaleInput ConvertTo( ScaleUnit newUnit )
	{
		if ( newUnit == Unit )
			return this;

		var converted = Value * newUnit.ToInches() / Unit.ToInches();
		return new ScaleInput { Value = converted, Unit = newUnit };
	}

	/// <summary>
	/// Convert a raw import scale into a display value for a given unit.
	/// </summary>
	public static ScaleInput FromImportScale( float importScale, ScaleUnit unit )
	{
		return new ScaleInput { Value = importScale, Unit = unit };
	}
}

/// <summary>
/// Loads a .vmdl source file, finds all RenderMeshFile mesh entries and applies
/// bulk property edits to them, writing the result back while preserving the
/// original file formatting.
/// </summary>
public sealed class VmdlBulkEditor
{
	readonly Kv3Document _document;
	readonly List<Kv3Object> _meshEntries;
	string _source;

	public string Source => _source;

	public int MeshEntryCount => _meshEntries.Count;

	public bool HasRenderMeshList { get; }

	private VmdlBulkEditor( string source )
	{
		_source = source;
		_document = Kv3Document.Parse( source );

		HasRenderMeshList = _document.FindObjects( "RenderMeshList" ).Count > 0;
		_meshEntries = _document.FindObjects( "RenderMeshFile" );
	}

	public static VmdlBulkEditor Load( string source ) => new( source );

	public static VmdlBulkEditor LoadFile( string path )
	{
		return new VmdlBulkEditor( File.ReadAllText( path ) );
	}

	/// <summary>
	/// Apply the given properties to every mesh entry. Only properties that are
	/// non-null are changed. Returns true if anything was actually modified.
	/// </summary>
	public bool Apply( MeshEntryProperties props )
	{
		var edits = new List<(int Start, int End, string Text)>();

		foreach ( var entry in _meshEntries )
		{
			if ( props.ImportScale.HasValue )
			{
				SetField( entry, "import_scale", FormatNumber( props.ImportScale.Value ), edits );
			}

			if ( props.AlignOriginX.HasValue )
				SetField( entry, "align_origin_x_type", Quote( props.AlignOriginX.Value.ToKv3Value() ), edits );

			if ( props.AlignOriginY.HasValue )
				SetField( entry, "align_origin_y_type", Quote( props.AlignOriginY.Value.ToKv3Value() ), edits );

			if ( props.AlignOriginZ.HasValue )
				SetField( entry, "align_origin_z_type", Quote( props.AlignOriginZ.Value.ToKv3Value() ), edits );
		}

		// Align origin also needs to be set on the collision shapes so they stay
		// aligned with the render geometry - the physics nodes share the same
		// align_origin_*_type fields.
		if ( props.AlignOriginX.HasValue || props.AlignOriginY.HasValue || props.AlignOriginZ.HasValue )
		{
			foreach ( var shape in _document.FindObjects( "PhysicsHullFromRender" ) )
				ApplyAlignOrigin( shape, props, edits );
			foreach ( var shape in _document.FindObjects( "PhysicsMeshFromRender" ) )
				ApplyAlignOrigin( shape, props, edits );
			foreach ( var shape in _document.FindObjects( "PhysicsHullFile" ) )
				ApplyAlignOrigin( shape, props, edits );
			foreach ( var shape in _document.FindObjects( "PhysicsMeshFile" ) )
				ApplyAlignOrigin( shape, props, edits );
		}

		if ( props.GlobalDefaultMaterial is not null )
		{
			foreach ( var group in _document.FindObjects( "DefaultMaterialGroup" ) )
			{
				SetField( group, "global_default_material", Quote( props.GlobalDefaultMaterial ), edits );
				SetField( group, "use_global_default", "true", edits );
			}
		}

		if ( edits.Count == 0 )
			return false;

		var sb = new StringBuilder( _source );
		foreach ( var e in edits.OrderByDescending( x => x.Start ) )
		{
			sb.Remove( e.Start, e.End - e.Start );
			sb.Insert( e.Start, e.Text );
		}

		_source = sb.ToString();
		return true;
	}

	private void ApplyAlignOrigin( Kv3Object shape, MeshEntryProperties props, List<(int Start, int End, string Text)> edits )
	{
		if ( props.AlignOriginX.HasValue )
			SetField( shape, "align_origin_x_type", Quote( props.AlignOriginX.Value.ToKv3Value() ), edits );

		if ( props.AlignOriginY.HasValue )
			SetField( shape, "align_origin_y_type", Quote( props.AlignOriginY.Value.ToKv3Value() ), edits );

		if ( props.AlignOriginZ.HasValue )
			SetField( shape, "align_origin_z_type", Quote( props.AlignOriginZ.Value.ToKv3Value() ), edits );
	}

	/// <summary>Returns the current state of a mesh entry's editable properties (from the first entry).</summary>
	public MeshEntryProperties ReadFirstEntryProperties()
	{
		var entry = _meshEntries.FirstOrDefault();
		if ( entry is null )
			return default;

		return new MeshEntryProperties
		{
			ImportScale = ReadFloat( entry, "import_scale" ),
			AlignOriginX = ReadAlign( entry, "align_origin_x_type" ),
			AlignOriginY = ReadAlign( entry, "align_origin_y_type" ),
			AlignOriginZ = ReadAlign( entry, "align_origin_z_type" ),
			GlobalDefaultMaterial = ReadDefaultMaterial()
		};
	}

	/// <summary>The global default material path of the first DefaultMaterialGroup, or null.</summary>
	public string ReadDefaultMaterial()
	{
		var group = _document.FindObjects( "DefaultMaterialGroup" ).FirstOrDefault();
		if ( group is null )
			return null;

		return (group.FindField( "global_default_material" )?.Value as Kv3Scalar)?.Value;
	}

	private void SetField( Kv3Object obj, string key, string valueText, List<(int Start, int End, string Text)> edits )
	{
		var field = obj.FindField( key );

		if ( field?.Value is Kv3Scalar scalar )
		{
			if ( scalar.Raw != valueText )
				edits.Add( (scalar.Start, scalar.End, valueText) );
			return;
		}

		// Field doesn't exist - insert it right after the _class line.
		var classField = obj.FindField( "_class" );
		if ( classField?.Value is not Kv3Scalar classScalar )
			return;

		int lineEnd = _source.IndexOf( '\n', classScalar.End );
		if ( lineEnd < 0 )
			lineEnd = _source.Length;

		int lineStart = _source.LastIndexOf( '\n', Math.Max( 0, classField.KeyStart - 1 ) ) + 1;
		var indent = _source.Substring( lineStart, classField.KeyStart - lineStart );

		string insert = "\n" + indent + key + " = " + valueText;
		edits.Add( (lineEnd, lineEnd, insert) );
	}

	private static float? ReadFloat( Kv3Object obj, string key )
	{
		if ( obj.FindField( key )?.Value is Kv3Scalar s &&
			float.TryParse( s.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) )
		{
			return value;
		}

		return null;
	}

	private static AlignOrigin? ReadAlign( Kv3Object obj, string key )
	{
		if ( obj.FindField( key )?.Value is Kv3Scalar s )
		{
			return s.Value switch
			{
				"Center" => AlignOrigin.Center,
				"Mins" => AlignOrigin.Mins,
				"Maxs" => AlignOrigin.Maxs,
				"BoundsCenter" => AlignOrigin.BoundsCenter,
				"BoundsMin" => AlignOrigin.BoundsMin,
				"BoundsMax" => AlignOrigin.BoundsMax,
				_ => AlignOrigin.None
			};
		}

		return null;
	}

	private static string FormatNumber( float value )
	{
		return value.ToString( "0.0########", CultureInfo.InvariantCulture );
	}

	private static string Quote( string value ) => $"\"{value}\"";
}
bluedock.modelpro / Editor/Assembly.cs
Editor library
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
bluedock.modelpro / Editor/MeshConvertTab.cs
Editor library
using System;
using System.Globalization;
using System.IO;
using ModelPro.Vmdl;

namespace ModelPro.Editor;

/// <summary>
/// The DMX/FBX/OBJ tab of Model Pro. Pick a folder, it recursively finds every
/// mesh file (.fbx, .obj, .dmx) and converts them all into .vmdl models with the
/// chosen collision, scale and align-origin settings.
/// </summary>
public class MeshConvertTab : Widget
{
	TreeView _folderTree;
	TreeView _fileList;
	Label _statusLabel;

	ComboBox _collision;
	LineEdit _scaleEdit;
	ComboBox _scaleUnit;
	ScaleUnit _lastScaleUnit = ScaleUnit.Inches;
	ComboBox _alignX;
	ComboBox _alignY;
	ComboBox _alignZ;
	Button _convertButton;
	Label _resultLabel;

	List<string> _foundMeshFiles = new();

	public MeshConvertTab( Widget parent ) : base( parent )
	{
		BuildUI();
		LoadSettings();
	}

	private void BuildUI()
	{
		Layout = Layout.Column();
		Layout.Spacing = 4;

		var split = Layout.AddRow();
		split.Spacing = 8;

		var left = split.AddColumn();
		left.Spacing = 4;
		left.Add( new Label( "<b>Folder</b>" ) );

		var treeScroll = left.Add( new ScrollArea( this ), 1 );
		treeScroll.Canvas = new Widget();
		treeScroll.Canvas.Layout = Layout.Column();
		treeScroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 2 );

		_folderTree = new TreeView( treeScroll.Canvas );
		treeScroll.Canvas.Layout.Add( _folderTree );
		_folderTree.ExpandForSelection = true;

		var rootDir = Sandbox.Project.Current?.RootDirectory;
		if ( rootDir is not null )
		{
			var root = new FolderNode( rootDir.FullName, OnFolderSelected );
			_folderTree.AddItem( root );
			_folderTree.Open( root );
		}

		var right = split.AddColumn();
		right.Spacing = 4;

		_statusLabel = right.Add( new Label( "Select a folder to search for mesh files (.fbx, .obj, .dmx)." ) );
		_statusLabel.WordWrap = true;

		var listScroll = right.Add( new ScrollArea( this ), 1 );
		listScroll.Canvas = new Widget();
		listScroll.Canvas.Layout = Layout.Column();
		listScroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 2 );

		_fileList = new TreeView( listScroll.Canvas );
		listScroll.Canvas.Layout.Add( _fileList );

		var group = right.Add( new Widget() );
		var grid = Layout.Grid();
		grid.Spacing = 4;
		group.Layout = grid;

		int row = 0;

		grid.AddCell( 0, row, new Label( "Collision" ) );
		_collision = new ComboBox() { FixedWidth = 150 };
		foreach ( var mode in Enum.GetValues<CollisionMode>() )
			_collision.AddItem( mode.ToDisplayString() );
		_collision.TrySelectNamed( CollisionMode.Hull.ToDisplayString() );
		grid.AddCell( 1, row, _collision );
		row++;

		grid.AddCell( 0, row, new Label( "Scale" ) );

		var scaleRow = Layout.Row();
		scaleRow.Spacing = 4;
		_scaleEdit = new LineEdit() { Text = "1.0", FixedWidth = 90 };
		_scaleUnit = new ComboBox() { FixedWidth = 130 };
		foreach ( var unit in Enum.GetValues<ScaleUnit>() )
		{
			var u = unit;
			_scaleUnit.AddItem( u.ToDisplayString(), onSelected: () => OnScaleUnitChanged( u ) );
		}
		_scaleUnit.TrySelectNamed( ScaleUnit.Inches.ToDisplayString() );
		scaleRow.Add( _scaleEdit );
		scaleRow.Add( _scaleUnit );
		grid.AddCell( 1, row, scaleRow );
		row++;

		grid.AddCell( 0, row, new Label( "Align Origin X" ) );
		_alignX = CreateAlignCombo();
		grid.AddCell( 1, row, _alignX );
		row++;

		grid.AddCell( 0, row, new Label( "Align Origin Y" ) );
		_alignY = CreateAlignCombo();
		grid.AddCell( 1, row, _alignY );
		row++;

		grid.AddCell( 0, row, new Label( "Align Origin Z" ) );
		_alignZ = CreateAlignCombo();
		grid.AddCell( 1, row, _alignZ );
		row++;

		_convertButton = new Button( "Convert to N models", "transform" );
		_convertButton.Clicked += ConvertAll;
		grid.AddCell( 0, row, _convertButton );
		row++;

		_resultLabel = new Label( "" );
		_resultLabel.WordWrap = true;
		right.Add( _resultLabel );
	}

	private ComboBox CreateAlignCombo()
	{
		var combo = new ComboBox() { FixedWidth = 120 };
		combo.AddItem( "None" );
		combo.AddItem( "BoundsCenter" );
		combo.AddItem( "BoundsMin" );
		combo.AddItem( "BoundsMax" );
		combo.TrySelectNamed( "None" );
		return combo;
	}

	/// <summary>Restore the last used values into the controls.</summary>
	private void LoadSettings()
	{
		var s = ModelProSettings.Current;

		_collision.TrySelectNamed( s.FbxCollision.ToDisplayString() );
		_scaleEdit.Text = s.FbxScale;
		_lastScaleUnit = s.FbxScaleUnit;
		_scaleUnit.TrySelectNamed( s.FbxScaleUnit.ToDisplayString() );
		_alignX.TrySelectNamed( s.FbxAlignX.ToKv3Value() );
		_alignY.TrySelectNamed( s.FbxAlignY.ToKv3Value() );
		_alignZ.TrySelectNamed( s.FbxAlignZ.ToKv3Value() );
	}

	/// <summary>Remember the current values for next time.</summary>
	private void SaveSettings()
	{
		var s = ModelProSettings.Current;
		s.FbxCollision = ParseCollision( _collision.CurrentText );
		s.FbxScale = _scaleEdit.Text;
		s.FbxScaleUnit = ParseScaleUnit( _scaleUnit.CurrentText );
		s.FbxAlignX = ParseAlign( _alignX.CurrentText );
		s.FbxAlignY = ParseAlign( _alignY.CurrentText );
		s.FbxAlignZ = ParseAlign( _alignZ.CurrentText );
		s.Save();
	}

	private void OnFolderSelected( string folder )
	{
		_statusLabel.Text = $"Searching <b>{folder}</b>...";
		_resultLabel.Text = "";

		_foundMeshFiles = Directory.GetFiles( folder, "*.fbx", SearchOption.AllDirectories )
			.Concat( Directory.GetFiles( folder, "*.obj", SearchOption.AllDirectories ) )
			.Concat( Directory.GetFiles( folder, "*.dmx", SearchOption.AllDirectories ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
			.ToList();

		_fileList.Clear();
		foreach ( var file in _foundMeshFiles )
			_fileList.AddItem( new FileNode( file, 0 ) );

		_statusLabel.Text = $"Found <b>{_foundMeshFiles.Count}</b> mesh files in <b>{folder}</b>.";

		_convertButton.Text = _foundMeshFiles.Count == 0
			? "Convert to 0 models"
			: $"Convert to {_foundMeshFiles.Count} models";
	}

	private void ConvertAll()
	{
		if ( _foundMeshFiles.Count == 0 )
			return;

		var options = new MeshToModelOptions
		{
			Collision = ParseCollision( _collision.CurrentText ),
			ImportScale = ReadScale() ?? 1.0f,
			AlignOriginX = ParseAlign( _alignX.CurrentText ),
			AlignOriginY = ParseAlign( _alignY.CurrentText ),
			AlignOriginZ = ParseAlign( _alignZ.CurrentText )
		};

		var assetsRoot = Sandbox.Project.Current?.GetAssetsPath();
		if ( string.IsNullOrEmpty( assetsRoot ) )
			return;

		int created = 0;
		int skipped = 0;
		var errors = new List<string>();

		foreach ( var mesh in _foundMeshFiles )
		{
			try
			{
				var vmdlPath = Path.ChangeExtension( mesh, ".vmdl" );

				// Don't overwrite an existing model.
				if ( File.Exists( vmdlPath ) )
				{
					skipped++;
					continue;
				}

				var relative = mesh.Replace( '\\', '/' );
				var assetsRootNorm = assetsRoot.Replace( '\\', '/' );
				if ( relative.StartsWith( assetsRootNorm, StringComparison.OrdinalIgnoreCase ) )
					relative = relative.Substring( assetsRootNorm.Length ).TrimStart( '/' );

				var source = VmdlGenerator.Generate( relative, options );
				File.WriteAllText( vmdlPath, source );

				RegisterAndCompile( vmdlPath );
				created++;
			}
			catch ( Exception e )
			{
				errors.Add( $"{Path.GetFileName( mesh )}: {e.Message}" );
			}
		}

		var msg = $"Created <b>{created}</b> models" +
			(skipped > 0 ? $" ({skipped} skipped — already exist)" : "") + ".";
		if ( errors.Count > 0 )
			msg += $"\n\nErrors ({errors.Count}):\n{string.Join( "\n", errors.Take( 8 ) )}";

		_resultLabel.Text = msg;

		SaveSettings();
	}

	private static void RegisterAndCompile( string vmdlPath )
	{
		var asset = AssetSystem.RegisterFile( vmdlPath );
		if ( asset is null )
		{
			Log.Warning( $"Model Pro: failed to register {vmdlPath}" );
			return;
		}

		asset.Compile( true );
	}

	private float? ReadScale()
	{
		if ( !float.TryParse( _scaleEdit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) )
			return null;

		var unit = ParseScaleUnit( _scaleUnit.CurrentText );
		return new ScaleInput { Value = value, Unit = unit }.ToImportScale();
	}

	private void OnScaleUnitChanged( ScaleUnit newUnit )
	{
		if ( newUnit == _lastScaleUnit )
			return;

		if ( float.TryParse( _scaleEdit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var current ) )
		{
			var converted = new ScaleInput { Value = current, Unit = _lastScaleUnit }.ConvertTo( newUnit );
			_scaleEdit.Text = converted.Value.ToString( "0.0########", CultureInfo.InvariantCulture );
		}

		_lastScaleUnit = newUnit;
	}

	private static ScaleUnit ParseScaleUnit( string text )
	{
		foreach ( var unit in Enum.GetValues<ScaleUnit>() )
		{
			if ( string.Equals( unit.ToDisplayString(), text, StringComparison.OrdinalIgnoreCase ) )
				return unit;
		}

		return ScaleUnit.Inches;
	}

	private static CollisionMode ParseCollision( string text )
	{
		foreach ( var mode in Enum.GetValues<CollisionMode>() )
		{
			if ( string.Equals( mode.ToDisplayString(), text, StringComparison.OrdinalIgnoreCase ) )
				return mode;
		}

		return CollisionMode.Hull;
	}

	private static AlignOrigin ParseAlign( string text )
	{
		return text switch
		{
			"BoundsCenter" => AlignOrigin.BoundsCenter,
			"BoundsMin" => AlignOrigin.BoundsMin,
			"BoundsMax" => AlignOrigin.BoundsMax,
			"Center" => AlignOrigin.Center,
			"Mins" => AlignOrigin.Mins,
			"Maxs" => AlignOrigin.Maxs,
			_ => AlignOrigin.None
		};
	}
}
bluedock.modelpro / UnitTests/AlignOriginTests.cs
UnitTest library
using System;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ModelPro.Vmdl;

[TestClass]
public class AlignOriginTests
{
	const string ToolFile = """
	{
		rootNode = 
		{
			_class = "RootNode"
			children = 
			[
				{
					_class = "RenderMeshList"
					children = 
					[
						{
							_class = "RenderMeshFile"
							filename = "models/vehicle_01_a.fbx"
							import_scale = 1.0
							align_origin_x_type = "Center"
							align_origin_y_type = "Center"
							align_origin_z_type = "Mins"
						},
					]
				},
			]
		}
	}
	""";

	[TestMethod]
	public void AllAlignOriginsRoundTrip()
	{
		foreach ( var value in Enum.GetValues<AlignOrigin>() )
		{
			var kv3 = value.ToKv3Value();

			var editor = VmdlBulkEditor.Load( ToolFile );
			var props = new MeshEntryProperties
			{
				AlignOriginX = value,
				AlignOriginY = value,
				AlignOriginZ = value
			};

			Assert.IsTrue( editor.Apply( props ), $"{value} should change the file" );

			var parsed = Kv3Document.Parse( editor.Source );
			var entry = parsed.FindObjects( "RenderMeshFile" ).Single();
			Assert.AreEqual( kv3, ((Kv3Scalar)entry.FindField( "align_origin_x_type" ).Value).Value, value.ToString() );
		}
	}

	[TestMethod]
	public void BoundsValuesAreWritable()
	{
		var editor = VmdlBulkEditor.Load( ToolFile );
		var props = new MeshEntryProperties
		{
			ImportScale = 0.3937f,
			AlignOriginX = AlignOrigin.BoundsCenter,
			AlignOriginY = AlignOrigin.BoundsCenter,
			AlignOriginZ = AlignOrigin.BoundsMin
		};

		Assert.IsTrue( editor.Apply( props ), "Bounds* align values should apply" );

		var parsed = Kv3Document.Parse( editor.Source );
		var entry = parsed.FindObjects( "RenderMeshFile" ).Single();
		Assert.AreEqual( "0.3937", ((Kv3Scalar)entry.FindField( "import_scale" ).Value).Value );
		Assert.AreEqual( "BoundsCenter", ((Kv3Scalar)entry.FindField( "align_origin_x_type" ).Value).Value );
		Assert.AreEqual( "BoundsCenter", ((Kv3Scalar)entry.FindField( "align_origin_y_type" ).Value).Value );
		Assert.AreEqual( "BoundsMin", ((Kv3Scalar)entry.FindField( "align_origin_z_type" ).Value).Value );
	}
}
bluedock.modelpro / UnitTests/UnitTest.cs
UnitTest library
global using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class TestInit
{
	public static Sandbox.TestAppSystem AppSystem;

	[AssemblyInitialize]
	public static void AssemblyInitialize( TestContext context )
	{
		AppSystem = new Sandbox.TestAppSystem();
		AppSystem.Init();
	}

	[AssemblyCleanup]
	public static void AssemblyCleanup()
	{
		AppSystem.Shutdown();
	}
}
bluedock.modelpro / Editor/Vmdl/VmdlGenerator.cs
Editor library
using System.Globalization;
using System.Text;

namespace ModelPro.Vmdl;

/// <summary>
/// Options for converting a mesh file (.fbx, .obj, .dmx) into a model (.vmdl),
/// matching the asset browser's create-model popup.
/// </summary>
public struct MeshToModelOptions
{
	/// <summary>The collision shape to generate for the model.</summary>
	public CollisionMode Collision { get; set; }

	/// <summary>The import scale (in inches) to apply to the mesh.</summary>
	public float ImportScale { get; set; }

	public AlignOrigin AlignOriginX { get; set; }
	public AlignOrigin AlignOriginY { get; set; }
	public AlignOrigin AlignOriginZ { get; set; }
}

/// <summary>
/// Generates the KV3 text of a model (.vmdl) file from a source mesh file,
/// writing the same structure ModelDoc produces so it compiles cleanly.
/// </summary>
public static class VmdlGenerator
{
	const string Header = "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->";

	/// <summary>
	/// Build the vmdl source text for a mesh file at the given content-relative
	/// path (e.g. "models/foo.fbx").
	/// </summary>
	public static string Generate( string meshRelativePath, MeshToModelOptions options )
	{
		var sb = new StringBuilder();

		sb.AppendLine( Header );
		sb.AppendLine( "{" );
		sb.AppendLine( "\trootNode = " );
		sb.AppendLine( "\t{" );
		sb.AppendLine( "\t\t_class = \"RootNode\"" );
		sb.AppendLine( "\t\tchildren = " );
		sb.AppendLine( "\t\t[" );

		// Material group
		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"MaterialGroupList\"" );
		sb.AppendLine( "\t\t\t\tchildren = " );
		sb.AppendLine( "\t\t\t\t[" );
		sb.AppendLine( "\t\t\t\t\t{" );
		sb.AppendLine( "\t\t\t\t\t\t_class = \"DefaultMaterialGroup\"" );
		sb.AppendLine( "\t\t\t\t\t\tremaps = [  ]" );
		sb.AppendLine( "\t\t\t\t\t\tuse_global_default = true" );
		sb.AppendLine( "\t\t\t\t\t\tglobal_default_material = \"materials/default.vmat\"" );
		sb.AppendLine( "\t\t\t\t\t}," );
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );

		// Physics shape list
		AppendPhysicsShapeList( sb, meshRelativePath, options );

		// Render mesh list
		AppendRenderMeshList( sb, meshRelativePath, options );

		sb.AppendLine( "\t\t]" );
		sb.AppendLine( "\t\tmodel_archetype = \"\"" );
		sb.AppendLine( "\t\tprimary_associated_entity = \"\"" );
		sb.AppendLine( "\t\tanim_graph_name = \"\"" );
		sb.AppendLine( "\t\tbase_model_name = \"\"" );
		sb.AppendLine( "\t}" );
		sb.AppendLine( "}" );

		return sb.ToString();
	}

	private static void AppendRenderMeshList( StringBuilder sb, string meshRelativePath, MeshToModelOptions options )
	{
		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"RenderMeshList\"" );
		sb.AppendLine( "\t\t\t\tchildren = " );
		sb.AppendLine( "\t\t\t\t[" );
		sb.AppendLine( "\t\t\t\t\t{" );
		sb.AppendLine( "\t\t\t\t\t\t_class = \"RenderMeshFile\"" );
		sb.AppendLine( $"\t\t\t\t\t\tfilename = \"{meshRelativePath}\"" );
		sb.AppendLine( "\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]" );
		sb.AppendLine( "\t\t\t\t\t\timport_rotation = [ 0.0, 0.0, 0.0 ]" );
		sb.AppendLine( $"\t\t\t\t\t\timport_scale = {FormatNumber( options.ImportScale )}" );
		sb.AppendLine( $"\t\t\t\t\t\talign_origin_x_type = \"{options.AlignOriginX.ToKv3Value()}\"" );
		sb.AppendLine( $"\t\t\t\t\t\talign_origin_y_type = \"{options.AlignOriginY.ToKv3Value()}\"" );
		sb.AppendLine( $"\t\t\t\t\t\talign_origin_z_type = \"{options.AlignOriginZ.ToKv3Value()}\"" );
		sb.AppendLine( "\t\t\t\t\t\tparent_bone = \"\"" );
		sb.AppendLine( "\t\t\t\t\t\timport_filter = " );
		sb.AppendLine( "\t\t\t\t\t\t{" );
		sb.AppendLine( "\t\t\t\t\t\t\texclude_by_default = false" );
		sb.AppendLine( "\t\t\t\t\t\t}" );
		sb.AppendLine( "\t\t\t\t\t}," );
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );
	}

	private static void AppendPhysicsShapeList( StringBuilder sb, string meshRelativePath, MeshToModelOptions options )
	{
		if ( options.Collision == CollisionMode.None )
			return;

		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"PhysicsShapeList\"" );
		sb.AppendLine( "\t\t\t\tchildren = " );
		sb.AppendLine( "\t\t\t\t[" );
		sb.AppendLine( "\t\t\t\t\t{" );

		switch ( options.Collision )
		{
			case CollisionMode.Mesh:
				sb.AppendLine( "\t\t\t\t\t\t_class = \"PhysicsMeshFromRender\"" );
				sb.AppendLine( "\t\t\t\t\t\tparent_bone = \"\"" );
				sb.AppendLine( "\t\t\t\t\t\tsurface_prop = \"default\"" );
				sb.AppendLine( "\t\t\t\t\t\tcollision_tags = \"solid\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_x_type = \"{options.AlignOriginX.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_y_type = \"{options.AlignOriginY.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_z_type = \"{options.AlignOriginZ.ToKv3Value()}\"" );
				break;

			case CollisionMode.File:
				sb.AppendLine( "\t\t\t\t\t\t_class = \"PhysicsHullFile\"" );
				sb.AppendLine( $"\t\t\t\t\t\tfilename = \"{meshRelativePath}\"" );
				sb.AppendLine( $"\t\t\t\t\t\timport_scale = {FormatNumber( options.ImportScale )}" );
				sb.AppendLine( "\t\t\t\t\t\tparent_bone = \"\"" );
				sb.AppendLine( "\t\t\t\t\t\tsurface_prop = \"default\"" );
				sb.AppendLine( "\t\t\t\t\t\tcollision_tags = \"solid\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_x_type = \"{options.AlignOriginX.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_y_type = \"{options.AlignOriginY.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_z_type = \"{options.AlignOriginZ.ToKv3Value()}\"" );
				sb.AppendLine( "\t\t\t\t\t\tfaceMergeAngle = 20.0" );
				sb.AppendLine( "\t\t\t\t\t\tmaxHullVertices = 32" );
				break;

			case CollisionMode.Hull:
			default:
				sb.AppendLine( "\t\t\t\t\t\t_class = \"PhysicsHullFromRender\"" );
				sb.AppendLine( "\t\t\t\t\t\tparent_bone = \"\"" );
				sb.AppendLine( "\t\t\t\t\t\tsurface_prop = \"default\"" );
				sb.AppendLine( "\t\t\t\t\t\tcollision_tags = \"solid\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_x_type = \"{options.AlignOriginX.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_y_type = \"{options.AlignOriginY.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_z_type = \"{options.AlignOriginZ.ToKv3Value()}\"" );
				sb.AppendLine( "\t\t\t\t\t\tfaceMergeAngle = 20.0" );
				sb.AppendLine( "\t\t\t\t\t\tmaxHullVertices = 32" );
				sb.AppendLine( "\t\t\t\t\t\thull_mode = \"HullPerElement\"" );
				break;
		}

		sb.AppendLine( "\t\t\t\t\t}," );
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );
	}

	private static string FormatNumber( float value )
	{
		return value.ToString( "0.0########", CultureInfo.InvariantCulture );
	}
}
bluedock.modelpro / Editor/FileNodes.cs
Editor library
using System;
using System.IO;

namespace ModelPro.Editor;

/// <summary>A tree node representing a folder on disk.</summary>
public class FolderNode : TreeNode<DirectoryInfo>
{
	public string FullPath { get; }
	Action<string> _onSelected;

	public override string Name => System.IO.Path.GetFileName( FullPath );

	public FolderNode( string fullPath, Action<string> onSelected ) : base( new DirectoryInfo( fullPath ) )
	{
		FullPath = fullPath;
		_onSelected = onSelected;
		Height = Theme.RowHeight;
	}

	protected override void BuildChildren()
	{
		Clear();

		foreach ( var dir in Directory.GetDirectories( FullPath )
					 .OrderBy( x => x, StringComparer.OrdinalIgnoreCase ) )
		{
			AddItem( new FolderNode( dir, _onSelected ) );
		}
	}

	public override void OnSelectionChanged( bool state )
	{
		if ( state )
			_onSelected?.Invoke( FullPath );
	}

	public override void OnPaint( VirtualWidget item )
	{
		PaintSelection( item );

		var rect = item.Rect;

		Paint.SetPen( Theme.Yellow );
		Paint.DrawIcon( rect, "folder", 18, TextFlag.LeftCenter );

		rect.Left += 24;
		Paint.SetPen( Theme.Text );
		Paint.SetDefaultFont();
		Paint.DrawText( rect, Name, TextFlag.LeftCenter );
	}

	public override string GetTooltip()
	{
		return FullPath;
	}
}

/// <summary>A tree node representing a file.</summary>
public class FileNode : TreeNode<string>
{
	public string FullPath { get; }
	public int MeshEntryCount { get; }

	public override string Name => System.IO.Path.GetFileName( FullPath );

	public FileNode( string fullPath, int meshEntryCount ) : base( fullPath )
	{
		FullPath = fullPath;
		MeshEntryCount = meshEntryCount;
		Height = Theme.RowHeight;
	}

	public override void OnPaint( VirtualWidget item )
	{
		PaintSelection( item );

		var rect = item.Rect;

		Paint.SetPen( Theme.Text );
		Paint.SetDefaultFont();
		Paint.DrawText( rect, MeshEntryCount > 0 ? $"{Name}  ({MeshEntryCount} meshes)" : Name, TextFlag.LeftCenter );
	}

	public override string GetTooltip()
	{
		return FullPath;
	}
}
bluedock.modelpro / .obj/__compiler_extra.cs
Game library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Model Pro" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "modelpro" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "bluedock" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "bluedock.modelpro" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[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-08-09T08:00:58.3436594Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.113.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.113.0")]
bluedock.modelpro / UnitTests/Kv3Tests.cs
UnitTest library
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ModelPro.Vmdl;

[TestClass]
public class Kv3Tests
{
	const string SampleVmdl = """
	<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->
	{
		rootNode = 
		{
			_class = "RootNode"
			children = 
			[
				{
					_class = "RenderMeshList"
					children = 
					[
						{
							_class = "RenderMeshFile"
							name = "Torso_LOD0"
							children = 
							[
								{
									_class = "RenderMeshMarkup"
									use_expensive_tangents = true
								},
							]
							filename = "models/citizen/citizen.fbx"
							import_translation = [ 0.0, 0.0, 0.0 ]
							import_rotation = [ 0.0, 0.0, 0.0 ]
							import_scale = 1.0
							align_origin_x_type = "None"
							align_origin_y_type = "None"
							align_origin_z_type = "None"
							parent_bone = ""
							import_filter = 
							{
								exclude_by_default = true
								exception_list = 
								[
									"CitizenTorso_LOD0",
								]
							}
						},
						{
							_class = "RenderMeshFile"
							name = "Arms_LOD0"
							filename = "models/citizen/citizen_arms.fbx"
							import_scale = 1.0
							align_origin_x_type = "None"
							align_origin_y_type = "None"
							align_origin_z_type = "None"
						},
					]
				},
			]
			model_archetype = ""
		}
	}
	""";

	[TestMethod]
	public void FindsMeshEntries()
	{
		var doc = Kv3Document.Parse( SampleVmdl );
		Assert.AreEqual( 2, doc.FindObjects( "RenderMeshFile" ).Count );
		Assert.AreEqual( 1, doc.FindObjects( "RenderMeshList" ).Count );
	}

	[TestMethod]
	public void AppliesScaleAndAlign()
	{
		var editor = VmdlBulkEditor.Load( SampleVmdl );
		Assert.AreEqual( 2, editor.MeshEntryCount );

		var props = new MeshEntryProperties
		{
			ImportScale = 0.5f,
			AlignOriginX = AlignOrigin.Center,
			AlignOriginY = AlignOrigin.Mins,
			AlignOriginZ = AlignOrigin.Maxs
		};

		Assert.IsTrue( editor.Apply( props ) );

		var result = Kv3Document.Parse( editor.Source );
		var entries = result.FindObjects( "RenderMeshFile" );
		Assert.AreEqual( 2, entries.Count );

		foreach ( var entry in entries )
		{
			Assert.AreEqual( "0.5", (entry.FindField( "import_scale" ).Value as Kv3Scalar)?.Value );
			Assert.AreEqual( "Center", (entry.FindField( "align_origin_x_type" ).Value as Kv3Scalar)?.Value );
			Assert.AreEqual( "Mins", (entry.FindField( "align_origin_y_type" ).Value as Kv3Scalar)?.Value );
			Assert.AreEqual( "Maxs", (entry.FindField( "align_origin_z_type" ).Value as Kv3Scalar)?.Value );
		}
	}

	[TestMethod]
	public void ConvertsScaleUnits()
	{
		// The value in the field is the import_scale multiplier written to the vmdl.
		Assert.AreEqual( 1.0f, new ScaleInput { Value = 1, Unit = ScaleUnit.Inches }.ToImportScale(), 0.0001f );
		Assert.AreEqual( 0.3937f, new ScaleInput { Value = 0.3937f, Unit = ScaleUnit.Centimeters }.ToImportScale(), 0.0001f );

		// Switching 1 inch to cm re-expresses it as 0.3937 (1 cm = 0.3937 inches).
		var inches = new ScaleInput { Value = 1, Unit = ScaleUnit.Inches };
		var cm = inches.ConvertTo( ScaleUnit.Centimeters );
		Assert.AreEqual( 0.3937f, cm.Value, 0.0001f );
		Assert.AreEqual( 0.3937f, cm.ToImportScale(), 0.0001f );

		// A cm model (import_scale 0.3937) reads back as itself.
		var fromCm = ScaleInput.FromImportScale( 0.3937f, ScaleUnit.Centimeters );
		Assert.AreEqual( 0.3937f, fromCm.Value, 0.0001f );
		Assert.AreEqual( 0.3937f, fromCm.ToImportScale(), 0.0001f );
	}

	[TestMethod]
	public void NoOpWhenValuesMatch()
	{
		var editor = VmdlBulkEditor.Load( SampleVmdl );

		var props = new MeshEntryProperties
		{
			ImportScale = 1.0f,
			AlignOriginX = AlignOrigin.None,
			AlignOriginY = AlignOrigin.None,
			AlignOriginZ = AlignOrigin.None
		};

		Assert.IsFalse( editor.Apply( props ) );
		Assert.AreEqual( SampleVmdl, editor.Source );
	}

	[TestMethod]
	public void ReadsFirstEntryProperties()
	{
		var editor = VmdlBulkEditor.Load( SampleVmdl );
		var props = editor.ReadFirstEntryProperties();

		Assert.AreEqual( 1.0f, props.ImportScale );
		Assert.AreEqual( AlignOrigin.None, props.AlignOriginX );
		Assert.AreEqual( AlignOrigin.None, props.AlignOriginY );
		Assert.AreEqual( AlignOrigin.None, props.AlignOriginZ );
	}
}
bluedock.modelpro / UnitTests/MaterialGroupTests.cs
UnitTest library
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ModelPro.Vmdl;

[TestClass]
public class MaterialGroupTests
{
	const string WithMaterial = """
	{
		rootNode = 
		{
			_class = "RootNode"
			children = 
			[
				{
					_class = "MaterialGroupList"
					children = 
					[
						{
							_class = "DefaultMaterialGroup"
							remaps = [  ]
							use_global_default = true
							global_default_material = "materials/old.vmat"
						},
					]
				},
				{
					_class = "PhysicsShapeList"
					children = 
					[
						{
							_class = "PhysicsHullFromRender"
							parent_bone = ""
							surface_prop = "default"
							collision_tags = "solid"
							faceMergeAngle = 20.0
							maxHullVertices = 32
							hull_mode = "HullPerElement"
						},
					]
				},
				{
					_class = "RenderMeshList"
					children = 
					[
						{
							_class = "RenderMeshFile"
							filename = "models/foo.fbx"
							import_scale = 1.0
						},
					]
				},
			]
		}
	}
	""";

	[TestMethod]
	public void SetsDefaultMaterial()
	{
		var editor = VmdlBulkEditor.Load( WithMaterial );
		Assert.AreEqual( "materials/old.vmat", editor.ReadDefaultMaterial() );

		var props = new MeshEntryProperties { GlobalDefaultMaterial = "materials/new.vmat" };
		Assert.IsTrue( editor.Apply( props ) );

		var parsed = Kv3Document.Parse( editor.Source );
		var group = parsed.FindObjects( "DefaultMaterialGroup" ).Single();
		Assert.AreEqual( "materials/new.vmat", ((Kv3Scalar)group.FindField( "global_default_material" ).Value).Value );
		Assert.AreEqual( "true", ((Kv3Scalar)group.FindField( "use_global_default" ).Value).Value );
	}

	[TestMethod]
	public void NullMaterialLeavesUnchanged()
	{
		var editor = VmdlBulkEditor.Load( WithMaterial );

		var props = new MeshEntryProperties { ImportScale = 2.0f };
		Assert.IsTrue( editor.Apply( props ) );

		var parsed = Kv3Document.Parse( editor.Source );
		var group = parsed.FindObjects( "DefaultMaterialGroup" ).Single();
		Assert.AreEqual( "materials/old.vmat", ((Kv3Scalar)group.FindField( "global_default_material" ).Value).Value );
	}

	[TestMethod]
	public void AlignOriginAppliesToCollision()
	{
		var editor = VmdlBulkEditor.Load( WithMaterial );

		var props = new MeshEntryProperties
		{
			AlignOriginX = AlignOrigin.BoundsCenter,
			AlignOriginY = AlignOrigin.BoundsCenter,
			AlignOriginZ = AlignOrigin.BoundsMin
		};

		Assert.IsTrue( editor.Apply( props ) );

		var parsed = Kv3Document.Parse( editor.Source );

		// Render mesh
		var mesh = parsed.FindObjects( "RenderMeshFile" ).Single();
		Assert.AreEqual( "BoundsCenter", ((Kv3Scalar)mesh.FindField( "align_origin_x_type" ).Value).Value );
		Assert.AreEqual( "BoundsMin", ((Kv3Scalar)mesh.FindField( "align_origin_z_type" ).Value).Value );

		// Collision shape must match
		var hull = parsed.FindObjects( "PhysicsHullFromRender" ).Single();
		Assert.AreEqual( "BoundsCenter", ((Kv3Scalar)hull.FindField( "align_origin_x_type" ).Value).Value );
		Assert.AreEqual( "BoundsCenter", ((Kv3Scalar)hull.FindField( "align_origin_y_type" ).Value).Value );
		Assert.AreEqual( "BoundsMin", ((Kv3Scalar)hull.FindField( "align_origin_z_type" ).Value).Value );
	}
}
bluedock.modelpro / Assembly.cs
Game library
global using Sandbox;
global using System.Collections.Generic;
global using System.Linq;
Debug: View Raw JSON Response
{
    "TotalCount": 19,
    "Files": [
        {
            "Ident": "bluedock.modelpro",
            "Path": "Editor/VmdlEditTab.cs",
            "FileName": "VmdlEditTab.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 340317,
            "Code": "using System;\nusing System.Globalization;\nusing System.IO;\nusing ModelPro.Vmdl;\n\nnamespace ModelPro.Editor;\n\n/// <summary>\n/// The VMDL tab of Model Pro. Pick a folder, it recursively finds every .vmdl\n/// file, and you can apply uniform scale and align-origin X/Y/Z changes to all\n/// their mesh entries at once.\n/// </summary>\npublic class VmdlEditTab : Widget\n{\n\tTreeView _folderTree;\n\tTreeView _fileList;\n\tLabel _statusLabel;\n\n\tLineEdit _scaleEdit;\n\tComboBox _scaleUnit;\n\tScaleUnit _lastScaleUnit = ScaleUnit.Inches;\n\tComboBox _alignX;\n\tComboBox _alignY;\n\tComboBox _alignZ;\n\tControlSheet _materialSheet;\n\tDefaultMaterialModel _materialModel = new();\n\tButton _applyButton;\n\tLabel _resultLabel;\n\n\tList<string> _foundVmdlFiles = new();\n\n\tpublic VmdlEditTab( Widget parent ) : base( parent )\n\t{\n\t\tBuildUI();\n\t\tLoadSettings();\n\t}\n\n\tprivate void BuildUI()\n\t{\n\t\tLayout = Layout.Column();\n\t\tLayout.Spacing = 4;\n\n\t\tvar split = Layout.AddRow();\n\t\tsplit.Spacing = 8;\n\n\t\tvar left = split.AddColumn();\n\t\tleft.Spacing = 4;\n\t\tleft.Add( new Label( \"<b>Folder</b>\" ) );\n\n\t\tvar treeScroll = left.Add( new ScrollArea( this ), 1 );\n\t\ttreeScroll.Canvas = new Widget();\n\t\ttreeScroll.Canvas.Layout = Layout.Column();\n\t\ttreeScroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 2 );\n\n\t\t_folderTree = new TreeView( treeScroll.Canvas );\n\t\ttreeScroll.Canvas.Layout.Add( _folderTree );\n\t\t_folderTree.ExpandForSelection = true;\n\n\t\tAddFolderNodes();\n\n\t\tvar right = split.AddColumn();\n\t\tright.Spacing = 4;\n\n\t\t_statusLabel = right.Add( new Label( \"Select a folder to search for models.\" ) );\n\t\t_statusLabel.WordWrap = true;\n\n\t\tvar listScroll = right.Add( new ScrollArea( this ), 1 );\n\t\tlistScroll.Canvas = new Widget();\n\t\tlistScroll.Canvas.Layout = Layout.Column();\n\t\tlistScroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 2 );\n\n\t\t_fileList = new TreeView( listScroll.Canvas );\n\t\tlistScroll.Canvas.Layout.Add( _fileList );\n\n\t\tvar group = right.Add( new Widget() );\n\t\tvar grid = Layout.Grid();\n\t\tgrid.Spacing = 4;\n\t\tgroup.Layout = grid;\n\n\t\tint row = 0;\n\n\t\tgrid.AddCell( 0, row, new Label( \"Scale\" ) );\n\n\t\tvar scaleRow = Layout.Row();\n\t\tscaleRow.Spacing = 4;\n\t\t_scaleEdit = new LineEdit() { Text = \"1.0\", FixedWidth = 90 };\n\t\t_scaleUnit = new ComboBox() { FixedWidth = 130 };\n\t\tforeach ( var unit in Enum.GetValues<ScaleUnit>() )\n\t\t{\n\t\t\tvar u = unit;\n\t\t\t_scaleUnit.AddItem( u.ToDisplayString(), onSelected: () => OnScaleUnitChanged( u ) );\n\t\t}\n\t\t_scaleUnit.TrySelectNamed( ScaleUnit.Inches.ToDisplayString() );\n\t\tscaleRow.Add( _scaleEdit );\n\t\tscaleRow.Add( _scaleUnit );\n\t\tgrid.AddCell( 1, row, scaleRow );\n\t\trow++;\n\n\t\tgrid.AddCell( 0, row, new Label( \"Align Origin X\" ) );\n\t\t_alignX = CreateAlignCombo();\n\t\tgrid.AddCell( 1, row, _alignX );\n\t\trow++;\n\n\t\tgrid.AddCell( 0, row, new Label( \"Align Origin Y\" ) );\n\t\t_alignY = CreateAlignCombo();\n\t\tgrid.AddCell( 1, row, _alignY );\n\t\trow++;\n\n\t\tgrid.AddCell( 0, row, new Label( \"Align Origin Z\" ) );\n\t\t_alignZ = CreateAlignCombo();\n\t\tgrid.AddCell( 1, row, _alignZ );\n\t\trow++;\n\n\t\t// The material picker has its own \"Material\" label, so give it a fixed\n\t\t// width row below the grid - otherwise it stretches the whole window.\n\t\tvar materialWrap = new Widget() { FixedWidth = 280 };\n\t\tmaterialWrap.Layout = Layout.Column();\n\t\t_materialSheet = new ControlSheet();\n\t\t_materialSheet.AddProperty( _materialModel, x => x.Material );\n\t\tmaterialWrap.Layout.Add( _materialSheet );\n\t\tright.Add( materialWrap );\n\n\t\t_applyButton = new Button( \"Apply to N models\", \"check\" );\n\t\t_applyButton.Clicked += ApplyEdits;\n\t\tright.Add( _applyButton );\n\n\t\t_resultLabel = new Label( \"\" );\n\t\t_resultLabel.WordWrap = true;\n\t\tright.Add( _resultLabel );\n\t}\n\n\tprivate ComboBox CreateAlignCombo()\n\t{\n\t\tvar combo = new ComboBox() { FixedWidth = 120 };\n\t\tcombo.AddItem( \"None\" );\n\t\tcombo.AddItem( \"BoundsCenter\" );\n\t\tcombo.AddItem( \"BoundsMin\" );\n\t\tcombo.AddItem( \"BoundsMax\" );\n\t\tcombo.TrySelectNamed( \"None\" );\n\t\treturn combo;\n\t}\n\n\t/// <summary>Restore the last used values into the controls.</summary>\n\tprivate void LoadSettings()\n\t{\n\t\tvar s = ModelProSettings.Current;\n\n\t\t_scaleEdit.Text = s.VmdlScale;\n\t\t_lastScaleUnit = s.VmdlScaleUnit;\n\t\t_scaleUnit.TrySelectNamed( s.VmdlScaleUnit.ToDisplayString() );\n\t\t_alignX.TrySelectNamed( s.VmdlAlignX.ToKv3Value() );\n\t\t_alignY.TrySelectNamed( s.VmdlAlignY.ToKv3Value() );\n\t\t_alignZ.TrySelectNamed( s.VmdlAlignZ.ToKv3Value() );\n\t\t_materialModel.Material = s.VmdlDefaultMaterial;\n\t}\n\n\t/// <summary>Remember the current values for next time.</summary>\n\tprivate void SaveSettings()\n\t{\n\t\tvar s = ModelProSettings.Current;\n\t\ts.VmdlScale = _scaleEdit.Text;\n\t\ts.VmdlScaleUnit = ParseScaleUnit( _scaleUnit.CurrentText );\n\t\ts.VmdlAlignX = ParseAlign( _alignX.CurrentText ).Value;\n\t\ts.VmdlAlignY = ParseAlign( _alignY.CurrentText ).Value;\n\t\ts.VmdlAlignZ = ParseAlign( _alignZ.CurrentText ).Value;\n\t\ts.VmdlDefaultMaterial = _materialModel.Material ?? \"\";\n\t\ts.Save();\n\t}\n\n\tprivate void AddFolderNodes()\n\t{\n\t\tvar rootDir = Sandbox.Project.Current?.RootDirectory;\n\t\tif ( rootDir is null )\n\t\t\treturn;\n\n\t\tvar root = new FolderNode( rootDir.FullName, OnFolderSelected );\n\t\t_folderTree.AddItem( root );\n\t\t_folderTree.Open( root );\n\t}\n\n\tprivate void OnFolderSelected( string folder )\n\t{\n\t\t_statusLabel.Text = $\"Searching <b>{folder}</b>...\";\n\t\t_resultLabel.Text = \"\";\n\n\t\t_foundVmdlFiles = Directory.GetFiles( folder, \"*.vmdl\", SearchOption.AllDirectories )\n\t\t\t.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToList();\n\n\t\t_fileList.Clear();\n\n\t\tint meshEntryTotal = 0;\n\t\tforeach ( var file in _foundVmdlFiles )\n\t\t{\n\t\t\tint count = 0;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar editor = VmdlBulkEditor.LoadFile( file );\n\t\t\t\tcount = editor.MeshEntryCount;\n\t\t\t\tmeshEntryTotal += count;\n\t\t\t}\n\t\t\tcatch ( Exception e )\n\t\t\t{\n\t\t\t\tLog.Warning( e, $\"Model Pro: failed to parse {file}\" );\n\t\t\t}\n\n\t\t\t_fileList.AddItem( new FileNode( file, count ) );\n\t\t}\n\n\t\t_statusLabel.Text = $\"Found <b>{_foundVmdlFiles.Count}</b> models in <b>{folder}</b> \u2014 {meshEntryTotal} mesh entries.\";\n\n\t\t_applyButton.Text = _foundVmdlFiles.Count == 0\n\t\t\t? \"Apply to 0 models\"\n\t\t\t: $\"Apply to {_foundVmdlFiles.Count} models\";\n\t}\n\n\tprivate void ApplyEdits()\n\t{\n\t\tif ( _foundVmdlFiles.Count == 0 )\n\t\t\treturn;\n\n\t\tvar props = new MeshEntryProperties\n\t\t{\n\t\t\tImportScale = ReadScale(),\n\t\t\tAlignOriginX = ParseAlign( _alignX.CurrentText ),\n\t\t\tAlignOriginY = ParseAlign( _alignY.CurrentText ),\n\t\t\tAlignOriginZ = ParseAlign( _alignZ.CurrentText ),\n\t\t\tGlobalDefaultMaterial = string.IsNullOrWhiteSpace( _materialModel.Material ) ? null : _materialModel.Material.Trim()\n\t\t};\n\n\t\tLog.Info( $\"Model Pro: applying scale={props.ImportScale?.ToString( \"0.0########\", CultureInfo.InvariantCulture ) ?? \"off\"} \" +\n\t\t\t$\"align=({_alignX.CurrentText},{_alignY.CurrentText},{_alignZ.CurrentText}) to {_foundVmdlFiles.Count} file(s)\" );\n\n\t\tint modified = 0;\n\t\tint totalEntries = 0;\n\t\tint noMeshEntries = 0;\n\t\tint alreadyMatched = 0;\n\t\tvar errors = new List<string>();\n\n\t\tforeach ( var file in _foundVmdlFiles )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar editor = VmdlBulkEditor.LoadFile( file );\n\t\t\t\tif ( editor.MeshEntryCount == 0 )\n\t\t\t\t{\n\t\t\t\t\tnoMeshEntries++;\n\t\t\t\t\tLog.Info( $\"Model Pro: {Path.GetFileName( file )} - no RenderMeshFile entries found\" );\n\t\t\t\t}\n\t\t\t\telse if ( editor.Apply( props ) )\n\t\t\t\t{\n\t\t\t\t\tFile.WriteAllText( file, editor.Source );\n\t\t\t\t\tmodified++;\n\t\t\t\t\tLog.Info( $\"Model Pro: updated {Path.GetFileName( file )}\" );\n\n\t\t\t\t\tRecompileModel( file );\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\talreadyMatched++;\n\t\t\t\t\tLog.Info( $\"Model Pro: {Path.GetFileName( file )} - values already match\" );\n\t\t\t\t}\n\n\t\t\t\ttotalEntries += editor.MeshEntryCount;\n\t\t\t}\n\t\t\tcatch ( Exception e )\n\t\t\t{\n\t\t\t\terrors.Add( $\"{Path.GetFileName( file )}: {e.Message}\" );\n\t\t\t\tLog.Warning( e, $\"Model Pro: failed to process {file}\" );\n\t\t\t}\n\t\t}\n\n\t\tvar msg = $\"Updated <b>{modified}</b> of {_foundVmdlFiles.Count} models ({totalEntries} mesh entries).\";\n\t\tif ( modified == 0 && errors.Count == 0 )\n\t\t{\n\t\t\tvar reason = noMeshEntries > 0 && alreadyMatched == 0\n\t\t\t\t? \"None of the files contain RenderMeshFile entries.\"\n\t\t\t\t: \"The values already match what's set above. Change a value and try again.\";\n\t\t\tmsg = $\"No models needed updating \u2014 {reason}\";\n\t\t}\n\n\t\tif ( errors.Count > 0 )\n\t\t\tmsg += $\"\\n\\nErrors ({errors.Count}):\\n{string.Join( \"\\n\", errors.Take( 8 ) )}\";\n\n\t\t_resultLabel.Text = msg;\n\n\t\tSaveSettings();\n\t}\n\n\tprivate static AlignOrigin? ParseAlign( string text )\n\t{\n\t\treturn text switch\n\t\t{\n\t\t\t\"Center\" => AlignOrigin.Center,\n\t\t\t\"Mins\" => AlignOrigin.Mins,\n\t\t\t\"Maxs\" => AlignOrigin.Maxs,\n\t\t\t\"BoundsCenter\" => AlignOrigin.BoundsCenter,\n\t\t\t\"BoundsMin\" => AlignOrigin.BoundsMin,\n\t\t\t\"BoundsMax\" => AlignOrigin.BoundsMax,\n\t\t\t_ => AlignOrigin.None\n\t\t};\n\t}\n\n\t/// <summary>\n\t/// Force the editor to recompile the model from its (just-written) source file\n\t/// so the asset system and any open ModelDoc pick up the change.\n\t/// </summary>\n\tprivate static void RecompileModel( string absolutePath )\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar project = Sandbox.Project.Current;\n\t\t\tvar assetsRoot = project?.GetAssetsPath();\n\t\t\tif ( string.IsNullOrEmpty( assetsRoot ) )\n\t\t\t\treturn;\n\n\t\t\t// Find the asset by its content-relative path - FindByPath expects the\n\t\t\t// relative asset path (e.g. \"models/vehicle_01_a.vmdl\"), not a Windows path.\n\t\t\tvar relative = absolutePath.Replace( '\\\\', '/' );\n\t\t\tif ( relative.StartsWith( assetsRoot.Replace( '\\\\', '/' ), StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\trelative = relative.Substring( assetsRoot.Replace( '\\\\', '/' ).Length ).TrimStart( '/' );\n\n\t\t\tvar asset = AssetSystem.FindByPath( relative );\n\t\t\tif ( asset is null )\n\t\t\t{\n\t\t\t\t// Fall back to matching by absolute path.\n\t\t\t\tasset = AssetSystem.FindByPath( absolutePath );\n\t\t\t}\n\n\t\t\tif ( asset is null )\n\t\t\t{\n\t\t\t\tLog.Warning( $\"Model Pro: couldn't find asset for '{absolutePath}' to recompile\" );\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tasset.Compile( true );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( e, $\"Model Pro: failed to recompile '{absolutePath}'\" );\n\t\t}\n\t}\n\n\tprivate float? ReadScale()\n\t{\n\t\tif ( !float.TryParse( _scaleEdit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) )\n\t\t\treturn null;\n\n\t\tvar unit = ParseScaleUnit( _scaleUnit.CurrentText );\n\t\treturn new ScaleInput { Value = value, Unit = unit }.ToImportScale();\n\t}\n\n\t/// <summary>\n\t/// When the unit dropdown changes, convert the current scale value so the\n\t/// import scale stays the same - just like the create-model popup does.\n\t/// </summary>\n\tprivate void OnScaleUnitChanged( ScaleUnit newUnit )\n\t{\n\t\tif ( newUnit == _lastScaleUnit )\n\t\t\treturn;\n\n\t\tif ( float.TryParse( _scaleEdit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var current ) )\n\t\t{\n\t\t\tvar converted = new ScaleInput { Value = current, Unit = _lastScaleUnit }.ConvertTo( newUnit );\n\t\t\t_scaleEdit.Text = converted.Value.ToString( \"0.0########\", CultureInfo.InvariantCulture );\n\t\t}\n\n\t\t_lastScaleUnit = newUnit;\n\t}\n\n\tprivate static ScaleUnit ParseScaleUnit( string text )\n\t{\n\t\tforeach ( var unit in Enum.GetValues<ScaleUnit>() )\n\t\t{\n\t\t\tif ( string.Equals( unit.ToDisplayString(), text, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\treturn unit;\n\t\t}\n\n\t\treturn ScaleUnit.Inches;\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Editor/Vmdl/Kv3Parser.cs",
            "FileName": "Kv3Parser.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 340317,
            "Code": "using System;\nusing System.Text;\n\nnamespace ModelPro.Vmdl;\n\ninternal enum Kv3TokenType\n{\n\tLBrace,\n\tRBrace,\n\tLBracket,\n\tRBracket,\n\tEquals,\n\tComma,\n\tString,\n\tIdent,\n\tEnd\n}\n\ninternal readonly struct Kv3Token\n{\n\tpublic readonly Kv3TokenType Type;\n\tpublic readonly int Start;\n\tpublic readonly int End;\n\tpublic readonly string Text;\n\n\tpublic Kv3Token( Kv3TokenType type, int start, int end, string text )\n\t{\n\t\tType = type;\n\t\tStart = start;\n\t\tEnd = end;\n\t\tText = text;\n\t}\n}\n\n/// <summary>\n/// Tokenizes KV3 text (the format model .vmdl source files are stored in).\n/// Handles the <!-- ... --> header comment, // and /* */ comments, quoted strings\n/// with escapes, and barewords (identifiers and numbers).\n/// </summary>\ninternal sealed class Kv3Tokenizer\n{\n\treadonly string src;\n\tint pos;\n\n\tpublic Kv3Tokenizer( string src )\n\t{\n\t\tthis.src = src;\n\t}\n\n\tpublic List<Kv3Token> Tokenize()\n\t{\n\t\tvar tokens = new List<Kv3Token>();\n\n\t\twhile ( true )\n\t\t{\n\t\t\tSkipTrivia();\n\t\t\tif ( pos >= src.Length )\n\t\t\t{\n\t\t\t\ttokens.Add( new Kv3Token( Kv3TokenType.End, pos, pos, \"\" ) );\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tint start = pos;\n\t\t\tchar c = src[pos];\n\n\t\t\tswitch ( c )\n\t\t\t{\n\t\t\t\tcase '{':\n\t\t\t\t\tpos++;\n\t\t\t\t\ttokens.Add( new Kv3Token( Kv3TokenType.LBrace, start, pos, \"{\" ) );\n\t\t\t\t\tbreak;\n\t\t\t\tcase '}':\n\t\t\t\t\tpos++;\n\t\t\t\t\ttokens.Add( new Kv3Token( Kv3TokenType.RBrace, start, pos, \"}\" ) );\n\t\t\t\t\tbreak;\n\t\t\t\tcase '[':\n\t\t\t\t\tpos++;\n\t\t\t\t\ttokens.Add( new Kv3Token( Kv3TokenType.LBracket, start, pos, \"[\" ) );\n\t\t\t\t\tbreak;\n\t\t\t\tcase ']':\n\t\t\t\t\tpos++;\n\t\t\t\t\ttokens.Add( new Kv3Token( Kv3TokenType.RBracket, start, pos, \"]\" ) );\n\t\t\t\t\tbreak;\n\t\t\t\tcase '=':\n\t\t\t\t\tpos++;\n\t\t\t\t\ttokens.Add( new Kv3Token( Kv3TokenType.Equals, start, pos, \"=\" ) );\n\t\t\t\t\tbreak;\n\t\t\t\tcase ',':\n\t\t\t\t\tpos++;\n\t\t\t\t\ttokens.Add( new Kv3Token( Kv3TokenType.Comma, start, pos, \",\" ) );\n\t\t\t\t\tbreak;\n\t\t\t\tcase '\"':\n\t\t\t\t\tReadString( tokens, start );\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tReadBareword( tokens, start );\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\treturn tokens;\n\t}\n\n\tvoid ReadString( List<Kv3Token> tokens, int start )\n\t{\n\t\tpos++; // opening quote\n\t\twhile ( pos < src.Length && src[pos] != '\"' )\n\t\t{\n\t\t\tif ( src[pos] == '\\\\' && pos + 1 < src.Length )\n\t\t\t\tpos += 2;\n\t\t\telse\n\t\t\t\tpos++;\n\t\t}\n\n\t\tif ( pos < src.Length )\n\t\t\tpos++; // closing quote\n\n\t\ttokens.Add( new Kv3Token( Kv3TokenType.String, start, pos, src.Substring( start, pos - start ) ) );\n\t}\n\n\tvoid ReadBareword( List<Kv3Token> tokens, int start )\n\t{\n\t\twhile ( pos < src.Length )\n\t\t{\n\t\t\tchar c = src[pos];\n\t\t\tif ( char.IsWhiteSpace( c ) || c is '{' or '}' or '[' or ']' or '=' or ',' )\n\t\t\t\tbreak;\n\t\t\tif ( c == '/' && pos + 1 < src.Length && src[pos + 1] is '/' or '*' )\n\t\t\t\tbreak;\n\t\t\tif ( c == '<' && pos + 3 < src.Length && src.AsSpan( pos ).StartsWith( \"<!--\" ) )\n\t\t\t\tbreak;\n\t\t\tpos++;\n\t\t}\n\n\t\ttokens.Add( new Kv3Token( Kv3TokenType.Ident, start, pos, src.Substring( start, pos - start ) ) );\n\t}\n\n\tvoid SkipTrivia()\n\t{\n\t\twhile ( pos < src.Length )\n\t\t{\n\t\t\tchar c = src[pos];\n\n\t\t\tif ( char.IsWhiteSpace( c ) )\n\t\t\t{\n\t\t\t\tpos++;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( c == '/' && pos + 1 < src.Length && src[pos + 1] == '/' )\n\t\t\t{\n\t\t\t\tpos += 2;\n\t\t\t\twhile ( pos < src.Length && src[pos] != '\\n' )\n\t\t\t\t\tpos++;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( c == '/' && pos + 1 < src.Length && src[pos + 1] == '*' )\n\t\t\t{\n\t\t\t\tvar end = src.IndexOf( \"*/\", pos + 2, StringComparison.Ordinal );\n\t\t\t\tpos = end < 0 ? src.Length : end + 2;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( c == '<' && pos + 3 < src.Length && src.AsSpan( pos ).StartsWith( \"<!--\" ) )\n\t\t\t{\n\t\t\t\tvar end = src.IndexOf( \"-->\", pos, StringComparison.Ordinal );\n\t\t\t\tpos = end < 0 ? src.Length : end + 3;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tbreak;\n\t\t}\n\t}\n}\n\n/// <summary>A parsed node in a KV3 document.</summary>\npublic abstract class Kv3Node\n{\n\tpublic int Start;\n\tpublic int End;\n}\n\n/// <summary>An object - a list of key/value fields.</summary>\npublic sealed class Kv3Object : Kv3Node\n{\n\tpublic List<Kv3Field> Fields = new();\n\n\tpublic Kv3Field FindField( string key ) => Fields.FirstOrDefault( f => f.Key == key );\n\n\t/// <summary>The value of the \"_class\" field, used to identify node types in modeldoc files.</summary>\n\tpublic string Class => (FindField( \"_class\" )?.Value as Kv3Scalar)?.Value;\n}\n\npublic sealed class Kv3Field\n{\n\tpublic string Key;\n\tpublic int KeyStart;\n\tpublic int KeyEnd;\n\tpublic Kv3Node Value;\n}\n\n/// <summary>An array of values.</summary>\npublic sealed class Kv3Array : Kv3Node\n{\n\tpublic List<Kv3Node> Items = new();\n}\n\n/// <summary>A scalar value - a string, number, or bareword.</summary>\npublic sealed class Kv3Scalar : Kv3Node\n{\n\tpublic bool IsString;\n\tpublic string Raw;\n\tpublic string Value;\n}\n\n/// <summary>\n/// A parsed KV3 text document. Keeps a reference to the original source so edits\n/// can be applied surgically without reformatting the whole file.\n/// </summary>\npublic sealed class Kv3Document\n{\n\tpublic string Source { get; }\n\tpublic Kv3Object Root { get; }\n\n\tKv3Document( string source )\n\t{\n\t\tSource = source;\n\t\tRoot = Kv3Parser.Parse( source );\n\t}\n\n\tpublic static Kv3Document Parse( string source ) => new( source );\n\n\t/// <summary>\n\t/// Walk the tree and return every object whose \"_class\" field matches the given name.\n\t/// </summary>\n\tpublic List<Kv3Object> FindObjects( string className )\n\t{\n\t\tvar result = new List<Kv3Object>();\n\t\tWalk( Root, result, className );\n\t\treturn result;\n\t}\n\n\tstatic void Walk( Kv3Node node, List<Kv3Object> result, string className )\n\t{\n\t\tif ( node is Kv3Object obj )\n\t\t{\n\t\t\tif ( obj.Class == className )\n\t\t\t\tresult.Add( obj );\n\n\t\t\tforeach ( var f in obj.Fields )\n\t\t\t\tWalk( f.Value, result, className );\n\t\t}\n\t\telse if ( node is Kv3Array arr )\n\t\t{\n\t\t\tforeach ( var item in arr.Items )\n\t\t\t\tWalk( item, result, className );\n\t\t}\n\t}\n}\n\ninternal sealed class Kv3Parser\n{\n\treadonly string src;\n\treadonly List<Kv3Token> tokens;\n\tint index;\n\n\tKv3Parser( string src, List<Kv3Token> tokens )\n\t{\n\t\tthis.src = src;\n\t\tthis.tokens = tokens;\n\t}\n\n\tpublic static Kv3Object Parse( string source )\n\t{\n\t\tvar tokens = new Kv3Tokenizer( source ).Tokenize();\n\t\tvar parser = new Kv3Parser( source, tokens );\n\t\treturn parser.ParseObject();\n\t}\n\n\tKv3Token Peek => tokens[index];\n\tKv3Token Next() => tokens[index++];\n\tbool AtEnd => Peek.Type == Kv3TokenType.End;\n\n\tKv3Node ParseValue()\n\t{\n\t\tvar tok = Peek;\n\t\tif ( tok.Type == Kv3TokenType.LBrace )\n\t\t\treturn ParseObject();\n\t\tif ( tok.Type == Kv3TokenType.LBracket )\n\t\t\treturn ParseArray();\n\t\treturn ParseScalar();\n\t}\n\n\tKv3Object ParseObject()\n\t{\n\t\tvar open = Next(); // {\n\t\tvar obj = new Kv3Object { Start = open.Start };\n\n\t\twhile ( true )\n\t\t{\n\t\t\tif ( AtEnd )\n\t\t\t\tbreak;\n\n\t\t\tif ( Peek.Type == Kv3TokenType.RBrace )\n\t\t\t{\n\t\t\t\tvar close = Next();\n\t\t\t\tobj.End = close.End;\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif ( Peek.Type == Kv3TokenType.Comma )\n\t\t\t{\n\t\t\t\tNext();\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar keyTok = Peek;\n\t\t\tif ( keyTok.Type is not (Kv3TokenType.String or Kv3TokenType.Ident) )\n\t\t\t{\n\t\t\t\tNext();\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tNext(); // consume key\n\n\t\t\tstring key = keyTok.Type == Kv3TokenType.String\n\t\t\t\t? Unquote( keyTok.Text )\n\t\t\t\t: keyTok.Text;\n\n\t\t\tif ( Peek.Type == Kv3TokenType.Equals )\n\t\t\t\tNext();\n\n\t\t\tvar value = ParseValue();\n\t\t\tobj.Fields.Add( new Kv3Field { Key = key, KeyStart = keyTok.Start, KeyEnd = keyTok.End, Value = value } );\n\t\t}\n\n\t\treturn obj;\n\t}\n\n\tKv3Array ParseArray()\n\t{\n\t\tvar open = Next(); // [\n\t\tvar arr = new Kv3Array { Start = open.Start };\n\n\t\twhile ( true )\n\t\t{\n\t\t\tif ( AtEnd )\n\t\t\t\tbreak;\n\n\t\t\tif ( Peek.Type == Kv3TokenType.RBracket )\n\t\t\t{\n\t\t\t\tvar close = Next();\n\t\t\t\tarr.End = close.End;\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif ( Peek.Type == Kv3TokenType.Comma )\n\t\t\t{\n\t\t\t\tNext();\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tarr.Items.Add( ParseValue() );\n\t\t}\n\n\t\treturn arr;\n\t}\n\n\tKv3Scalar ParseScalar()\n\t{\n\t\tvar tok = Next();\n\t\treturn new Kv3Scalar\n\t\t{\n\t\t\tStart = tok.Start,\n\t\t\tEnd = tok.End,\n\t\t\tIsString = tok.Type == Kv3TokenType.String,\n\t\t\tRaw = tok.Text,\n\t\t\tValue = tok.Type == Kv3TokenType.String ? Unquote( tok.Text ) : tok.Text\n\t\t};\n\t}\n\n\tstatic string Unquote( string s )\n\t{\n\t\tif ( s.Length < 2 || s[0] != '\"' )\n\t\t\treturn s;\n\n\t\tvar inner = s.Substring( 1, s.Length - 2 );\n\t\tvar sb = new StringBuilder( inner.Length );\n\n\t\tfor ( int i = 0; i < inner.Length; i++ )\n\t\t{\n\t\t\tif ( inner[i] == '\\\\' && i + 1 < inner.Length )\n\t\t\t{\n\t\t\t\tchar n = inner[++i];\n\t\t\t\tswitch ( n )\n\t\t\t\t{\n\t\t\t\t\tcase 'n':\n\t\t\t\t\t\tsb.Append( '\\n' );\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 't':\n\t\t\t\t\t\tsb.Append( '\\t' );\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 'r':\n\t\t\t\t\t\tsb.Append( '\\r' );\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase '\"':\n\t\t\t\t\t\tsb.Append( '\"' );\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase '\\'':\n\t\t\t\t\t\tsb.Append( '\\'' );\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase '\\\\':\n\t\t\t\t\t\tsb.Append( '\\\\' );\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tsb.Append( n );\n\t\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tsb.Append( inner[i] );\n\t\t\t}\n\t\t}\n\n\t\treturn sb.ToString();\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Code/Assembly.cs",
            "FileName": "Assembly.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 340317,
            "Code": "global using Sandbox;\nglobal using System.Collections.Generic;\nglobal using System.Linq;\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Editor/DefaultMaterialModel.cs",
            "FileName": "DefaultMaterialModel.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 340317,
            "Code": "using System;\nusing System.ComponentModel;\n\nnamespace ModelPro.Editor;\n\n/// <summary>\n/// A concrete <see cref=\"AssetPathAttribute\"/> that restricts picking to a single\n/// asset type extension. Used so the material field only accepts .vmat files.\n/// </summary>\n[AttributeUsage( AttributeTargets.Property )]\npublic class AssetPathTypeAttribute : AssetPathAttribute\n{\n\treadonly string _extension;\n\n\tpublic AssetPathTypeAttribute( string extension )\n\t{\n\t\t_extension = extension;\n\t}\n\n\tpublic override string AssetTypeExtension => _extension;\n}\n\n/// <summary>\n/// A tiny serialized object used to render a material picker control. The\n/// [AssetPathType(\"vmat\")] attribute makes the editor use its material picker,\n/// so only .vmat assets can be selected.\n/// </summary>\npublic class DefaultMaterialModel\n{\n\t/// <summary>The selected default material path, e.g. \"materials/default.vmat\".</summary>\n\t[AssetPathType( \"vmat\" )]\n\tpublic string Material { get; set; } = \"\";\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "UnitTests/VmdlGeneratorTests.cs",
            "FileName": "VmdlGeneratorTests.cs",
            "PackageType": "library",
            "CodeKind": "UnitTest",
            "AssetVersionId": 340317,
            "Code": "using System;\nusing System.Linq;\nusing Microsoft.VisualStudio.TestTools.UnitTesting;\nusing ModelPro.Vmdl;\n\n[TestClass]\npublic class VmdlGeneratorTests\n{\n\t[TestMethod]\n\tpublic void GeneratesValidVmdl_HullCollision()\n\t{\n\t\tvar options = new MeshToModelOptions\n\t\t{\n\t\t\tCollision = CollisionMode.Hull,\n\t\t\tImportScale = 0.3937f,\n\t\t\tAlignOriginX = AlignOrigin.BoundsCenter,\n\t\t\tAlignOriginY = AlignOrigin.BoundsCenter,\n\t\t\tAlignOriginZ = AlignOrigin.BoundsMin\n\t\t};\n\n\t\tvar source = VmdlGenerator.Generate( \"models/foo.fbx\", options );\n\n\t\t// Parseable, finds all nodes.\n\t\tvar doc = Kv3Document.Parse( source );\n\t\tAssert.AreEqual( 1, doc.FindObjects( \"RenderMeshFile\" ).Count );\n\t\tAssert.AreEqual( 1, doc.FindObjects( \"PhysicsHullFromRender\" ).Count );\n\t\tAssert.AreEqual( 1, doc.FindObjects( \"RenderMeshList\" ).Count );\n\t\tAssert.AreEqual( 1, doc.FindObjects( \"PhysicsShapeList\" ).Count );\n\n\t\tvar mesh = doc.FindObjects( \"RenderMeshFile\" ).Single();\n\t\tAssert.AreEqual( \"models/foo.fbx\", ((Kv3Scalar)mesh.FindField( \"filename\" ).Value).Value );\n\t\tAssert.AreEqual( \"0.3937\", ((Kv3Scalar)mesh.FindField( \"import_scale\" ).Value).Value );\n\t\tAssert.AreEqual( \"BoundsCenter\", ((Kv3Scalar)mesh.FindField( \"align_origin_x_type\" ).Value).Value );\n\t\tAssert.AreEqual( \"BoundsMin\", ((Kv3Scalar)mesh.FindField( \"align_origin_z_type\" ).Value).Value );\n\n\t\t// Collision must carry the same align origin.\n\t\tvar hull = doc.FindObjects( \"PhysicsHullFromRender\" ).Single();\n\t\tAssert.AreEqual( \"BoundsCenter\", ((Kv3Scalar)hull.FindField( \"align_origin_x_type\" ).Value).Value );\n\t\tAssert.AreEqual( \"BoundsCenter\", ((Kv3Scalar)hull.FindField( \"align_origin_y_type\" ).Value).Value );\n\t\tAssert.AreEqual( \"BoundsMin\", ((Kv3Scalar)hull.FindField( \"align_origin_z_type\" ).Value).Value );\n\t}\n\n\t[TestMethod]\n\tpublic void GeneratesMeshCollision()\n\t{\n\t\tvar options = new MeshToModelOptions\n\t\t{\n\t\t\tCollision = CollisionMode.Mesh,\n\t\t\tImportScale = 1.0f\n\t\t};\n\n\t\tvar source = VmdlGenerator.Generate( \"models/foo.fbx\", options );\n\t\tvar doc = Kv3Document.Parse( source );\n\t\tAssert.AreEqual( 1, doc.FindObjects( \"PhysicsMeshFromRender\" ).Count );\n\t\tAssert.AreEqual( 0, doc.FindObjects( \"PhysicsHullFromRender\" ).Count );\n\t}\n\n\t[TestMethod]\n\tpublic void GeneratesFileCollision()\n\t{\n\t\tvar options = new MeshToModelOptions\n\t\t{\n\t\t\tCollision = CollisionMode.File,\n\t\t\tImportScale = 0.3937f\n\t\t};\n\n\t\tvar source = VmdlGenerator.Generate( \"models/foo.fbx\", options );\n\t\tvar doc = Kv3Document.Parse( source );\n\t\tAssert.AreEqual( 1, doc.FindObjects( \"PhysicsHullFile\" ).Count );\n\n\t\tvar hull = doc.FindObjects( \"PhysicsHullFile\" ).Single();\n\t\tAssert.AreEqual( \"models/foo.fbx\", ((Kv3Scalar)hull.FindField( \"filename\" ).Value).Value );\n\t\tAssert.AreEqual( \"0.3937\", ((Kv3Scalar)hull.FindField( \"import_scale\" ).Value).Value );\n\t}\n\n\t[TestMethod]\n\tpublic void NoCollisionWhenNone()\n\t{\n\t\tvar options = new MeshToModelOptions\n\t\t{\n\t\t\tCollision = CollisionMode.None,\n\t\t\tImportScale = 1.0f\n\t\t};\n\n\t\tvar source = VmdlGenerator.Generate( \"models/foo.fbx\", options );\n\t\tvar doc = Kv3Document.Parse( source );\n\t\tAssert.AreEqual( 0, doc.FindObjects( \"PhysicsShapeList\" ).Count );\n\t\tAssert.AreEqual( 1, doc.FindObjects( \"RenderMeshFile\" ).Count );\n\t}\n\n\t[TestMethod]\n\tpublic void GeneratedVmdlIsEditableByBulkEditor()\n\t{\n\t\tvar options = new MeshToModelOptions\n\t\t{\n\t\t\tCollision = CollisionMode.Hull,\n\t\t\tImportScale = 0.3937f,\n\t\t\tAlignOriginX = AlignOrigin.BoundsCenter,\n\t\t\tAlignOriginY = AlignOrigin.BoundsCenter,\n\t\t\tAlignOriginZ = AlignOrigin.BoundsMin\n\t\t};\n\n\t\tvar source = VmdlGenerator.Generate( \"models/foo.fbx\", options );\n\n\t\t// The generated file should load in the bulk editor and find its mesh entry.\n\t\tvar editor = VmdlBulkEditor.Load( source );\n\t\tAssert.AreEqual( 1, editor.MeshEntryCount );\n\n\t\t// And a further bulk edit should work on it.\n\t\tvar props = new MeshEntryProperties { ImportScale = 2.0f };\n\t\tAssert.IsTrue( editor.Apply( props ) );\n\n\t\tvar parsed = Kv3Document.Parse( editor.Source );\n\t\tvar mesh = parsed.FindObjects( \"RenderMeshFile\" ).Single();\n\t\tAssert.AreEqual( \"2.0\", ((Kv3Scalar)mesh.FindField( \"import_scale\" ).Value).Value );\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "UnitTests/LibraryTest.cs",
            "FileName": "LibraryTest.cs",
            "PackageType": "library",
            "CodeKind": "UnitTest",
            "AssetVersionId": 340317,
            "Code": "using Sandbox;\r\n\r\n[TestClass]\r\npublic partial class LibraryTests\r\n{\r\n\t[TestMethod]\r\n\tpublic void SceneTest()\r\n\t{\r\n\t\tvar scene = new Scene();\r\n\t\tusing ( scene.Push() )\r\n\t\t{\r\n\t\t\tvar go = new GameObject();\r\n\r\n\t\t\tAssert.AreEqual( 1, scene.Directory.GameObjectCount );\r\n\t\t}\r\n\t}\r\n\r\n}\r\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Editor/ModelProApp.cs",
            "FileName": "ModelProApp.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 340317,
            "Code": "using System;\n\nnamespace ModelPro.Editor;\n\n/// <summary>\n/// Model Pro - an editor app for bulk editing model (.vmdl) properties and\n/// converting mesh files (.fbx) into models. Two tabs: VMDL bulk editing and\n/// FBX to model conversion.\n/// </summary>\n[EditorApp( \"Model Pro\", \"view_in_ar\", \"Bulk edit model properties and convert meshes to models\" )]\npublic class ModelProApp : Window\n{\n\tpublic ModelProApp()\n\t{\n\t\tWindowTitle = \"Model Pro\";\n\t\tMinimumSize = new Vector2( 800, 500 );\n\t\tSetWindowIcon( \"view_in_ar\" );\n\n\t\tBuildUI();\n\n\t\tShow();\n\t\tStateCookie = \"ModelPro\";\n\t}\n\n\tprivate void BuildUI()\n\t{\n\t\tCanvas = new Widget( null );\n\t\tCanvas.Layout = Layout.Column();\n\t\tCanvas.Layout.Margin = 4;\n\n\t\tvar tabs = new TabWidget( Canvas );\n\t\ttabs.StateCookie = \"ModelProTabs\";\n\t\tCanvas.Layout.Add( tabs, 1 );\n\n\t\ttabs.AddPage( \"DMX/FBX/OBJ\", \"transform\", new MeshConvertTab( tabs ) );\n\t\ttabs.AddPage( \"VMDL\", \"view_in_ar\", new VmdlEditTab( tabs ) );\n\t}\n\n\t[Menu( \"Editor\", \"Model Pro/Open Model Pro\" )]\n\tpublic static void OpenModelPro()\n\t{\n\t\tvar existing = Window.All.OfType<ModelProApp>().FirstOrDefault();\n\t\tif ( existing.IsValid() )\n\t\t{\n\t\t\texisting.Show();\n\t\t\texisting.Focus();\n\t\t\treturn;\n\t\t}\n\n\t\tnew ModelProApp();\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Editor/ModelProSettings.cs",
            "FileName": "ModelProSettings.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 340317,
            "Code": "using ModelPro.Vmdl;\n\nnamespace ModelPro.Editor;\n\n/// <summary>\n/// Remembers the last values used in Model Pro so the next time the app is\n/// opened the controls are pre-filled. Persisted to the editor config folder.\n/// </summary>\npublic class ModelProSettings\n{\n\tconst string Path = \"modelpro/modelpro.json\";\n\n\t// VMDL tab\n\tpublic string VmdlScale { get; set; } = \"1.0\";\n\tpublic ScaleUnit VmdlScaleUnit { get; set; } = ScaleUnit.Inches;\n\tpublic AlignOrigin VmdlAlignX { get; set; } = AlignOrigin.None;\n\tpublic AlignOrigin VmdlAlignY { get; set; } = AlignOrigin.None;\n\tpublic AlignOrigin VmdlAlignZ { get; set; } = AlignOrigin.None;\n\tpublic string VmdlDefaultMaterial { get; set; } = \"\";\n\n\t// FBX tab\n\tpublic CollisionMode FbxCollision { get; set; } = CollisionMode.Hull;\n\tpublic string FbxScale { get; set; } = \"1.0\";\n\tpublic ScaleUnit FbxScaleUnit { get; set; } = ScaleUnit.Inches;\n\tpublic AlignOrigin FbxAlignX { get; set; } = AlignOrigin.None;\n\tpublic AlignOrigin FbxAlignY { get; set; } = AlignOrigin.None;\n\tpublic AlignOrigin FbxAlignZ { get; set; } = AlignOrigin.None;\n\n\tprivate static readonly ModelProSettings _current = new();\n\n\t/// <summary>The single shared instance, loaded from disk on first access.</summary>\n\tpublic static ModelProSettings Current\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( _loaded ) return _current;\n\t\t\t_loaded = true;\n\n\t\t\ttry\n\t\t\t{\n\t\t\t\tglobal::Editor.FileSystem.Config.CreateDirectory( \"modelpro\" );\n\t\t\t\tvar loaded = global::Editor.FileSystem.Config.ReadJsonOrDefault( Path, _current );\n\t\t\t\tif ( loaded is not null )\n\t\t\t\t{\n\t\t\t\t\t// Keep the path the same - only copy values over.\n\t\t\t\t\t_current.VmdlScale = loaded.VmdlScale;\n\t\t\t\t\t_current.VmdlScaleUnit = loaded.VmdlScaleUnit;\n\t\t\t\t\t_current.VmdlAlignX = loaded.VmdlAlignX;\n\t\t\t\t\t_current.VmdlAlignY = loaded.VmdlAlignY;\n\t\t\t\t\t_current.VmdlAlignZ = loaded.VmdlAlignZ;\n\t\t\t\t\t_current.VmdlDefaultMaterial = loaded.VmdlDefaultMaterial;\n\n\t\t\t\t\t_current.FbxCollision = loaded.FbxCollision;\n\t\t\t\t\t_current.FbxScale = loaded.FbxScale;\n\t\t\t\t\t_current.FbxScaleUnit = loaded.FbxScaleUnit;\n\t\t\t\t\t_current.FbxAlignX = loaded.FbxAlignX;\n\t\t\t\t\t_current.FbxAlignY = loaded.FbxAlignY;\n\t\t\t\t\t_current.FbxAlignZ = loaded.FbxAlignZ;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch ( System.Exception e )\n\t\t\t{\n\t\t\t\tLog.Warning( e, \"Model Pro: failed to load settings\" );\n\t\t\t}\n\n\t\t\treturn _current;\n\t\t}\n\t}\n\n\tstatic bool _loaded;\n\n\t/// <summary>Save the current settings to the editor config folder.</summary>\n\tpublic void Save()\n\t{\n\t\ttry\n\t\t{\n\t\t\tglobal::Editor.FileSystem.Config.CreateDirectory( \"modelpro\" );\n\t\t\tglobal::Editor.FileSystem.Config.WriteJson( Path, _current );\n\t\t}\n\t\tcatch ( System.Exception e )\n\t\t{\n\t\t\tLog.Warning( e, \"Model Pro: failed to save settings\" );\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Editor/Vmdl/VmdlBulkEditor.cs",
            "FileName": "VmdlBulkEditor.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 340317,
            "Code": "using System;\nusing System.Globalization;\nusing System.IO;\nusing System.Text;\n\nnamespace ModelPro.Vmdl;\n\n/// <summary>\n/// Align origin options for each axis of a RenderMeshFile entry.\n/// These match the values used in ModelDoc's align_origin_*_type fields.\n/// </summary>\npublic enum AlignOrigin\n{\n\tNone,\n\tCenter,\n\tMins,\n\tMaxs,\n\tBoundsCenter,\n\tBoundsMin,\n\tBoundsMax\n}\n\npublic static class AlignOriginExtensions\n{\n\tpublic static string ToKv3Value( this AlignOrigin value )\n\t{\n\t\treturn value switch\n\t\t{\n\t\t\tAlignOrigin.Center => \"Center\",\n\t\t\tAlignOrigin.Mins => \"Mins\",\n\t\t\tAlignOrigin.Maxs => \"Maxs\",\n\t\t\tAlignOrigin.BoundsCenter => \"BoundsCenter\",\n\t\t\tAlignOrigin.BoundsMin => \"BoundsMin\",\n\t\t\tAlignOrigin.BoundsMax => \"BoundsMax\",\n\t\t\t_ => \"None\"\n\t\t};\n\t}\n}\n\n/// <summary>\n/// Collision modes for converting a mesh file into a model, matching the\n/// options in the asset browser's create-model popup.\n/// </summary>\npublic enum CollisionMode\n{\n\t/// <summary>A convex hull generated from the render geometry (PhysicsHullFromRender).</summary>\n\tHull,\n\n\t/// <summary>An exact triangle mesh from the render geometry (PhysicsMeshFromRender).</summary>\n\tMesh,\n\n\t/// <summary>A hull file that references the source mesh file, with the import scale baked in (PhysicsHullFile).</summary>\n\tFile,\n\n\t/// <summary>No collision.</summary>\n\tNone\n}\n\npublic static class CollisionModeExtensions\n{\n\tpublic static string ToDisplayString( this CollisionMode mode )\n\t{\n\t\treturn mode switch\n\t\t{\n\t\t\tCollisionMode.Hull => \"Convex Hull\",\n\t\t\tCollisionMode.Mesh => \"Exact Mesh\",\n\t\t\tCollisionMode.File => \"File (Hull from FBX)\",\n\t\t\t_ => \"None\"\n\t\t};\n\t}\n}\n\n/// <summary>\n/// The unit the scale value is specified in, matching the units dropdown in the\n/// asset browser's create-model popup. Import scale stored in the vmdl is always\n/// in inches, so the entered value is multiplied by the conversion factor.\n/// </summary>\npublic enum ScaleUnit\n{\n\tInches,\n\tFeet,\n\tMeters,\n\tCentimeters,\n\tMillimeters,\n\tCustom\n}\n\npublic static class ScaleUnitExtensions\n{\n\t/// <summary>How many inches one of these units is. Custom returns 1 - the value is used as-is.</summary>\n\tpublic static float ToInches( this ScaleUnit unit )\n\t{\n\t\treturn unit switch\n\t\t{\n\t\t\tScaleUnit.Feet => 12.0f,\n\t\t\tScaleUnit.Meters => 39.3701f,\n\t\t\tScaleUnit.Centimeters => 0.3937f,\n\t\t\tScaleUnit.Millimeters => 0.03937f,\n\t\t\t_ => 1.0f\n\t\t};\n\t}\n\n\tpublic static string ToDisplayString( this ScaleUnit unit )\n\t{\n\t\treturn unit switch\n\t\t{\n\t\t\tScaleUnit.Feet => \"Feet (ft)\",\n\t\t\tScaleUnit.Meters => \"Meters (m)\",\n\t\t\tScaleUnit.Centimeters => \"Centimeters (cm)\",\n\t\t\tScaleUnit.Millimeters => \"Millimeters (mm)\",\n\t\t\tScaleUnit.Custom => \"Custom\",\n\t\t\t_ => \"Inches (in)\"\n\t\t};\n\t}\n}\n\n/// <summary>\n/// The properties that can be bulk-edited on every RenderMeshFile entry\n/// in a model's RenderMeshList, plus the model's default material group.\n/// </summary>\npublic readonly struct MeshEntryProperties\n{\n\t/// <summary>The final import scale in inches, already converted from the chosen unit.</summary>\n\tpublic float? ImportScale { get; init; }\n\tpublic AlignOrigin? AlignOriginX { get; init; }\n\tpublic AlignOrigin? AlignOriginY { get; init; }\n\tpublic AlignOrigin? AlignOriginZ { get; init; }\n\n\t/// <summary>\n\t/// The global default material for the model's DefaultMaterialGroup\n\t/// (e.g. \"materials/default.vmat\"). Null leaves it unchanged.\n\t/// </summary>\n\tpublic string GlobalDefaultMaterial { get; init; }\n}\n\n/// <summary>A scale typed in a chosen unit, plus the unit it's in.</summary>\npublic readonly struct ScaleInput\n{\n\tpublic float Value { get; init; }\n\tpublic ScaleUnit Unit { get; init; }\n\n\t/// <summary>\n\t/// The value is the import_scale multiplier directly - what gets written to the\n\t/// vmdl. The unit is a display helper: it rescales the number when you switch\n\t/// units (1 inch shows as 0.3937 in cm, since 1 cm = 0.3937 inches).\n\t/// </summary>\n\tpublic float ToImportScale() => Value;\n\n\t/// <summary>\n\t/// Re-express this scale in another unit so the import scale stays the same.\n\t/// E.g. 1 inch converts to 0.3937 centimeters (1 cm = 0.3937 inches).\n\t/// </summary>\n\tpublic ScaleInput ConvertTo( ScaleUnit newUnit )\n\t{\n\t\tif ( newUnit == Unit )\n\t\t\treturn this;\n\n\t\tvar converted = Value * newUnit.ToInches() / Unit.ToInches();\n\t\treturn new ScaleInput { Value = converted, Unit = newUnit };\n\t}\n\n\t/// <summary>\n\t/// Convert a raw import scale into a display value for a given unit.\n\t/// </summary>\n\tpublic static ScaleInput FromImportScale( float importScale, ScaleUnit unit )\n\t{\n\t\treturn new ScaleInput { Value = importScale, Unit = unit };\n\t}\n}\n\n/// <summary>\n/// Loads a .vmdl source file, finds all RenderMeshFile mesh entries and applies\n/// bulk property edits to them, writing the result back while preserving the\n/// original file formatting.\n/// </summary>\npublic sealed class VmdlBulkEditor\n{\n\treadonly Kv3Document _document;\n\treadonly List<Kv3Object> _meshEntries;\n\tstring _source;\n\n\tpublic string Source => _source;\n\n\tpublic int MeshEntryCount => _meshEntries.Count;\n\n\tpublic bool HasRenderMeshList { get; }\n\n\tprivate VmdlBulkEditor( string source )\n\t{\n\t\t_source = source;\n\t\t_document = Kv3Document.Parse( source );\n\n\t\tHasRenderMeshList = _document.FindObjects( \"RenderMeshList\" ).Count > 0;\n\t\t_meshEntries = _document.FindObjects( \"RenderMeshFile\" );\n\t}\n\n\tpublic static VmdlBulkEditor Load( string source ) => new( source );\n\n\tpublic static VmdlBulkEditor LoadFile( string path )\n\t{\n\t\treturn new VmdlBulkEditor( File.ReadAllText( path ) );\n\t}\n\n\t/// <summary>\n\t/// Apply the given properties to every mesh entry. Only properties that are\n\t/// non-null are changed. Returns true if anything was actually modified.\n\t/// </summary>\n\tpublic bool Apply( MeshEntryProperties props )\n\t{\n\t\tvar edits = new List<(int Start, int End, string Text)>();\n\n\t\tforeach ( var entry in _meshEntries )\n\t\t{\n\t\t\tif ( props.ImportScale.HasValue )\n\t\t\t{\n\t\t\t\tSetField( entry, \"import_scale\", FormatNumber( props.ImportScale.Value ), edits );\n\t\t\t}\n\n\t\t\tif ( props.AlignOriginX.HasValue )\n\t\t\t\tSetField( entry, \"align_origin_x_type\", Quote( props.AlignOriginX.Value.ToKv3Value() ), edits );\n\n\t\t\tif ( props.AlignOriginY.HasValue )\n\t\t\t\tSetField( entry, \"align_origin_y_type\", Quote( props.AlignOriginY.Value.ToKv3Value() ), edits );\n\n\t\t\tif ( props.AlignOriginZ.HasValue )\n\t\t\t\tSetField( entry, \"align_origin_z_type\", Quote( props.AlignOriginZ.Value.ToKv3Value() ), edits );\n\t\t}\n\n\t\t// Align origin also needs to be set on the collision shapes so they stay\n\t\t// aligned with the render geometry - the physics nodes share the same\n\t\t// align_origin_*_type fields.\n\t\tif ( props.AlignOriginX.HasValue || props.AlignOriginY.HasValue || props.AlignOriginZ.HasValue )\n\t\t{\n\t\t\tforeach ( var shape in _document.FindObjects( \"PhysicsHullFromRender\" ) )\n\t\t\t\tApplyAlignOrigin( shape, props, edits );\n\t\t\tforeach ( var shape in _document.FindObjects( \"PhysicsMeshFromRender\" ) )\n\t\t\t\tApplyAlignOrigin( shape, props, edits );\n\t\t\tforeach ( var shape in _document.FindObjects( \"PhysicsHullFile\" ) )\n\t\t\t\tApplyAlignOrigin( shape, props, edits );\n\t\t\tforeach ( var shape in _document.FindObjects( \"PhysicsMeshFile\" ) )\n\t\t\t\tApplyAlignOrigin( shape, props, edits );\n\t\t}\n\n\t\tif ( props.GlobalDefaultMaterial is not null )\n\t\t{\n\t\t\tforeach ( var group in _document.FindObjects( \"DefaultMaterialGroup\" ) )\n\t\t\t{\n\t\t\t\tSetField( group, \"global_default_material\", Quote( props.GlobalDefaultMaterial ), edits );\n\t\t\t\tSetField( group, \"use_global_default\", \"true\", edits );\n\t\t\t}\n\t\t}\n\n\t\tif ( edits.Count == 0 )\n\t\t\treturn false;\n\n\t\tvar sb = new StringBuilder( _source );\n\t\tforeach ( var e in edits.OrderByDescending( x => x.Start ) )\n\t\t{\n\t\t\tsb.Remove( e.Start, e.End - e.Start );\n\t\t\tsb.Insert( e.Start, e.Text );\n\t\t}\n\n\t\t_source = sb.ToString();\n\t\treturn true;\n\t}\n\n\tprivate void ApplyAlignOrigin( Kv3Object shape, MeshEntryProperties props, List<(int Start, int End, string Text)> edits )\n\t{\n\t\tif ( props.AlignOriginX.HasValue )\n\t\t\tSetField( shape, \"align_origin_x_type\", Quote( props.AlignOriginX.Value.ToKv3Value() ), edits );\n\n\t\tif ( props.AlignOriginY.HasValue )\n\t\t\tSetField( shape, \"align_origin_y_type\", Quote( props.AlignOriginY.Value.ToKv3Value() ), edits );\n\n\t\tif ( props.AlignOriginZ.HasValue )\n\t\t\tSetField( shape, \"align_origin_z_type\", Quote( props.AlignOriginZ.Value.ToKv3Value() ), edits );\n\t}\n\n\t/// <summary>Returns the current state of a mesh entry's editable properties (from the first entry).</summary>\n\tpublic MeshEntryProperties ReadFirstEntryProperties()\n\t{\n\t\tvar entry = _meshEntries.FirstOrDefault();\n\t\tif ( entry is null )\n\t\t\treturn default;\n\n\t\treturn new MeshEntryProperties\n\t\t{\n\t\t\tImportScale = ReadFloat( entry, \"import_scale\" ),\n\t\t\tAlignOriginX = ReadAlign( entry, \"align_origin_x_type\" ),\n\t\t\tAlignOriginY = ReadAlign( entry, \"align_origin_y_type\" ),\n\t\t\tAlignOriginZ = ReadAlign( entry, \"align_origin_z_type\" ),\n\t\t\tGlobalDefaultMaterial = ReadDefaultMaterial()\n\t\t};\n\t}\n\n\t/// <summary>The global default material path of the first DefaultMaterialGroup, or null.</summary>\n\tpublic string ReadDefaultMaterial()\n\t{\n\t\tvar group = _document.FindObjects( \"DefaultMaterialGroup\" ).FirstOrDefault();\n\t\tif ( group is null )\n\t\t\treturn null;\n\n\t\treturn (group.FindField( \"global_default_material\" )?.Value as Kv3Scalar)?.Value;\n\t}\n\n\tprivate void SetField( Kv3Object obj, string key, string valueText, List<(int Start, int End, string Text)> edits )\n\t{\n\t\tvar field = obj.FindField( key );\n\n\t\tif ( field?.Value is Kv3Scalar scalar )\n\t\t{\n\t\t\tif ( scalar.Raw != valueText )\n\t\t\t\tedits.Add( (scalar.Start, scalar.End, valueText) );\n\t\t\treturn;\n\t\t}\n\n\t\t// Field doesn't exist - insert it right after the _class line.\n\t\tvar classField = obj.FindField( \"_class\" );\n\t\tif ( classField?.Value is not Kv3Scalar classScalar )\n\t\t\treturn;\n\n\t\tint lineEnd = _source.IndexOf( '\\n', classScalar.End );\n\t\tif ( lineEnd < 0 )\n\t\t\tlineEnd = _source.Length;\n\n\t\tint lineStart = _source.LastIndexOf( '\\n', Math.Max( 0, classField.KeyStart - 1 ) ) + 1;\n\t\tvar indent = _source.Substring( lineStart, classField.KeyStart - lineStart );\n\n\t\tstring insert = \"\\n\" + indent + key + \" = \" + valueText;\n\t\tedits.Add( (lineEnd, lineEnd, insert) );\n\t}\n\n\tprivate static float? ReadFloat( Kv3Object obj, string key )\n\t{\n\t\tif ( obj.FindField( key )?.Value is Kv3Scalar s &&\n\t\t\tfloat.TryParse( s.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) )\n\t\t{\n\t\t\treturn value;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tprivate static AlignOrigin? ReadAlign( Kv3Object obj, string key )\n\t{\n\t\tif ( obj.FindField( key )?.Value is Kv3Scalar s )\n\t\t{\n\t\t\treturn s.Value switch\n\t\t\t{\n\t\t\t\t\"Center\" => AlignOrigin.Center,\n\t\t\t\t\"Mins\" => AlignOrigin.Mins,\n\t\t\t\t\"Maxs\" => AlignOrigin.Maxs,\n\t\t\t\t\"BoundsCenter\" => AlignOrigin.BoundsCenter,\n\t\t\t\t\"BoundsMin\" => AlignOrigin.BoundsMin,\n\t\t\t\t\"BoundsMax\" => AlignOrigin.BoundsMax,\n\t\t\t\t_ => AlignOrigin.None\n\t\t\t};\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tprivate static string FormatNumber( float value )\n\t{\n\t\treturn value.ToString( \"0.0########\", CultureInfo.InvariantCulture );\n\t}\n\n\tprivate static string Quote( string value ) => $\"\\\"{value}\\\"\";\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Editor/Assembly.cs",
            "FileName": "Assembly.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 340317,
            "Code": "global using Sandbox;\nglobal using Editor;\nglobal using System.Collections.Generic;\nglobal using System.Linq;\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Editor/MeshConvertTab.cs",
            "FileName": "MeshConvertTab.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 340317,
            "Code": "using System;\nusing System.Globalization;\nusing System.IO;\nusing ModelPro.Vmdl;\n\nnamespace ModelPro.Editor;\n\n/// <summary>\n/// The DMX/FBX/OBJ tab of Model Pro. Pick a folder, it recursively finds every\n/// mesh file (.fbx, .obj, .dmx) and converts them all into .vmdl models with the\n/// chosen collision, scale and align-origin settings.\n/// </summary>\npublic class MeshConvertTab : Widget\n{\n\tTreeView _folderTree;\n\tTreeView _fileList;\n\tLabel _statusLabel;\n\n\tComboBox _collision;\n\tLineEdit _scaleEdit;\n\tComboBox _scaleUnit;\n\tScaleUnit _lastScaleUnit = ScaleUnit.Inches;\n\tComboBox _alignX;\n\tComboBox _alignY;\n\tComboBox _alignZ;\n\tButton _convertButton;\n\tLabel _resultLabel;\n\n\tList<string> _foundMeshFiles = new();\n\n\tpublic MeshConvertTab( Widget parent ) : base( parent )\n\t{\n\t\tBuildUI();\n\t\tLoadSettings();\n\t}\n\n\tprivate void BuildUI()\n\t{\n\t\tLayout = Layout.Column();\n\t\tLayout.Spacing = 4;\n\n\t\tvar split = Layout.AddRow();\n\t\tsplit.Spacing = 8;\n\n\t\tvar left = split.AddColumn();\n\t\tleft.Spacing = 4;\n\t\tleft.Add( new Label( \"<b>Folder</b>\" ) );\n\n\t\tvar treeScroll = left.Add( new ScrollArea( this ), 1 );\n\t\ttreeScroll.Canvas = new Widget();\n\t\ttreeScroll.Canvas.Layout = Layout.Column();\n\t\ttreeScroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 2 );\n\n\t\t_folderTree = new TreeView( treeScroll.Canvas );\n\t\ttreeScroll.Canvas.Layout.Add( _folderTree );\n\t\t_folderTree.ExpandForSelection = true;\n\n\t\tvar rootDir = Sandbox.Project.Current?.RootDirectory;\n\t\tif ( rootDir is not null )\n\t\t{\n\t\t\tvar root = new FolderNode( rootDir.FullName, OnFolderSelected );\n\t\t\t_folderTree.AddItem( root );\n\t\t\t_folderTree.Open( root );\n\t\t}\n\n\t\tvar right = split.AddColumn();\n\t\tright.Spacing = 4;\n\n\t\t_statusLabel = right.Add( new Label( \"Select a folder to search for mesh files (.fbx, .obj, .dmx).\" ) );\n\t\t_statusLabel.WordWrap = true;\n\n\t\tvar listScroll = right.Add( new ScrollArea( this ), 1 );\n\t\tlistScroll.Canvas = new Widget();\n\t\tlistScroll.Canvas.Layout = Layout.Column();\n\t\tlistScroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 2 );\n\n\t\t_fileList = new TreeView( listScroll.Canvas );\n\t\tlistScroll.Canvas.Layout.Add( _fileList );\n\n\t\tvar group = right.Add( new Widget() );\n\t\tvar grid = Layout.Grid();\n\t\tgrid.Spacing = 4;\n\t\tgroup.Layout = grid;\n\n\t\tint row = 0;\n\n\t\tgrid.AddCell( 0, row, new Label( \"Collision\" ) );\n\t\t_collision = new ComboBox() { FixedWidth = 150 };\n\t\tforeach ( var mode in Enum.GetValues<CollisionMode>() )\n\t\t\t_collision.AddItem( mode.ToDisplayString() );\n\t\t_collision.TrySelectNamed( CollisionMode.Hull.ToDisplayString() );\n\t\tgrid.AddCell( 1, row, _collision );\n\t\trow++;\n\n\t\tgrid.AddCell( 0, row, new Label( \"Scale\" ) );\n\n\t\tvar scaleRow = Layout.Row();\n\t\tscaleRow.Spacing = 4;\n\t\t_scaleEdit = new LineEdit() { Text = \"1.0\", FixedWidth = 90 };\n\t\t_scaleUnit = new ComboBox() { FixedWidth = 130 };\n\t\tforeach ( var unit in Enum.GetValues<ScaleUnit>() )\n\t\t{\n\t\t\tvar u = unit;\n\t\t\t_scaleUnit.AddItem( u.ToDisplayString(), onSelected: () => OnScaleUnitChanged( u ) );\n\t\t}\n\t\t_scaleUnit.TrySelectNamed( ScaleUnit.Inches.ToDisplayString() );\n\t\tscaleRow.Add( _scaleEdit );\n\t\tscaleRow.Add( _scaleUnit );\n\t\tgrid.AddCell( 1, row, scaleRow );\n\t\trow++;\n\n\t\tgrid.AddCell( 0, row, new Label( \"Align Origin X\" ) );\n\t\t_alignX = CreateAlignCombo();\n\t\tgrid.AddCell( 1, row, _alignX );\n\t\trow++;\n\n\t\tgrid.AddCell( 0, row, new Label( \"Align Origin Y\" ) );\n\t\t_alignY = CreateAlignCombo();\n\t\tgrid.AddCell( 1, row, _alignY );\n\t\trow++;\n\n\t\tgrid.AddCell( 0, row, new Label( \"Align Origin Z\" ) );\n\t\t_alignZ = CreateAlignCombo();\n\t\tgrid.AddCell( 1, row, _alignZ );\n\t\trow++;\n\n\t\t_convertButton = new Button( \"Convert to N models\", \"transform\" );\n\t\t_convertButton.Clicked += ConvertAll;\n\t\tgrid.AddCell( 0, row, _convertButton );\n\t\trow++;\n\n\t\t_resultLabel = new Label( \"\" );\n\t\t_resultLabel.WordWrap = true;\n\t\tright.Add( _resultLabel );\n\t}\n\n\tprivate ComboBox CreateAlignCombo()\n\t{\n\t\tvar combo = new ComboBox() { FixedWidth = 120 };\n\t\tcombo.AddItem( \"None\" );\n\t\tcombo.AddItem( \"BoundsCenter\" );\n\t\tcombo.AddItem( \"BoundsMin\" );\n\t\tcombo.AddItem( \"BoundsMax\" );\n\t\tcombo.TrySelectNamed( \"None\" );\n\t\treturn combo;\n\t}\n\n\t/// <summary>Restore the last used values into the controls.</summary>\n\tprivate void LoadSettings()\n\t{\n\t\tvar s = ModelProSettings.Current;\n\n\t\t_collision.TrySelectNamed( s.FbxCollision.ToDisplayString() );\n\t\t_scaleEdit.Text = s.FbxScale;\n\t\t_lastScaleUnit = s.FbxScaleUnit;\n\t\t_scaleUnit.TrySelectNamed( s.FbxScaleUnit.ToDisplayString() );\n\t\t_alignX.TrySelectNamed( s.FbxAlignX.ToKv3Value() );\n\t\t_alignY.TrySelectNamed( s.FbxAlignY.ToKv3Value() );\n\t\t_alignZ.TrySelectNamed( s.FbxAlignZ.ToKv3Value() );\n\t}\n\n\t/// <summary>Remember the current values for next time.</summary>\n\tprivate void SaveSettings()\n\t{\n\t\tvar s = ModelProSettings.Current;\n\t\ts.FbxCollision = ParseCollision( _collision.CurrentText );\n\t\ts.FbxScale = _scaleEdit.Text;\n\t\ts.FbxScaleUnit = ParseScaleUnit( _scaleUnit.CurrentText );\n\t\ts.FbxAlignX = ParseAlign( _alignX.CurrentText );\n\t\ts.FbxAlignY = ParseAlign( _alignY.CurrentText );\n\t\ts.FbxAlignZ = ParseAlign( _alignZ.CurrentText );\n\t\ts.Save();\n\t}\n\n\tprivate void OnFolderSelected( string folder )\n\t{\n\t\t_statusLabel.Text = $\"Searching <b>{folder}</b>...\";\n\t\t_resultLabel.Text = \"\";\n\n\t\t_foundMeshFiles = Directory.GetFiles( folder, \"*.fbx\", SearchOption.AllDirectories )\n\t\t\t.Concat( Directory.GetFiles( folder, \"*.obj\", SearchOption.AllDirectories ) )\n\t\t\t.Concat( Directory.GetFiles( folder, \"*.dmx\", SearchOption.AllDirectories ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToList();\n\n\t\t_fileList.Clear();\n\t\tforeach ( var file in _foundMeshFiles )\n\t\t\t_fileList.AddItem( new FileNode( file, 0 ) );\n\n\t\t_statusLabel.Text = $\"Found <b>{_foundMeshFiles.Count}</b> mesh files in <b>{folder}</b>.\";\n\n\t\t_convertButton.Text = _foundMeshFiles.Count == 0\n\t\t\t? \"Convert to 0 models\"\n\t\t\t: $\"Convert to {_foundMeshFiles.Count} models\";\n\t}\n\n\tprivate void ConvertAll()\n\t{\n\t\tif ( _foundMeshFiles.Count == 0 )\n\t\t\treturn;\n\n\t\tvar options = new MeshToModelOptions\n\t\t{\n\t\t\tCollision = ParseCollision( _collision.CurrentText ),\n\t\t\tImportScale = ReadScale() ?? 1.0f,\n\t\t\tAlignOriginX = ParseAlign( _alignX.CurrentText ),\n\t\t\tAlignOriginY = ParseAlign( _alignY.CurrentText ),\n\t\t\tAlignOriginZ = ParseAlign( _alignZ.CurrentText )\n\t\t};\n\n\t\tvar assetsRoot = Sandbox.Project.Current?.GetAssetsPath();\n\t\tif ( string.IsNullOrEmpty( assetsRoot ) )\n\t\t\treturn;\n\n\t\tint created = 0;\n\t\tint skipped = 0;\n\t\tvar errors = new List<string>();\n\n\t\tforeach ( var mesh in _foundMeshFiles )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar vmdlPath = Path.ChangeExtension( mesh, \".vmdl\" );\n\n\t\t\t\t// Don't overwrite an existing model.\n\t\t\t\tif ( File.Exists( vmdlPath ) )\n\t\t\t\t{\n\t\t\t\t\tskipped++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tvar relative = mesh.Replace( '\\\\', '/' );\n\t\t\t\tvar assetsRootNorm = assetsRoot.Replace( '\\\\', '/' );\n\t\t\t\tif ( relative.StartsWith( assetsRootNorm, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t\trelative = relative.Substring( assetsRootNorm.Length ).TrimStart( '/' );\n\n\t\t\t\tvar source = VmdlGenerator.Generate( relative, options );\n\t\t\t\tFile.WriteAllText( vmdlPath, source );\n\n\t\t\t\tRegisterAndCompile( vmdlPath );\n\t\t\t\tcreated++;\n\t\t\t}\n\t\t\tcatch ( Exception e )\n\t\t\t{\n\t\t\t\terrors.Add( $\"{Path.GetFileName( mesh )}: {e.Message}\" );\n\t\t\t}\n\t\t}\n\n\t\tvar msg = $\"Created <b>{created}</b> models\" +\n\t\t\t(skipped > 0 ? $\" ({skipped} skipped \u2014 already exist)\" : \"\") + \".\";\n\t\tif ( errors.Count > 0 )\n\t\t\tmsg += $\"\\n\\nErrors ({errors.Count}):\\n{string.Join( \"\\n\", errors.Take( 8 ) )}\";\n\n\t\t_resultLabel.Text = msg;\n\n\t\tSaveSettings();\n\t}\n\n\tprivate static void RegisterAndCompile( string vmdlPath )\n\t{\n\t\tvar asset = AssetSystem.RegisterFile( vmdlPath );\n\t\tif ( asset is null )\n\t\t{\n\t\t\tLog.Warning( $\"Model Pro: failed to register {vmdlPath}\" );\n\t\t\treturn;\n\t\t}\n\n\t\tasset.Compile( true );\n\t}\n\n\tprivate float? ReadScale()\n\t{\n\t\tif ( !float.TryParse( _scaleEdit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) )\n\t\t\treturn null;\n\n\t\tvar unit = ParseScaleUnit( _scaleUnit.CurrentText );\n\t\treturn new ScaleInput { Value = value, Unit = unit }.ToImportScale();\n\t}\n\n\tprivate void OnScaleUnitChanged( ScaleUnit newUnit )\n\t{\n\t\tif ( newUnit == _lastScaleUnit )\n\t\t\treturn;\n\n\t\tif ( float.TryParse( _scaleEdit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var current ) )\n\t\t{\n\t\t\tvar converted = new ScaleInput { Value = current, Unit = _lastScaleUnit }.ConvertTo( newUnit );\n\t\t\t_scaleEdit.Text = converted.Value.ToString( \"0.0########\", CultureInfo.InvariantCulture );\n\t\t}\n\n\t\t_lastScaleUnit = newUnit;\n\t}\n\n\tprivate static ScaleUnit ParseScaleUnit( string text )\n\t{\n\t\tforeach ( var unit in Enum.GetValues<ScaleUnit>() )\n\t\t{\n\t\t\tif ( string.Equals( unit.ToDisplayString(), text, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\treturn unit;\n\t\t}\n\n\t\treturn ScaleUnit.Inches;\n\t}\n\n\tprivate static CollisionMode ParseCollision( string text )\n\t{\n\t\tforeach ( var mode in Enum.GetValues<CollisionMode>() )\n\t\t{\n\t\t\tif ( string.Equals( mode.ToDisplayString(), text, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\treturn mode;\n\t\t}\n\n\t\treturn CollisionMode.Hull;\n\t}\n\n\tprivate static AlignOrigin ParseAlign( string text )\n\t{\n\t\treturn text switch\n\t\t{\n\t\t\t\"BoundsCenter\" => AlignOrigin.BoundsCenter,\n\t\t\t\"BoundsMin\" => AlignOrigin.BoundsMin,\n\t\t\t\"BoundsMax\" => AlignOrigin.BoundsMax,\n\t\t\t\"Center\" => AlignOrigin.Center,\n\t\t\t\"Mins\" => AlignOrigin.Mins,\n\t\t\t\"Maxs\" => AlignOrigin.Maxs,\n\t\t\t_ => AlignOrigin.None\n\t\t};\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "UnitTests/AlignOriginTests.cs",
            "FileName": "AlignOriginTests.cs",
            "PackageType": "library",
            "CodeKind": "UnitTest",
            "AssetVersionId": 340317,
            "Code": "using System;\nusing System.Linq;\nusing Microsoft.VisualStudio.TestTools.UnitTesting;\nusing ModelPro.Vmdl;\n\n[TestClass]\npublic class AlignOriginTests\n{\n\tconst string ToolFile = \"\"\"\n\t{\n\t\trootNode = \n\t\t{\n\t\t\t_class = \"RootNode\"\n\t\t\tchildren = \n\t\t\t[\n\t\t\t\t{\n\t\t\t\t\t_class = \"RenderMeshList\"\n\t\t\t\t\tchildren = \n\t\t\t\t\t[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\t\tfilename = \"models/vehicle_01_a.fbx\"\n\t\t\t\t\t\t\timport_scale = 1.0\n\t\t\t\t\t\t\talign_origin_x_type = \"Center\"\n\t\t\t\t\t\t\talign_origin_y_type = \"Center\"\n\t\t\t\t\t\t\talign_origin_z_type = \"Mins\"\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t]\n\t\t}\n\t}\n\t\"\"\";\n\n\t[TestMethod]\n\tpublic void AllAlignOriginsRoundTrip()\n\t{\n\t\tforeach ( var value in Enum.GetValues<AlignOrigin>() )\n\t\t{\n\t\t\tvar kv3 = value.ToKv3Value();\n\n\t\t\tvar editor = VmdlBulkEditor.Load( ToolFile );\n\t\t\tvar props = new MeshEntryProperties\n\t\t\t{\n\t\t\t\tAlignOriginX = value,\n\t\t\t\tAlignOriginY = value,\n\t\t\t\tAlignOriginZ = value\n\t\t\t};\n\n\t\t\tAssert.IsTrue( editor.Apply( props ), $\"{value} should change the file\" );\n\n\t\t\tvar parsed = Kv3Document.Parse( editor.Source );\n\t\t\tvar entry = parsed.FindObjects( \"RenderMeshFile\" ).Single();\n\t\t\tAssert.AreEqual( kv3, ((Kv3Scalar)entry.FindField( \"align_origin_x_type\" ).Value).Value, value.ToString() );\n\t\t}\n\t}\n\n\t[TestMethod]\n\tpublic void BoundsValuesAreWritable()\n\t{\n\t\tvar editor = VmdlBulkEditor.Load( ToolFile );\n\t\tvar props = new MeshEntryProperties\n\t\t{\n\t\t\tImportScale = 0.3937f,\n\t\t\tAlignOriginX = AlignOrigin.BoundsCenter,\n\t\t\tAlignOriginY = AlignOrigin.BoundsCenter,\n\t\t\tAlignOriginZ = AlignOrigin.BoundsMin\n\t\t};\n\n\t\tAssert.IsTrue( editor.Apply( props ), \"Bounds* align values should apply\" );\n\n\t\tvar parsed = Kv3Document.Parse( editor.Source );\n\t\tvar entry = parsed.FindObjects( \"RenderMeshFile\" ).Single();\n\t\tAssert.AreEqual( \"0.3937\", ((Kv3Scalar)entry.FindField( \"import_scale\" ).Value).Value );\n\t\tAssert.AreEqual( \"BoundsCenter\", ((Kv3Scalar)entry.FindField( \"align_origin_x_type\" ).Value).Value );\n\t\tAssert.AreEqual( \"BoundsCenter\", ((Kv3Scalar)entry.FindField( \"align_origin_y_type\" ).Value).Value );\n\t\tAssert.AreEqual( \"BoundsMin\", ((Kv3Scalar)entry.FindField( \"align_origin_z_type\" ).Value).Value );\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "UnitTests/UnitTest.cs",
            "FileName": "UnitTest.cs",
            "PackageType": "library",
            "CodeKind": "UnitTest",
            "AssetVersionId": 340317,
            "Code": "global using Microsoft.VisualStudio.TestTools.UnitTesting;\r\n\r\n[TestClass]\r\npublic class TestInit\r\n{\r\n\tpublic static Sandbox.TestAppSystem AppSystem;\r\n\r\n\t[AssemblyInitialize]\r\n\tpublic static void AssemblyInitialize( TestContext context )\r\n\t{\r\n\t\tAppSystem = new Sandbox.TestAppSystem();\r\n\t\tAppSystem.Init();\r\n\t}\r\n\r\n\t[AssemblyCleanup]\r\n\tpublic static void AssemblyCleanup()\r\n\t{\r\n\t\tAppSystem.Shutdown();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Editor/Vmdl/VmdlGenerator.cs",
            "FileName": "VmdlGenerator.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 340317,
            "Code": "using System.Globalization;\nusing System.Text;\n\nnamespace ModelPro.Vmdl;\n\n/// <summary>\n/// Options for converting a mesh file (.fbx, .obj, .dmx) into a model (.vmdl),\n/// matching the asset browser's create-model popup.\n/// </summary>\npublic struct MeshToModelOptions\n{\n\t/// <summary>The collision shape to generate for the model.</summary>\n\tpublic CollisionMode Collision { get; set; }\n\n\t/// <summary>The import scale (in inches) to apply to the mesh.</summary>\n\tpublic float ImportScale { get; set; }\n\n\tpublic AlignOrigin AlignOriginX { get; set; }\n\tpublic AlignOrigin AlignOriginY { get; set; }\n\tpublic AlignOrigin AlignOriginZ { get; set; }\n}\n\n/// <summary>\n/// Generates the KV3 text of a model (.vmdl) file from a source mesh file,\n/// writing the same structure ModelDoc produces so it compiles cleanly.\n/// </summary>\npublic static class VmdlGenerator\n{\n\tconst string Header = \"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->\";\n\n\t/// <summary>\n\t/// Build the vmdl source text for a mesh file at the given content-relative\n\t/// path (e.g. \"models/foo.fbx\").\n\t/// </summary>\n\tpublic static string Generate( string meshRelativePath, MeshToModelOptions options )\n\t{\n\t\tvar sb = new StringBuilder();\n\n\t\tsb.AppendLine( Header );\n\t\tsb.AppendLine( \"{\" );\n\t\tsb.AppendLine( \"\\trootNode = \" );\n\t\tsb.AppendLine( \"\\t{\" );\n\t\tsb.AppendLine( \"\\t\\t_class = \\\"RootNode\\\"\" );\n\t\tsb.AppendLine( \"\\t\\tchildren = \" );\n\t\tsb.AppendLine( \"\\t\\t[\" );\n\n\t\t// Material group\n\t\tsb.AppendLine( \"\\t\\t\\t{\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t_class = \\\"MaterialGroupList\\\"\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\tchildren = \" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t[\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t{\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t_class = \\\"DefaultMaterialGroup\\\"\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tremaps = [  ]\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tuse_global_default = true\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tglobal_default_material = \\\"materials/default.vmat\\\"\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t},\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t]\" );\n\t\tsb.AppendLine( \"\\t\\t\\t},\" );\n\n\t\t// Physics shape list\n\t\tAppendPhysicsShapeList( sb, meshRelativePath, options );\n\n\t\t// Render mesh list\n\t\tAppendRenderMeshList( sb, meshRelativePath, options );\n\n\t\tsb.AppendLine( \"\\t\\t]\" );\n\t\tsb.AppendLine( \"\\t\\tmodel_archetype = \\\"\\\"\" );\n\t\tsb.AppendLine( \"\\t\\tprimary_associated_entity = \\\"\\\"\" );\n\t\tsb.AppendLine( \"\\t\\tanim_graph_name = \\\"\\\"\" );\n\t\tsb.AppendLine( \"\\t\\tbase_model_name = \\\"\\\"\" );\n\t\tsb.AppendLine( \"\\t}\" );\n\t\tsb.AppendLine( \"}\" );\n\n\t\treturn sb.ToString();\n\t}\n\n\tprivate static void AppendRenderMeshList( StringBuilder sb, string meshRelativePath, MeshToModelOptions options )\n\t{\n\t\tsb.AppendLine( \"\\t\\t\\t{\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t_class = \\\"RenderMeshList\\\"\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\tchildren = \" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t[\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t{\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t_class = \\\"RenderMeshFile\\\"\" );\n\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\tfilename = \\\"{meshRelativePath}\\\"\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\timport_translation = [ 0.0, 0.0, 0.0 ]\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\timport_rotation = [ 0.0, 0.0, 0.0 ]\" );\n\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\timport_scale = {FormatNumber( options.ImportScale )}\" );\n\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_x_type = \\\"{options.AlignOriginX.ToKv3Value()}\\\"\" );\n\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_y_type = \\\"{options.AlignOriginY.ToKv3Value()}\\\"\" );\n\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_z_type = \\\"{options.AlignOriginZ.ToKv3Value()}\\\"\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tparent_bone = \\\"\\\"\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\timport_filter = \" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t{\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t\\texclude_by_default = false\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t}\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t},\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t]\" );\n\t\tsb.AppendLine( \"\\t\\t\\t},\" );\n\t}\n\n\tprivate static void AppendPhysicsShapeList( StringBuilder sb, string meshRelativePath, MeshToModelOptions options )\n\t{\n\t\tif ( options.Collision == CollisionMode.None )\n\t\t\treturn;\n\n\t\tsb.AppendLine( \"\\t\\t\\t{\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t_class = \\\"PhysicsShapeList\\\"\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\tchildren = \" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t[\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t{\" );\n\n\t\tswitch ( options.Collision )\n\t\t{\n\t\t\tcase CollisionMode.Mesh:\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t_class = \\\"PhysicsMeshFromRender\\\"\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tparent_bone = \\\"\\\"\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tsurface_prop = \\\"default\\\"\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tcollision_tags = \\\"solid\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_x_type = \\\"{options.AlignOriginX.ToKv3Value()}\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_y_type = \\\"{options.AlignOriginY.ToKv3Value()}\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_z_type = \\\"{options.AlignOriginZ.ToKv3Value()}\\\"\" );\n\t\t\t\tbreak;\n\n\t\t\tcase CollisionMode.File:\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t_class = \\\"PhysicsHullFile\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\tfilename = \\\"{meshRelativePath}\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\timport_scale = {FormatNumber( options.ImportScale )}\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tparent_bone = \\\"\\\"\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tsurface_prop = \\\"default\\\"\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tcollision_tags = \\\"solid\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_x_type = \\\"{options.AlignOriginX.ToKv3Value()}\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_y_type = \\\"{options.AlignOriginY.ToKv3Value()}\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_z_type = \\\"{options.AlignOriginZ.ToKv3Value()}\\\"\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tfaceMergeAngle = 20.0\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tmaxHullVertices = 32\" );\n\t\t\t\tbreak;\n\n\t\t\tcase CollisionMode.Hull:\n\t\t\tdefault:\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t_class = \\\"PhysicsHullFromRender\\\"\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tparent_bone = \\\"\\\"\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tsurface_prop = \\\"default\\\"\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tcollision_tags = \\\"solid\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_x_type = \\\"{options.AlignOriginX.ToKv3Value()}\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_y_type = \\\"{options.AlignOriginY.ToKv3Value()}\\\"\" );\n\t\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\talign_origin_z_type = \\\"{options.AlignOriginZ.ToKv3Value()}\\\"\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tfaceMergeAngle = 20.0\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tmaxHullVertices = 32\" );\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\thull_mode = \\\"HullPerElement\\\"\" );\n\t\t\t\tbreak;\n\t\t}\n\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t},\" );\n\t\tsb.AppendLine( \"\\t\\t\\t\\t]\" );\n\t\tsb.AppendLine( \"\\t\\t\\t},\" );\n\t}\n\n\tprivate static string FormatNumber( float value )\n\t{\n\t\treturn value.ToString( \"0.0########\", CultureInfo.InvariantCulture );\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Editor/FileNodes.cs",
            "FileName": "FileNodes.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 340317,
            "Code": "using System;\nusing System.IO;\n\nnamespace ModelPro.Editor;\n\n/// <summary>A tree node representing a folder on disk.</summary>\npublic class FolderNode : TreeNode<DirectoryInfo>\n{\n\tpublic string FullPath { get; }\n\tAction<string> _onSelected;\n\n\tpublic override string Name => System.IO.Path.GetFileName( FullPath );\n\n\tpublic FolderNode( string fullPath, Action<string> onSelected ) : base( new DirectoryInfo( fullPath ) )\n\t{\n\t\tFullPath = fullPath;\n\t\t_onSelected = onSelected;\n\t\tHeight = Theme.RowHeight;\n\t}\n\n\tprotected override void BuildChildren()\n\t{\n\t\tClear();\n\n\t\tforeach ( var dir in Directory.GetDirectories( FullPath )\n\t\t\t\t\t .OrderBy( x => x, StringComparer.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tAddItem( new FolderNode( dir, _onSelected ) );\n\t\t}\n\t}\n\n\tpublic override void OnSelectionChanged( bool state )\n\t{\n\t\tif ( state )\n\t\t\t_onSelected?.Invoke( FullPath );\n\t}\n\n\tpublic override void OnPaint( VirtualWidget item )\n\t{\n\t\tPaintSelection( item );\n\n\t\tvar rect = item.Rect;\n\n\t\tPaint.SetPen( Theme.Yellow );\n\t\tPaint.DrawIcon( rect, \"folder\", 18, TextFlag.LeftCenter );\n\n\t\trect.Left += 24;\n\t\tPaint.SetPen( Theme.Text );\n\t\tPaint.SetDefaultFont();\n\t\tPaint.DrawText( rect, Name, TextFlag.LeftCenter );\n\t}\n\n\tpublic override string GetTooltip()\n\t{\n\t\treturn FullPath;\n\t}\n}\n\n/// <summary>A tree node representing a file.</summary>\npublic class FileNode : TreeNode<string>\n{\n\tpublic string FullPath { get; }\n\tpublic int MeshEntryCount { get; }\n\n\tpublic override string Name => System.IO.Path.GetFileName( FullPath );\n\n\tpublic FileNode( string fullPath, int meshEntryCount ) : base( fullPath )\n\t{\n\t\tFullPath = fullPath;\n\t\tMeshEntryCount = meshEntryCount;\n\t\tHeight = Theme.RowHeight;\n\t}\n\n\tpublic override void OnPaint( VirtualWidget item )\n\t{\n\t\tPaintSelection( item );\n\n\t\tvar rect = item.Rect;\n\n\t\tPaint.SetPen( Theme.Text );\n\t\tPaint.SetDefaultFont();\n\t\tPaint.DrawText( rect, MeshEntryCount > 0 ? $\"{Name}  ({MeshEntryCount} meshes)\" : Name, TextFlag.LeftCenter );\n\t}\n\n\tpublic override string GetTooltip()\n\t{\n\t\treturn FullPath;\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": ".obj/__compiler_extra.cs",
            "FileName": "__compiler_extra.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 340317,
            "Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Model Pro\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"modelpro\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"bluedock\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"bluedock.modelpro\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"28\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-08-09T08:00:58.3436594Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.113.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.113.0\")]"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "UnitTests/Kv3Tests.cs",
            "FileName": "Kv3Tests.cs",
            "PackageType": "library",
            "CodeKind": "UnitTest",
            "AssetVersionId": 340317,
            "Code": "using System;\nusing Microsoft.VisualStudio.TestTools.UnitTesting;\nusing ModelPro.Vmdl;\n\n[TestClass]\npublic class Kv3Tests\n{\n\tconst string SampleVmdl = \"\"\"\n\t<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->\n\t{\n\t\trootNode = \n\t\t{\n\t\t\t_class = \"RootNode\"\n\t\t\tchildren = \n\t\t\t[\n\t\t\t\t{\n\t\t\t\t\t_class = \"RenderMeshList\"\n\t\t\t\t\tchildren = \n\t\t\t\t\t[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\t\tname = \"Torso_LOD0\"\n\t\t\t\t\t\t\tchildren = \n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t_class = \"RenderMeshMarkup\"\n\t\t\t\t\t\t\t\t\tuse_expensive_tangents = true\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\tfilename = \"models/citizen/citizen.fbx\"\n\t\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]\n\t\t\t\t\t\t\timport_rotation = [ 0.0, 0.0, 0.0 ]\n\t\t\t\t\t\t\timport_scale = 1.0\n\t\t\t\t\t\t\talign_origin_x_type = \"None\"\n\t\t\t\t\t\t\talign_origin_y_type = \"None\"\n\t\t\t\t\t\t\talign_origin_z_type = \"None\"\n\t\t\t\t\t\t\tparent_bone = \"\"\n\t\t\t\t\t\t\timport_filter = \n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\texclude_by_default = true\n\t\t\t\t\t\t\t\texception_list = \n\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t\t\"CitizenTorso_LOD0\",\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\t\tname = \"Arms_LOD0\"\n\t\t\t\t\t\t\tfilename = \"models/citizen/citizen_arms.fbx\"\n\t\t\t\t\t\t\timport_scale = 1.0\n\t\t\t\t\t\t\talign_origin_x_type = \"None\"\n\t\t\t\t\t\t\talign_origin_y_type = \"None\"\n\t\t\t\t\t\t\talign_origin_z_type = \"None\"\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t]\n\t\t\tmodel_archetype = \"\"\n\t\t}\n\t}\n\t\"\"\";\n\n\t[TestMethod]\n\tpublic void FindsMeshEntries()\n\t{\n\t\tvar doc = Kv3Document.Parse( SampleVmdl );\n\t\tAssert.AreEqual( 2, doc.FindObjects( \"RenderMeshFile\" ).Count );\n\t\tAssert.AreEqual( 1, doc.FindObjects( \"RenderMeshList\" ).Count );\n\t}\n\n\t[TestMethod]\n\tpublic void AppliesScaleAndAlign()\n\t{\n\t\tvar editor = VmdlBulkEditor.Load( SampleVmdl );\n\t\tAssert.AreEqual( 2, editor.MeshEntryCount );\n\n\t\tvar props = new MeshEntryProperties\n\t\t{\n\t\t\tImportScale = 0.5f,\n\t\t\tAlignOriginX = AlignOrigin.Center,\n\t\t\tAlignOriginY = AlignOrigin.Mins,\n\t\t\tAlignOriginZ = AlignOrigin.Maxs\n\t\t};\n\n\t\tAssert.IsTrue( editor.Apply( props ) );\n\n\t\tvar result = Kv3Document.Parse( editor.Source );\n\t\tvar entries = result.FindObjects( \"RenderMeshFile\" );\n\t\tAssert.AreEqual( 2, entries.Count );\n\n\t\tforeach ( var entry in entries )\n\t\t{\n\t\t\tAssert.AreEqual( \"0.5\", (entry.FindField( \"import_scale\" ).Value as Kv3Scalar)?.Value );\n\t\t\tAssert.AreEqual( \"Center\", (entry.FindField( \"align_origin_x_type\" ).Value as Kv3Scalar)?.Value );\n\t\t\tAssert.AreEqual( \"Mins\", (entry.FindField( \"align_origin_y_type\" ).Value as Kv3Scalar)?.Value );\n\t\t\tAssert.AreEqual( \"Maxs\", (entry.FindField( \"align_origin_z_type\" ).Value as Kv3Scalar)?.Value );\n\t\t}\n\t}\n\n\t[TestMethod]\n\tpublic void ConvertsScaleUnits()\n\t{\n\t\t// The value in the field is the import_scale multiplier written to the vmdl.\n\t\tAssert.AreEqual( 1.0f, new ScaleInput { Value = 1, Unit = ScaleUnit.Inches }.ToImportScale(), 0.0001f );\n\t\tAssert.AreEqual( 0.3937f, new ScaleInput { Value = 0.3937f, Unit = ScaleUnit.Centimeters }.ToImportScale(), 0.0001f );\n\n\t\t// Switching 1 inch to cm re-expresses it as 0.3937 (1 cm = 0.3937 inches).\n\t\tvar inches = new ScaleInput { Value = 1, Unit = ScaleUnit.Inches };\n\t\tvar cm = inches.ConvertTo( ScaleUnit.Centimeters );\n\t\tAssert.AreEqual( 0.3937f, cm.Value, 0.0001f );\n\t\tAssert.AreEqual( 0.3937f, cm.ToImportScale(), 0.0001f );\n\n\t\t// A cm model (import_scale 0.3937) reads back as itself.\n\t\tvar fromCm = ScaleInput.FromImportScale( 0.3937f, ScaleUnit.Centimeters );\n\t\tAssert.AreEqual( 0.3937f, fromCm.Value, 0.0001f );\n\t\tAssert.AreEqual( 0.3937f, fromCm.ToImportScale(), 0.0001f );\n\t}\n\n\t[TestMethod]\n\tpublic void NoOpWhenValuesMatch()\n\t{\n\t\tvar editor = VmdlBulkEditor.Load( SampleVmdl );\n\n\t\tvar props = new MeshEntryProperties\n\t\t{\n\t\t\tImportScale = 1.0f,\n\t\t\tAlignOriginX = AlignOrigin.None,\n\t\t\tAlignOriginY = AlignOrigin.None,\n\t\t\tAlignOriginZ = AlignOrigin.None\n\t\t};\n\n\t\tAssert.IsFalse( editor.Apply( props ) );\n\t\tAssert.AreEqual( SampleVmdl, editor.Source );\n\t}\n\n\t[TestMethod]\n\tpublic void ReadsFirstEntryProperties()\n\t{\n\t\tvar editor = VmdlBulkEditor.Load( SampleVmdl );\n\t\tvar props = editor.ReadFirstEntryProperties();\n\n\t\tAssert.AreEqual( 1.0f, props.ImportScale );\n\t\tAssert.AreEqual( AlignOrigin.None, props.AlignOriginX );\n\t\tAssert.AreEqual( AlignOrigin.None, props.AlignOriginY );\n\t\tAssert.AreEqual( AlignOrigin.None, props.AlignOriginZ );\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "UnitTests/MaterialGroupTests.cs",
            "FileName": "MaterialGroupTests.cs",
            "PackageType": "library",
            "CodeKind": "UnitTest",
            "AssetVersionId": 340317,
            "Code": "using System.Linq;\nusing Microsoft.VisualStudio.TestTools.UnitTesting;\nusing ModelPro.Vmdl;\n\n[TestClass]\npublic class MaterialGroupTests\n{\n\tconst string WithMaterial = \"\"\"\n\t{\n\t\trootNode = \n\t\t{\n\t\t\t_class = \"RootNode\"\n\t\t\tchildren = \n\t\t\t[\n\t\t\t\t{\n\t\t\t\t\t_class = \"MaterialGroupList\"\n\t\t\t\t\tchildren = \n\t\t\t\t\t[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"DefaultMaterialGroup\"\n\t\t\t\t\t\t\tremaps = [  ]\n\t\t\t\t\t\t\tuse_global_default = true\n\t\t\t\t\t\t\tglobal_default_material = \"materials/old.vmat\"\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t_class = \"PhysicsShapeList\"\n\t\t\t\t\tchildren = \n\t\t\t\t\t[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"PhysicsHullFromRender\"\n\t\t\t\t\t\t\tparent_bone = \"\"\n\t\t\t\t\t\t\tsurface_prop = \"default\"\n\t\t\t\t\t\t\tcollision_tags = \"solid\"\n\t\t\t\t\t\t\tfaceMergeAngle = 20.0\n\t\t\t\t\t\t\tmaxHullVertices = 32\n\t\t\t\t\t\t\thull_mode = \"HullPerElement\"\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t_class = \"RenderMeshList\"\n\t\t\t\t\tchildren = \n\t\t\t\t\t[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\t\tfilename = \"models/foo.fbx\"\n\t\t\t\t\t\t\timport_scale = 1.0\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t]\n\t\t}\n\t}\n\t\"\"\";\n\n\t[TestMethod]\n\tpublic void SetsDefaultMaterial()\n\t{\n\t\tvar editor = VmdlBulkEditor.Load( WithMaterial );\n\t\tAssert.AreEqual( \"materials/old.vmat\", editor.ReadDefaultMaterial() );\n\n\t\tvar props = new MeshEntryProperties { GlobalDefaultMaterial = \"materials/new.vmat\" };\n\t\tAssert.IsTrue( editor.Apply( props ) );\n\n\t\tvar parsed = Kv3Document.Parse( editor.Source );\n\t\tvar group = parsed.FindObjects( \"DefaultMaterialGroup\" ).Single();\n\t\tAssert.AreEqual( \"materials/new.vmat\", ((Kv3Scalar)group.FindField( \"global_default_material\" ).Value).Value );\n\t\tAssert.AreEqual( \"true\", ((Kv3Scalar)group.FindField( \"use_global_default\" ).Value).Value );\n\t}\n\n\t[TestMethod]\n\tpublic void NullMaterialLeavesUnchanged()\n\t{\n\t\tvar editor = VmdlBulkEditor.Load( WithMaterial );\n\n\t\tvar props = new MeshEntryProperties { ImportScale = 2.0f };\n\t\tAssert.IsTrue( editor.Apply( props ) );\n\n\t\tvar parsed = Kv3Document.Parse( editor.Source );\n\t\tvar group = parsed.FindObjects( \"DefaultMaterialGroup\" ).Single();\n\t\tAssert.AreEqual( \"materials/old.vmat\", ((Kv3Scalar)group.FindField( \"global_default_material\" ).Value).Value );\n\t}\n\n\t[TestMethod]\n\tpublic void AlignOriginAppliesToCollision()\n\t{\n\t\tvar editor = VmdlBulkEditor.Load( WithMaterial );\n\n\t\tvar props = new MeshEntryProperties\n\t\t{\n\t\t\tAlignOriginX = AlignOrigin.BoundsCenter,\n\t\t\tAlignOriginY = AlignOrigin.BoundsCenter,\n\t\t\tAlignOriginZ = AlignOrigin.BoundsMin\n\t\t};\n\n\t\tAssert.IsTrue( editor.Apply( props ) );\n\n\t\tvar parsed = Kv3Document.Parse( editor.Source );\n\n\t\t// Render mesh\n\t\tvar mesh = parsed.FindObjects( \"RenderMeshFile\" ).Single();\n\t\tAssert.AreEqual( \"BoundsCenter\", ((Kv3Scalar)mesh.FindField( \"align_origin_x_type\" ).Value).Value );\n\t\tAssert.AreEqual( \"BoundsMin\", ((Kv3Scalar)mesh.FindField( \"align_origin_z_type\" ).Value).Value );\n\n\t\t// Collision shape must match\n\t\tvar hull = parsed.FindObjects( \"PhysicsHullFromRender\" ).Single();\n\t\tAssert.AreEqual( \"BoundsCenter\", ((Kv3Scalar)hull.FindField( \"align_origin_x_type\" ).Value).Value );\n\t\tAssert.AreEqual( \"BoundsCenter\", ((Kv3Scalar)hull.FindField( \"align_origin_y_type\" ).Value).Value );\n\t\tAssert.AreEqual( \"BoundsMin\", ((Kv3Scalar)hull.FindField( \"align_origin_z_type\" ).Value).Value );\n\t}\n}\n"
        },
        {
            "Ident": "bluedock.modelpro",
            "Path": "Assembly.cs",
            "FileName": "Assembly.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 340317,
            "Code": "global using Sandbox;\nglobal using System.Collections.Generic;\nglobal using System.Linq;\n"
        }
    ]
}