Editor/McpExtras.ModelImport.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Text.Json;
using System.Threading.Tasks;
namespace Editor.Mcp;
public static partial class ExtrasTools
{
private static ModelImportLifetime ModelImportLifetime = new();
private static readonly IModelImportBackend ImportBackend = new SandboxModelImportBackend();
private static readonly ModelImportBackendPipeline ImportPipeline = new( ImportBackend );
/// <summary>
/// Import a new external FBX, OBJ or DMX source into an isolated directory under the active
/// project's Assets root. Native model creation and compilation are synchronous and can block
/// the editor without a hard deadline. Returned evidence distinguishes observed asset state
/// from unavailable operation-completion, dependency-coverage and writer-shutdown evidence.
/// </summary>
/// <param name="sourcePath">Absolute readable path to one .fbx, .obj or .dmx source.</param>
/// <param name="targetDirectory">New final directory relative to Assets; no implicit subdirectory is appended.</param>
/// <param name="modelName">Generated .vmdl stem only. Null or empty defaults to the source stem.</param>
/// <param name="overwrite">Reserved. True always returns OverwriteUnsupported before mutation.</param>
/// <param name="copySiblingTextures">Copy every allowlisted immediate sibling image; false does not enumerate siblings.</param>
[McpTool( "x_import_model_source" )]
public static async Task<ImportModelResult> ImportModelSource( string sourcePath, string targetDirectory,
string modelName = "", bool overwrite = false, bool copySiblingTextures = true )
{
var result = new ImportModelResult();
ModelImportTransaction transaction = null;
var lease = ModelImportLifetime.TryEnter();
if ( lease is null ) return BusyResult( result );
try
{
if ( overwrite ) return result.Fail( "OverwriteUnsupported", "overwrite=true is unsupported in version 1." );
var project = Project.Current;
if ( project is null ) return result.Fail( "NoActiveProject", "No active project is available." );
if ( Game.IsPlaying ) return result.Fail( "PlayMode", "Model import is unavailable while play mode is running." );
var projectIdent = project.Config?.Ident;
var assetsRoot = project.GetAssetsPath();
ModelImportLifetime.ScanMarkers( assetsRoot );
transaction = ModelImportTransaction.Plan( assetsRoot, sourcePath, targetDirectory, modelName, copySiblingTextures );
result.SourceAsset = transaction.SourceAsset;
result.ModelAsset = transaction.ModelAsset;
if ( ModelImportLifetime.IsOwnedDestination( transaction.RelativeDirectory ) )
throw new ImportContractException( "DestinationExists", "The destination is nested beneath an earlier import-owned directory." );
EnsureOutputsAbsent( transaction );
EnsureProjectState( project, projectIdent );
result.Stage = "copy";
transaction.ReserveDirectory();
result.CopiedFiles.AddRange( transaction.CopyInputs() );
transaction.WriteMarker( "not_started" );
result.GeneratedFiles.Add( transaction.MarkerAsset );
result.Stage = "registration";
EnsureProjectState( project, projectIdent );
result.WriterState = "unconfirmed";
transaction.WriteMarker( "unconfirmed" );
transaction.PrepareForEngineMutation();
transaction.EngineMutationAttempted = true;
ModelImportLifetime.RecordHistorical( transaction.PendingPaths( result.GeneratedFiles ) );
foreach ( var copy in transaction.Copies.Where( x => !string.Equals( x.DestinationAsset, transaction.SourceAsset, StringComparison.OrdinalIgnoreCase ) ) )
ImportPipeline.RegisterDependency( copy.DestinationAbsolute, copy.DestinationAsset );
var sourceCopy = transaction.Copies.Single( x => string.Equals( x.DestinationAsset, transaction.SourceAsset, StringComparison.OrdinalIgnoreCase ) );
var sourceAsset = ImportPipeline.RegisterSource( sourceCopy.DestinationAbsolute, transaction.SourceAsset );
result.SourceRegistered = true;
result.Stage = "model_creation";
EnsureProjectState( project, projectIdent );
var modelAbsolute = Path.Combine( transaction.AssetsRoot, transaction.ModelAsset.Replace( '/', Path.DirectorySeparatorChar ) );
object modelAsset;
try { modelAsset = ImportPipeline.CreateModel( sourceAsset, modelAbsolute, transaction.ModelAsset ); }
finally { TryObserveGeneratedFiles( transaction, result ); }
result.ModelRegistered = true;
result.Stage = "compilation";
var compilation = await ImportPipeline.ObserveCompilationAsync( modelAsset );
CopyCompilationEvidence( compilation, result.Compilation );
EnsureProjectState( project, projectIdent );
result.Stage = "dependency_inspection";
var dependencies = ImportPipeline.InspectDependencies( sourceAsset, modelAsset );
CopyDependencyEvidence( dependencies, result );
result.Succeeded = true;
result.Stage = "complete";
result.Error = null;
result.RollbackStatus = "not_needed";
ApplyPendingState( transaction, result );
return result;
}
catch ( BackendPipelineException ex )
{
if ( ex.Evidence is BackendCompilationEvidence compilation ) CopyCompilationEvidence( compilation, result.Compilation );
if ( ex.Evidence is BackendDependencyEvidence dependencies ) CopyDependencyEvidence( dependencies, result );
result.Fail( ex.Code, ex.Message );
}
catch ( ImportContractException ex )
{
result.Fail( ex.Code, ex.Message );
}
catch ( Exception ex )
{
result.Fail( CodeForStage( result.Stage ), Safe( ex.Message, 1024 ) );
}
finally
{
try
{
if ( transaction is not null ) result.CopiedFiles = transaction.CompletedCopies.ToList();
if ( transaction?.EngineMutationAttempted == true )
{
ApplyPendingState( transaction, result );
transaction.Dispose();
}
else
{
var remaining = transaction?.CleanupPreEngine() ?? [];
if ( remaining.Count > 0 )
{
result.RollbackStatus = "incomplete";
result.PartialPaths.AddRange( remaining );
}
else if ( transaction is not null ) result.RollbackStatus = "complete";
}
}
catch ( Exception ex )
{
result.RollbackStatus = "incomplete";
AddWarning( result, $"Final import accounting was incomplete: {Safe( ex.Message, 180 )}" );
}
finally
{
result.FurtherImportsBlocked = false;
lease.Dispose();
}
}
return result;
}
private static void ApplyPendingState( ModelImportTransaction transaction, ImportModelResult result )
{
result.WriterState = "unconfirmed";
result.FurtherImportsBlocked = false;
result.PendingWriterPaths = transaction.PendingPaths( result.GeneratedFiles ).ToList();
ModelImportLifetime.RecordHistorical( result.PendingWriterPaths );
TryObserveGeneratedFiles( transaction, result );
result.PendingWriterPaths = transaction.PendingPaths( result.GeneratedFiles ).ToList();
ModelImportLifetime.RecordHistorical( result.PendingWriterPaths );
if ( !result.Succeeded )
{
result.RollbackStatus = "incomplete";
result.PartialPaths = result.PendingWriterPaths.ToList();
}
}
private static void EnsureOutputsAbsent( ModelImportTransaction transaction )
{
foreach ( var copy in transaction.Copies )
if ( File.Exists( copy.DestinationAbsolute ) || AssetSystem.FindByPath( copy.DestinationAsset ) is not null )
throw new ImportContractException( "DestinationExists", $"Planned output '{copy.DestinationAsset}' already exists." );
if ( AssetSystem.FindByPath( transaction.ModelAsset ) is not null )
throw new ImportContractException( "DestinationExists", $"Planned output '{transaction.ModelAsset}' is already registered." );
var prefix = transaction.RelativeDirectory.TrimEnd( '/' ) + "/";
if ( AssetSystem.All.Any( asset => asset?.Path is string path &&
path.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) ) )
throw new ImportContractException( "DestinationExists", "The destination contains registered asset evidence." );
var ancestor = transaction.RelativeDirectory;
while ( ancestor.Contains( '/' ) )
{
ancestor = ancestor[..ancestor.LastIndexOf( '/' )];
var ancestorPrefix = ancestor + "/";
var direct = AssetSystem.All
.Select( asset => asset?.Path?.Replace( '\\', '/' ) )
.Where( path => path is not null && path.StartsWith( ancestorPrefix, StringComparison.OrdinalIgnoreCase ) &&
!path[ancestorPrefix.Length..].Contains( '/' ) )
.ToArray();
var modelCount = direct.Count( path => Path.GetExtension( path ).Equals( ".vmdl", StringComparison.OrdinalIgnoreCase ) );
var sourceCount = direct.Count( path => new[] { ".fbx", ".obj", ".dmx" }.Contains( Path.GetExtension( path ), StringComparer.OrdinalIgnoreCase ) );
// A v1 import boundary owns exactly one source and one generated model. Directories
// containing several independent pairs are collection roots, not isolated imports.
if ( modelCount == 1 && sourceCount == 1 )
throw new ImportContractException( "DestinationExists", "A destination ancestor contains registered source/model import evidence." );
}
}
private static void EnsureProjectState( Project project, string ident )
{
if ( Project.Current is null || !ReferenceEquals( Project.Current, project ) || !string.Equals( Project.Current.Config?.Ident, ident, StringComparison.Ordinal ) )
throw new ImportContractException( "NoActiveProject", "The active project changed during import." );
if ( Game.IsPlaying ) throw new ImportContractException( "PlayMode", "Play mode started during import." );
}
private static void TryObserveGeneratedFiles( ModelImportTransaction transaction, ImportModelResult result )
{
try
{
if ( !Directory.Exists( transaction.FinalDirectory ) ) return;
foreach ( var file in Directory.EnumerateFiles( transaction.FinalDirectory, "*", SearchOption.TopDirectoryOnly ) )
{
if ( (File.GetAttributes( file ) & FileAttributes.ReparsePoint) != 0 ) continue;
var path = transaction.ToReportedPath( file );
if ( result.CopiedFiles.Contains( path, StringComparer.OrdinalIgnoreCase ) ) continue;
if ( !result.GeneratedFiles.Contains( path, StringComparer.OrdinalIgnoreCase ) ) result.GeneratedFiles.Add( path );
if ( !transaction.OwnedFiles.Contains( file, StringComparer.OrdinalIgnoreCase ) ) transaction.OwnedFiles.Add( file );
}
result.GeneratedFiles.Sort( StringComparer.OrdinalIgnoreCase );
}
catch ( Exception ex ) { AddWarning( result, $"Generated output attribution was incomplete: {Safe( ex.Message, 180 )}" ); }
}
private static void AddWarning( ImportModelResult result, string warning )
{
if ( result.Warnings.Count < 16 ) result.Warnings.Add( Safe( warning, 256 ) );
}
private static void CopyCompilationEvidence( BackendCompilationEvidence source, CompilationEvidence target )
{
target.Status = source.Status;
target.IsCompiled = source.IsCompiled;
target.IsCompiledAndUpToDate = source.IsCompiledAndUpToDate;
target.IsCompileFailed = source.IsCompileFailed;
target.UnavailableEvidence = new Dictionary<string, string>( source.UnavailableEvidence, StringComparer.Ordinal );
}
private static void CopyDependencyEvidence( BackendDependencyEvidence source, ImportModelResult target )
{
target.DependencyInspection.Status = source.Status;
target.DependencyInspection.InspectedAssets = source.InspectedAssets.ToList();
target.DependencyInspection.References = source.References.ToList();
target.DependencyInspection.InputDependencies = source.InputDependencies.ToList();
target.DependencyInspection.AdditionalContentFiles = source.AdditionalContentFiles.ToList();
target.DependencyInspection.UnavailableEvidence = new Dictionary<string, string>( source.UnavailableEvidence, StringComparer.Ordinal );
target.UnresolvedReferences = source.UnresolvedReferences.ToList();
}
private static ImportModelResult BusyResult( ImportModelResult result )
{
result.FurtherImportsBlocked = true;
return result.Fail( "ImportBusy", "Another model import request is currently executing." );
}
private static string CodeForStage( string stage ) => stage switch
{
"copy" => "CopyFailed", "registration" => "RegistrationFailed", "model_creation" => "ModelCreationFailed",
"compilation" => "CompilationUnconfirmed", "dependency_inspection" => "DependencyInspectionFailed", _ => "InvalidInput"
};
internal static string Safe( string value, int maximum )
{
var text = string.IsNullOrWhiteSpace( value ) ? "The operation failed without a message." : value.Replace( '\r', ' ' ).Replace( '\n', ' ' );
return text.Length <= maximum ? text : text[..maximum];
}
}
public sealed class ImportModelResult
{
public bool Succeeded { get; set; }
public string Stage { get; set; } = "validation";
public string SourceAsset { get; set; }
public string ModelAsset { get; set; }
public List<string> CopiedFiles { get; set; } = [];
public List<string> GeneratedFiles { get; set; } = [];
public bool SourceRegistered { get; set; }
public bool ModelRegistered { get; set; }
public CompilationEvidence Compilation { get; set; } = new();
public DependencyInspectionEvidence DependencyInspection { get; set; } = new();
public string WriterState { get; set; } = "not_started";
public bool FurtherImportsBlocked { get; set; }
public List<string> PendingWriterPaths { get; set; } = [];
public List<string> UnresolvedReferences { get; set; } = [];
public List<string> Warnings { get; set; } = [];
public ImportModelError Error { get; set; }
public string RollbackStatus { get; set; } = "not_needed";
public List<string> PartialPaths { get; set; } = [];
internal ImportModelResult Fail( string code, string message )
{
Succeeded = false; Error = new() { Code = code, Message = ExtrasTools.Safe( message, 1024 ) }; return this;
}
}
public sealed class CompilationEvidence
{
public string Status { get; set; } = "not_started";
public bool? IsCompiled { get; set; }
public bool? IsCompiledAndUpToDate { get; set; }
public bool? IsCompileFailed { get; set; }
public Dictionary<string, string> UnavailableEvidence { get; set; } = new( StringComparer.Ordinal );
}
public sealed class DependencyInspectionEvidence
{
public string Status { get; set; } = "not_started";
public List<string> InspectedAssets { get; set; } = [];
public List<string> References { get; set; } = [];
public List<string> InputDependencies { get; set; } = [];
public List<string> AdditionalContentFiles { get; set; } = [];
public Dictionary<string, string> UnavailableEvidence { get; set; } = new( StringComparer.Ordinal );
}
public sealed class ImportModelError
{
public string Code { get; set; }
public string Message { get; set; }
}
internal sealed class SandboxModelImportBackend : IModelImportBackend
{
public object RegisterFile( string absolutePath ) => AssetSystem.RegisterFile( absolutePath );
public object FindAsset( string assetPath ) => AssetSystem.FindByPath( assetPath );
public string GetAssetPath( object asset ) => (asset as Asset)?.Path;
public object CreateModel( object sourceAsset, string absoluteModelPath ) =>
EditorUtility.CreateModelFromMeshFile( (Asset)sourceAsset, absoluteModelPath );
public bool ReadIsCompiled( object asset ) => ((Asset)asset).IsCompiled;
public bool ReadIsCompiledAndUpToDate( object asset ) => ((Asset)asset).IsCompiledAndUpToDate;
public bool ReadIsCompileFailed( object asset ) => ((Asset)asset).IsCompileFailed;
public async ValueTask ObserveCompilationIfNeededAsync( object asset ) => await ((Asset)asset).CompileIfNeededAsync( 30.0f );
public IReadOnlyList<object> GetReferences( object asset ) => ((Asset)asset).GetReferences( false ).Cast<object>().ToArray();
public IReadOnlyList<string> GetUnrecognizedReferences( object asset ) => ((Asset)asset).GetUnrecognizedReferencePaths();
public IReadOnlyList<string> GetInputDependencies( object asset ) => ((Asset)asset).GetInputDependencies();
public IReadOnlyList<string> GetAdditionalContentFiles( object asset ) => ((Asset)asset).GetAdditionalContentFiles();
}
internal sealed class ModelImportLifetime : IHotloadManaged
{
private ModelImportRequestGate _gate = new();
private int _valid = 1;
private List<string> _pending = [];
private List<string> _ownedDirectories = [];
internal IReadOnlyList<string> PendingPaths => _pending;
internal string BusyReason => "Another model import request is currently executing.";
internal Lease TryEnter()
{
if ( Volatile.Read( ref _valid ) == 0 ) return null;
var lease = _gate.TryEnter();
return lease is null ? null : new Lease( lease );
}
internal void RecordHistorical( IEnumerable<string> paths )
{
_pending = _pending.Concat( paths ).Distinct( StringComparer.OrdinalIgnoreCase )
.OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToList();
}
internal void ScanMarkers( string assetsRoot )
{
var foundPending = new List<string>();
var foundOwned = new List<string>();
try
{
var stack = new Stack<string>(); stack.Push( assetsRoot );
while ( stack.Count > 0 )
{
var directory = stack.Pop();
foreach ( var child in Directory.EnumerateDirectories( directory ) )
if ( (File.GetAttributes( child ) & FileAttributes.ReparsePoint) == 0 ) stack.Push( child );
var marker = Path.Combine( directory, ModelImportTransaction.MarkerName );
if ( !File.Exists( marker ) ) continue;
var relative = Path.GetRelativePath( assetsRoot, directory ).Replace( '\\', '/' );
foundOwned.Add( relative );
var blocks = true;
try
{
using var document = JsonDocument.Parse( File.ReadAllText( marker ) );
var root = document.RootElement;
blocks = !root.TryGetProperty( "Version", out var version ) || version.GetInt32() != 1 ||
!root.TryGetProperty( "TransactionId", out var transactionId ) || !Guid.TryParse( transactionId.GetString(), out _ ) ||
!root.TryGetProperty( "SourceAsset", out var sourceAsset ) || string.IsNullOrWhiteSpace( sourceAsset.GetString() ) ||
!root.TryGetProperty( "ModelAsset", out var modelAsset ) || string.IsNullOrWhiteSpace( modelAsset.GetString() ) ||
!root.TryGetProperty( "WriterState", out var writerState ) ||
!string.Equals( writerState.GetString(), "stopped", StringComparison.Ordinal );
}
catch { blocks = true; }
if ( blocks ) foundPending.Add( relative );
}
_ownedDirectories = foundOwned;
_pending = foundPending;
}
catch ( Exception ex )
{
_ownedDirectories = [];
_pending = [];
throw new ImportContractException( "ImportBusy", $"Import ownership markers could not be scanned safely: {ExtrasTools.Safe( ex.Message, 700 )}" );
}
}
internal bool IsOwnedDestination( string relativeDirectory ) => _ownedDirectories.Any( owned =>
relativeDirectory.Equals( owned, StringComparison.OrdinalIgnoreCase ) ||
relativeDirectory.StartsWith( owned.TrimEnd( '/' ) + "/", StringComparison.OrdinalIgnoreCase ) ||
owned.StartsWith( relativeDirectory.TrimEnd( '/' ) + "/", StringComparison.OrdinalIgnoreCase ) );
public void Destroyed( Dictionary<string, object> state )
{
ModelImportHotloadTransfer.Write( state, _gate, _pending, _ownedDirectories );
Volatile.Write( ref _valid, 0 );
}
public void Created( IReadOnlyDictionary<string, object> state )
{
try
{
// The only legacy retained gate in this session belongs to the giant-lid invocation,
// whose MCP call returned before this contract migration began.
var snapshot = ModelImportHotloadTransfer.Read( state, legacyInvocationFinished: true );
_pending = snapshot.PendingPaths.ToList();
_ownedDirectories = snapshot.OwnedDirectories.ToList();
_gate ??= new ModelImportRequestGate();
_gate.Restore( snapshot.ActiveRequest );
Volatile.Write( ref _valid, 1 );
}
catch
{
_gate.Restore( true );
Volatile.Write( ref _valid, 0 );
}
}
public void Persisted() { }
public void Failed()
{
_gate.Restore( true );
Volatile.Write( ref _valid, 0 );
}
internal sealed class Lease : IDisposable
{
private ModelImportRequestGate.Lease _lease;
internal Lease( ModelImportRequestGate.Lease lease ) => _lease = lease;
public void Dispose()
{
var lease = Interlocked.Exchange( ref _lease, null );
lease?.Dispose();
}
}
}