Editor/Core/UnityModelMaterials.cs

Editor helper that resolves which materials to assign to FBX model slots using the model .meta importer settings, applying Unity semantics for embedded vs external materials and legacy material name/search rules.

File Access
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace ImportUnityPackage;

/// <summary>Resolves FBX slot materials from the model's own ModelImporter settings, as Unity does before any prefab overrides.</summary>
internal static class UnityModelMaterials
{
	internal const string ExternalObject = "external-object";
	internal const string MaterialSearch = "unity-material-search";

	internal static void Apply( UnityArchive archive )
	{
		var materials = archive.Assets.Where( a => a.Kind == UnityAssetKind.Material && a.Path.EndsWith( ".mat", StringComparison.OrdinalIgnoreCase ) ).ToArray();
		var guids = materials.Select( a => a.Guid ).ToHashSet( StringComparer.OrdinalIgnoreCase );
		var byPath = materials.ToDictionary( a => a.Path, StringComparer.OrdinalIgnoreCase );
		var byFile = materials.GroupBy( a => Path.GetFileName( a.Path ), StringComparer.OrdinalIgnoreCase ).ToDictionary( g => g.Key, g => g.ToArray(), StringComparer.OrdinalIgnoreCase );
		foreach ( var model in archive.Assets.Where( a => a.Kind == UnityAssetKind.Model ) )
		{
			// A .meta that cannot be read gives the model no import settings; the package still loads.
			YamlNode importer;
			try { importer = model.MetadataDocument?["ModelImporter"]; }
			catch ( InvalidDataException ex ) { archive.Skipped.Add( $"{model.Path}.meta: not read ({ex.Message})" ); continue; }
			if ( importer == null ) continue;
			var info = model.ModelInfo;
			var settings = importer["materials"];
			int Setting( string key, int fallback ) => (int?)settings?[key]?.Integer ?? fallback;
			// materialImportMode (Unity 2019.3+) replaced importMaterials. With import off, Unity renders its default
			// material until a prefab assigns one, so no model-level material exists.
			if ( ((int?)(settings?["materialImportMode"] ?? settings?["importMaterials"])?.Integer ?? 1) == 0 )
			{
				info.MaterialImport = "none";
				continue;
			}
			// Explicit per-slot remaps, written by Unity 2017.2+ when materials are extracted or remapped.
			foreach ( var entry in importer["externalObjects"]?.Items ?? Array.Empty<YamlNode>() )
			{
				var type = entry["first"]?["type"]?.Value ?? "";
				if ( type != "Material" && !type.EndsWith( ":Material", StringComparison.Ordinal ) ) continue;
				var reference = entry["second"]?.Reference ?? default;
				if ( entry["first"]?["name"]?.Value is string slot && slot.Length > 0 && reference.HasGuid && guids.Contains( reference.Guid ) )
					info.ImporterMaterials[slot] = new( reference.Guid, ExternalObject );
			}
			// materialLocation 1 embeds materials in the model; only externalObjects can replace them.
			if ( Setting( "materialLocation", 0 ) != 0 )
			{
				info.MaterialImport = "embedded";
				continue;
			}
			// Legacy external materials: Unity names a .mat from the slot, then looks for it in Materials folders.
			var naming = Setting( "materialName", 0 );
			var search = Setting( "materialSearch", 1 );
			info.MaterialImport = $"external (materialName {naming}, materialSearch {search})";
			var modelName = Path.GetFileNameWithoutExtension( model.Path );
			foreach ( var slot in info.Materials.Where( s => !info.ImporterMaterials.ContainsKey( s ) ) )
			{
				var texture = info.MaterialAlbedoFiles.GetValueOrDefault( slot ) is string file ? Path.GetFileNameWithoutExtension( file ) : null;
				// ModelImporterMaterialName: 0 texture name, 1 material name, 2 model-material, 3 texture or model-material.
				var name = naming switch { 1 => slot, 2 => modelName + "-" + slot, 3 => texture ?? modelName + "-" + slot, _ => texture ?? slot };
				if ( Find( model.Path, name, search, byPath, byFile ) is UnityAsset found ) info.ImporterMaterials[slot] = new( found.Guid, MaterialSearch );
			}
		}
	}

	/// <summary>ModelImporterMaterialSearch: 0 the model's Materials folder, 1 Materials folders up to Assets, 2 then anywhere (if unique).</summary>
	static UnityAsset Find( string modelPath, string name, int search, Dictionary<string, UnityAsset> byPath, Dictionary<string, UnityAsset[]> byFile )
	{
		// Unity replaces characters that are invalid in file names; try the plain name first.
		var names = new[] { name, string.Concat( name.Select( c => "/\\:.@\"<>*|?".Contains( c ) ? '_' : c ) ) }.Distinct().ToArray();
		var directory = Path.GetDirectoryName( modelPath )?.Replace( '\\', '/' ) ?? "";
		while ( true )
		{
			foreach ( var candidate in names )
				if ( byPath.TryGetValue( (directory.Length > 0 ? directory + "/" : "") + "Materials/" + candidate + ".mat", out var found ) ) return found;
			if ( search == 0 || directory.Length == 0 ) break;
			directory = Path.GetDirectoryName( directory )?.Replace( '\\', '/' ) ?? "";
		}
		if ( search < 2 ) return null;
		var matches = names.SelectMany( n => byFile.GetValueOrDefault( n + ".mat" ) ?? Array.Empty<UnityAsset>() ).Distinct().ToArray();
		return matches.Length == 1 ? matches[0] : null;
	}
}