Editor/Core/ImportPlan.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
namespace ImportUnityPackage;
public sealed record ImportDependency( UnityAsset Asset, string Reason );
public sealed record PlannedAsset( UnityAsset Asset, bool Explicit, ImportDependency[] RequiredBy, bool Vmat, bool Tmat, bool Vmdl )
{
public string OutputLabel => Asset.Kind switch
{
UnityAssetKind.Model => Vmdl ? "Model → VMDL" : "Model source",
UnityAssetKind.Material => Vmat && Tmat ? "Material → VMAT + TMAT" : Vmat ? "Material → VMAT" : Tmat ? "Terrain material → TMAT" : "Material source",
UnityAssetKind.Texture => UnityImport.NeedsTextureResource( Asset.Path ) ? "Texture → VTEX" : "Texture",
_ => "Model support"
};
}
/// <summary>A selection snapshot shared by the window, importer and import report.</summary>
public sealed class ImportPlan
{
public UnityArchive Archive { get; }
public ImportOptions Options { get; }
public IReadOnlyList<PlannedAsset> Assets { get; }
public IReadOnlyList<ImportIssue> Issues { get; }
readonly Dictionary<UnityAsset, PlannedAsset> byAsset;
internal ImportPlan( UnityArchive archive, ImportOptions options, PlannedAsset[] assets, ImportIssue[] issues )
{
Archive = archive; Options = options; Assets = Array.AsReadOnly( assets ); Issues = Array.AsReadOnly( issues );
byAsset = assets.ToDictionary( a => a.Asset );
}
public PlannedAsset Find( UnityAsset asset ) => byAsset.GetValueOrDefault( asset );
}
/// <summary>Read dependency evidence once, then resolve selections without disk access.</summary>
public sealed class ImportCatalog
{
readonly UnityArchive archive;
readonly Dictionary<UnityAsset, List<ImportDependency>> dependencies = new();
readonly Dictionary<UnityAsset, List<ImportIssue>> issues = new();
ImportCatalog( UnityArchive archive ) { this.archive = archive; }
public static ImportCatalog Read( UnityArchive archive, CancellationToken cancel = default )
{
var catalog = new ImportCatalog( archive );
var byGuid = archive.Assets.ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );
foreach ( var (alias, guid) in archive.GuidAliases ) if ( byGuid.TryGetValue( guid, out var asset ) ) byGuid[alias] = asset;
var materials = new Dictionary<UnityAsset, UnityMaterial>();
void Issue( UnityAsset asset, string code, string message )
{
if ( !catalog.issues.TryGetValue( asset, out var list ) ) catalog.issues[asset] = list = new();
list.Add( new( asset.Path, code, message ) );
}
void Link( UnityAsset parent, UnityAsset child, string reason )
{
if ( !catalog.dependencies.TryGetValue( parent, out var list ) ) catalog.dependencies[parent] = list = new();
if ( !list.Any( d => d.Asset == child && d.Reason == reason ) ) list.Add( new( child, reason ) );
}
foreach ( var asset in archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ) )
{
cancel.ThrowIfCancellationRequested();
try
{
var material = UnityMaterial.Parse( UnityImport.ReadText( asset.Source ) );
if ( !string.IsNullOrEmpty( material.ShaderGuid ) && byGuid.TryGetValue( material.ShaderGuid, out var shader ) && shader.Path.EndsWith( ".shader", StringComparison.OrdinalIgnoreCase ) )
material.ConfigureShader( UnityImport.ReadText( shader.Source ) );
materials[asset] = material;
foreach ( var guid in material.Textures.Values.Distinct( StringComparer.OrdinalIgnoreCase ) )
{
if ( byGuid.TryGetValue( guid, out var texture ) && texture.Kind == UnityAssetKind.Texture ) Link( asset, texture, "Texture reference" );
else Issue( asset, "missing-texture", $"Missing or unsupported texture {guid}." );
}
}
catch ( InvalidDataException ex ) { Issue( asset, "conversion-skipped", $"Conversion skipped. {ex.Message}" ); }
}
var byName = archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ).GroupBy( a => Path.GetFileNameWithoutExtension( a.Path ), StringComparer.OrdinalIgnoreCase )
.ToDictionary( g => g.Key, g => g.ToArray(), StringComparer.OrdinalIgnoreCase );
var byTexture = materials.SelectMany( m => m.Value.ColorTextureGuids.Where( byGuid.ContainsKey ).Select( g => (Name: Path.GetFileNameWithoutExtension( byGuid[g].Path ), Asset: m.Key) ) )
.GroupBy( m => m.Name, StringComparer.OrdinalIgnoreCase ).ToDictionary( g => g.Key, g => g.Select( m => m.Asset ).Distinct().ToArray(), StringComparer.OrdinalIgnoreCase );
foreach ( var asset in archive.Assets.Where( a => a.Kind == UnityAssetKind.Model ) )
{
cancel.ThrowIfCancellationRequested();
var referencedMaterials = new HashSet<UnityAsset>();
void Match( UnityAsset[] candidates, string reason )
{
var referenced = candidates.Where( referencedMaterials.Contains ).ToArray();
if ( referenced.Length > 0 ) candidates = referenced;
if ( candidates.Length == 1 ) Link( asset, candidates[0], reason + " (inferred)" );
else if ( candidates.Length > 1 ) Issue( asset, "ambiguous-material", $"{reason} matches multiple materials; no dependency was inferred." );
}
var refs = asset.ModelInfo.PrefabMaterials.AsEnumerable();
if ( asset.Metadata != null ) refs = refs.Concat( UnityMaterial.References( UnityImport.ReadText( asset.Metadata ) ) );
foreach ( var guid in refs.Distinct( StringComparer.OrdinalIgnoreCase ) )
if ( byGuid.TryGetValue( guid, out var dependency ) && dependency.Kind is UnityAssetKind.Material or UnityAssetKind.Texture )
{
Link( asset, dependency, "Model / prefab reference" );
if ( dependency.Kind == UnityAssetKind.Material ) referencedMaterials.Add( dependency );
}
foreach ( var filename in asset.ModelInfo.MaterialAlbedoFiles.Values ) Match( byTexture.GetValueOrDefault( Path.GetFileNameWithoutExtension( filename ), Array.Empty<UnityAsset>() ), $"Color texture {filename}" );
foreach ( var slot in asset.ModelInfo.Materials ) Match( byName.GetValueOrDefault( slot, Array.Empty<UnityAsset>() ), $"Material slot {slot}" );
Match( byName.GetValueOrDefault( Path.GetFileNameWithoutExtension( asset.Path ), Array.Empty<UnityAsset>() ), "Model filename" );
}
return catalog;
}
public ImportPlan CreatePlan( ImportOptions options )
{
var explicitAssets = archive.Assets.Where( a => a.Selected && UnityImport.IsEnabled( a, options ) ).ToHashSet();
var included = new HashSet<UnityAsset>( explicitAssets );
var requiredBy = new Dictionary<UnityAsset, List<ImportDependency>>();
var queue = new Queue<UnityAsset>( included );
while ( queue.TryDequeue( out var parent ) )
{
if ( !options.Materials || !dependencies.TryGetValue( parent, out var children ) ) continue;
foreach ( var child in children )
{
if ( !requiredBy.TryGetValue( child.Asset, out var parents ) ) requiredBy[child.Asset] = parents = new();
parents.Add( new( parent, child.Reason ) );
if ( included.Add( child.Asset ) ) queue.Enqueue( child.Asset );
}
}
var entries = included.OrderBy( a => a.Kind == UnityAssetKind.Model ? 1 : 0 ).ThenBy( a => a.Path, StringComparer.OrdinalIgnoreCase ).Select( a =>
{
var parents = requiredBy.GetValueOrDefault( a )?.ToArray() ?? Array.Empty<ImportDependency>();
var terrain = a.Path.EndsWith( ".terrainlayer", StringComparison.OrdinalIgnoreCase );
var modelUse = parents.Any( p => p.Asset.Kind == UnityAssetKind.Model );
return new PlannedAsset( a, explicitAssets.Contains( a ), parents,
a.Kind == UnityAssetKind.Material && options.Vmat && (!options.Automatic || !terrain || modelUse),
a.Kind == UnityAssetKind.Material && (options.Tmat || options.Automatic && terrain),
a.Kind == UnityAssetKind.Model && options.Vmdl );
} ).ToArray();
return new( archive, options, entries, entries.Where( a => a.Vmat || a.Tmat || a.Vmdl && options.Materials )
.Where( a => issues.ContainsKey( a.Asset ) ).SelectMany( a => issues[a.Asset] ).Distinct().ToArray() );
}
}