🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=bluedock.modelpro&take=20
Showing code results for query:
*
(19 total matches found)
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;
}
}
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();
}
}
Game
library
global using Sandbox; global using System.Collections.Generic; global using System.Linq;
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; } = "";
}
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 );
}
}
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 );
}
}
}
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();
}
}
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" );
}
}
}
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}\"";
}
Editor
library
global using Sandbox; global using Editor; global using System.Collections.Generic; global using System.Linq;
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
};
}
}
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 );
}
}
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();
}
}
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 );
}
}
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;
}
}
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")]
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 );
}
}
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 );
}
}
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"
}
]
}