Editor/Tools/ModelDocTools.cs

Editor tool class for creating and editing .vmdl model documents. It exposes Mcp tools to create a model from a mesh, read a .vmdl as KV3 or JSON, write KV3 content to a .vmdl, and add autogenerated physics blocks (hull or mesh) by editing the KV3 source and triggering compilation.

File AccessNative Interop
using System;
using System.IO;
using Editor;
using Sandbox;
using SboxMcp.Registry;
using static SboxMcp.Tools.AssetTools;

namespace SboxMcp.Tools;

public static class ModelDocTools
{
	[McpTool( "modeldoc_create_from_mesh", "Creates a .vmdl model from a mesh asset (FBX/OBJ/SMD) with optional auto-generated collision.", ToolCategory.ModelDoc, Writes = true )]
	public static object CreateFromMesh(
		[Desc( "Source mesh asset path, e.g. 'models/crate.fbx'" )] string meshAssetPath,
		[Desc( "Project-relative output path ending in .vmdl; omit to place next to the mesh" )] string outputVmdlPath = null,
		[Desc( "Collision to generate from the render mesh" )] CollisionKind collision = CollisionKind.Hull )
	{
		var meshAsset = AssetSystem.FindByPath( meshAssetPath )
			?? throw new InvalidOperationException( $"No mesh asset at '{meshAssetPath}' - use asset_search" );

		var meshSource = meshAsset.GetSourceFile( true );

		var absolute = outputVmdlPath is not null
			? ResolveNewAssetPath( outputVmdlPath )
			: meshSource is not null
				? Path.ChangeExtension( meshSource, ".vmdl" )
				: throw new InvalidOperationException(
					$"'{meshAssetPath}' has no local source file - pass outputVmdlPath explicitly" );

		if ( File.Exists( absolute ) )
			throw new InvalidOperationException( $"'{absolute}' already exists - delete it first or pick another path" );

		var asset = EditorUtility.CreateModelFromMeshFile( meshAsset, absolute )
			?? throw new InvalidOperationException(
				$"The engine could not create a model from '{meshAssetPath}' - is it a valid mesh file?" );

		var physics = collision == CollisionKind.None
			? null
			: AddPhysics( asset.Path, collision );

		return new
		{
			created = asset.Path,
			compiled = asset.IsCompiled,
			collision = collision.ToString(),
			physicsResult = physics
		};
	}

	public enum CollisionKind { Hull, Mesh, None }

	[McpTool( "modeldoc_get", "Reads a .vmdl as JSON (or raw KV3 text). The node tree shows meshes, materials, physics, attachments, LODs, bodygroups.", ToolCategory.ModelDoc )]
	public static object Get(
		[Desc( "Model asset path, e.g. 'models/crate.vmdl'" )] string vmdlPath,
		[Desc( "Return the raw KV3 text instead of a JSON view" )] bool raw = false )
	{
		var asset = AssetSystem.FindByPath( vmdlPath )
			?? throw new InvalidOperationException( $"No model at '{vmdlPath}' - use asset_search with assetType 'vmdl'" );

		var text = File.ReadAllText( asset.GetSourceFile( true ) );

		if ( raw )
			return new { path = asset.Path, format = "kv3", content = text };

		var json = EditorUtility.KeyValues3ToJson( text )
			?? throw new InvalidOperationException( $"'{vmdlPath}' could not be parsed as KV3" );

		return new { path = asset.Path, format = "json (read-only view; write with modeldoc_set using KV3)", content = json };
	}

	[McpTool( "modeldoc_set", "Writes full KV3 content to a .vmdl (creating it if missing), then compiles. Get the current KV3 with modeldoc_get raw=true first; compile errors are reported back.", ToolCategory.ModelDoc, Writes = true )]
	public static object Set(
		[Desc( "Model asset path or project-relative path ending in .vmdl" )] string vmdlPath,
		[Desc( "Complete KV3 file content. The '<!-- kv3 ... -->' header line is added automatically when missing." )] string kv3Content )
	{
		if ( !kv3Content.TrimStart().StartsWith( "<!--" ) )
			kv3Content = VmdlHeader + "\n" + kv3Content;

		return AssetTools.WriteRaw( vmdlPath, kv3Content );
	}

	[McpTool( "modeldoc_add_physics", "Adds auto-generated collision (hull or mesh) to an existing .vmdl from its render meshes.", ToolCategory.ModelDoc, Writes = true )]
	public static object AddPhysicsTool(
		[Desc( "Model asset path" )] string vmdlPath,
		[Desc( "Collision kind to generate" )] CollisionKind collision = CollisionKind.Hull )
	{
		if ( collision == CollisionKind.None )
			throw new ArgumentException( "collision must be Hull or Mesh" );

		return AddPhysics( vmdlPath, collision );
	}

	static object AddPhysics( string vmdlPath, CollisionKind collision )
	{
		var asset = AssetSystem.FindByPath( vmdlPath )
			?? throw new InvalidOperationException( $"No model at '{vmdlPath}'" );

		var file = asset.GetSourceFile( true );
		var text = File.ReadAllText( file );

		if ( text.Contains( "PhysicsHullFile" ) || text.Contains( "PhysicsMeshFile" ) )
			return new { path = asset.Path, skipped = "model already has physics nodes" };

		// the render mesh files drive the physics shapes
		var meshFile = ExtractFirstRenderMeshFilename( text )
			?? throw new InvalidOperationException( $"'{vmdlPath}' has no RenderMeshFile node to build physics from" );

		var nodeClass = collision == CollisionKind.Mesh ? "PhysicsMeshFile" : "PhysicsHullFile";
		var physicsBlock =
			"\t\t\t{\n" +
			"\t\t\t\t_class = \"PhysicsShapeList\"\n" +
			"\t\t\t\tchildren = \n" +
			"\t\t\t\t[\n" +
			"\t\t\t\t\t{\n" +
			$"\t\t\t\t\t\t_class = \"{nodeClass}\"\n" +
			$"\t\t\t\t\t\tfilename = \"{meshFile}\"\n" +
			"\t\t\t\t\t\timport_scale = 1.0\n" +
			"\t\t\t\t\t\tparent_bone = \"\"\n" +
			"\t\t\t\t\t\tsurface_prop = \"default\"\n" +
			"\t\t\t\t\t\tcollision_tags = \"solid\"\n" +
			"\t\t\t\t\t},\n" +
			"\t\t\t\t]\n" +
			"\t\t\t},\n";

		// insert as the first entry of RootNode's children array
		var anchorIndex = FindRootChildrenStart( text )
			?? throw new InvalidOperationException( $"Could not locate the RootNode children array in '{vmdlPath}'" );

		text = text.Insert( anchorIndex, "\n" + physicsBlock );
		File.WriteAllText( file, text );
		asset.Compile( true );

		return new
		{
			path = asset.Path,
			added = nodeClass,
			compiled = asset.IsCompiled,
			note = asset.IsCompiled ? null : "compile failed - read the file with modeldoc_get raw=true and fix it with modeldoc_set"
		};
	}

	static string ExtractFirstRenderMeshFilename( string kv3 )
	{
		var meshIdx = kv3.IndexOf( "\"RenderMeshFile\"", StringComparison.Ordinal );
		if ( meshIdx < 0 ) return null;

		var fileIdx = kv3.IndexOf( "filename", meshIdx, StringComparison.Ordinal );
		if ( fileIdx < 0 ) return null;

		var firstQuote = kv3.IndexOf( '"', fileIdx );
		var secondQuote = firstQuote < 0 ? -1 : kv3.IndexOf( '"', firstQuote + 1 );
		return secondQuote < 0 ? null : kv3.Substring( firstQuote + 1, secondQuote - firstQuote - 1 );
	}

	static int? FindRootChildrenStart( string kv3 )
	{
		var rootIdx = kv3.IndexOf( "\"RootNode\"", StringComparison.Ordinal );
		if ( rootIdx < 0 ) return null;

		var childrenIdx = kv3.IndexOf( "children", rootIdx, StringComparison.Ordinal );
		if ( childrenIdx < 0 ) return null;

		var bracket = kv3.IndexOf( '[', childrenIdx );
		return bracket < 0 ? null : bracket + 1;
	}

	internal const string VmdlHeader =
		"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->";
}