Editor/Core/UnityPrefabBindings.cs

Editor helper that analyzes Unity prefab YAML to infer which material slots and material assignments apply to imported 3D models. It parses prefab and scene files, follows nested prefab references into model renderers, collects evidence of overrides, determines prefab variants and default slot materials, and marks collision-only or hidden meshes.

File AccessReflection
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

namespace ImportUnityPackage;

/// <summary>Resolves renderer/slot assignments from prefab data, independently of publisher or material filenames.</summary>
internal static class UnityPrefabBindings
{
	sealed class Candidate( string path )
	{
		internal readonly string Path = path;
		internal readonly Dictionary<string, HashSet<string>> Slots = new( StringComparer.OrdinalIgnoreCase );
		internal readonly HashSet<string> Materials = new( StringComparer.OrdinalIgnoreCase );
		internal readonly List<string> Warnings = new();
		internal readonly List<UnityModel.PrefabSlotEvidence> Evidence = new();
		// Resolved slot materials per placed instance (a PrefabInstance document or an explicit renderer block).
		internal readonly Dictionary<string, Dictionary<string, string>> Units = new();
		// Slots one placement assigns two different materials: malformed data that cannot be a variant.
		internal readonly HashSet<string> Contradicted = new( StringComparer.OrdinalIgnoreCase );
		internal readonly HashSet<string> Embedded = new( StringComparer.OrdinalIgnoreCase );
		// Slots where different renderers of one placement need different materials.
		internal readonly HashSet<string> Shared = new( StringComparer.OrdinalIgnoreCase );
		internal readonly Dictionary<(string Unit, string Slot), long> Renderers = new();
		internal bool HasOverrides;
	}
	const string Unidentified = "unidentified renderer";
	static UnityReference Ref( YamlNode body, string key ) => body?[key]?.Reference ?? default;
	static readonly Regex MaterialSlot = new( @"^m_Materials\.Array\.data\[([0-9]+)\]$" );

	// Placement files read for evidence: the same limit as the conversion's (larger files are not converted either).
	const long MaxPlacementBytes = 256L * 1024 * 1024;

	internal static void Apply( UnityArchive archive )
	{
		var models = archive.Assets.Where( a => a.Kind == UnityAssetKind.Model ).ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );
		var materialIds = archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ).Select( a => a.Guid ).ToHashSet( StringComparer.OrdinalIgnoreCase );
		var materialPaths = archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ).ToDictionary( a => a.Guid, a => System.IO.Path.GetFileNameWithoutExtension( a.Path ), StringComparer.OrdinalIgnoreCase );
		var candidates = new Dictionary<string, List<Candidate>>( StringComparer.OrdinalIgnoreCase );
		var visible = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		// How each model is placed: a prefab instance renders every mesh except renderers it removes; explicit renderers name their meshes.
		var instanceRemovals = new Dictionary<string, List<HashSet<long>>>( StringComparer.OrdinalIgnoreCase );
		var placedMeshes = new Dictionary<string, HashSet<long>>( StringComparer.OrdinalIgnoreCase );
		bool IsModel( UnityReference reference ) => reference.Guid is { Length: > 0 } guid && models.ContainsKey( guid );
		void Placements( IEnumerable<UnityYamlObject> objects )
		{
			foreach ( var item in objects )
			{
				if ( item.ClassId is 33 or 137 or 199 && Ref( item.Body, "m_Mesh" ) is var mesh && IsModel( mesh ) )
				{
					visible.Add( mesh.Guid );
					if ( !placedMeshes.TryGetValue( mesh.Guid, out var ids ) ) placedMeshes[mesh.Guid] = ids = new();
					ids.Add( mesh.FileId );
				}
				if ( item.ClassId == 1001 && Ref( item.Body, "m_SourcePrefab" ) is var source && IsModel( source ) )
				{
					visible.Add( source.Guid );
					if ( !instanceRemovals.TryGetValue( source.Guid, out var removals ) ) instanceRemovals[source.Guid] = removals = new();
					removals.Add( (item.Body["m_Modification"]?["m_RemovedComponents"]?.Items ?? Array.Empty<YamlNode>()).Select( r => r.Reference )
						.Where( r => source.Guid.Equals( r.Guid, StringComparison.OrdinalIgnoreCase ) ).Select( r => r.FileId ).ToHashSet() );
				}
			}
		}
		var prefabFiles = archive.Assets.Where( a => a.Path.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) && new FileInfo( a.Source ).Length <= MaxPlacementBytes )
			.ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );
		var parsedPrefabs = new Dictionary<string, IReadOnlyList<UnityYamlObject>>( StringComparer.OrdinalIgnoreCase );
		IReadOnlyList<UnityYamlObject> PrefabObjects( string guid )
		{
			if ( !parsedPrefabs.TryGetValue( guid, out var objects ) ) parsedPrefabs[guid] = objects = UnityYaml.Parse( File.ReadAllText( prefabFiles[guid].Source ) );
			return objects;
		}
		// Follows a reference into nested prefabs until it reaches a model's renderer (or an explicit renderer's mesh).
		// A stripped object names its source; an object inside a nested instance has the ID (instanceID XOR sourceID)
		// with the sign bit cleared (Unity 2018.3+), so both sign variants of the source ID are tried.
		// Per parsed prefab: objects by file ID, its prefab instances and mesh filters by GameObject (lookups, not scans).
		var indexes = new Dictionary<string, (Dictionary<long, UnityYamlObject> ById, List<UnityYamlObject> Instances, Dictionary<long, UnityYamlObject> Filters)>( StringComparer.OrdinalIgnoreCase );
		// Resolved references; a miss is only remembered when it was resolved from the top (deeper misses can be depth cut-offs).
		var resolvedReferences = new Dictionary<(string, long), (string Model, long Id, bool Mesh)?>();
		(string Model, long Id, bool Mesh)? Resolve( UnityReference reference, int depth = 0 )
		{
			if ( IsModel( reference ) ) return (reference.Guid, reference.FileId, false);
			if ( depth > 8 || reference.Guid is not { Length: > 0 } guid || !prefabFiles.ContainsKey( guid ) ) return null;
			if ( resolvedReferences.TryGetValue( (guid, reference.FileId), out var known ) ) return known;
			var result = ResolveIn( guid, reference, depth );
			if ( result != null || depth == 0 ) resolvedReferences[(guid, reference.FileId)] = result;
			return result;
		}
		(string Model, long Id, bool Mesh)? ResolveIn( string guid, UnityReference reference, int depth )
		{
			if ( !indexes.TryGetValue( guid, out var index ) )
			{
				IReadOnlyList<UnityYamlObject> parsed;
				try { parsed = PrefabObjects( guid ); }
				catch ( InvalidDataException ) { return null; } // Reported where the prefab itself is read.
				var byId = new Dictionary<long, UnityYamlObject>();
				foreach ( var o in parsed ) byId.TryAdd( o.FileId, o );
				var filters = new Dictionary<long, UnityYamlObject>();
				foreach ( var o in parsed.Where( o => o.ClassId == 33 ) ) filters.TryAdd( Ref( o.Body, "m_GameObject" ).FileId, o );
				indexes[guid] = index = (byId, parsed.Where( o => o.ClassId == 1001 ).ToList(), filters);
			}
			if ( index.ById.TryGetValue( reference.FileId, out var item ) )
			{
				if ( item.Stripped ) return Resolve( Ref( item.Body, "m_CorrespondingSourceObject" ), depth + 1 );
				var mesh = item.ClassId is 137 or 199 ? Ref( item.Body, "m_Mesh" ) : item.ClassId == 23
					? Ref( index.Filters.GetValueOrDefault( Ref( item.Body, "m_GameObject" ).FileId )?.Body, "m_Mesh" ) : default;
				return IsModel( mesh ) ? (mesh.Guid, mesh.FileId, true) : null;
			}
			foreach ( var instance in index.Instances )
			{
				var source = Ref( instance.Body, "m_SourcePrefab" );
				var masked = (reference.FileId ^ instance.FileId) & long.MaxValue;
				foreach ( var id in new[] { masked, masked | long.MinValue } )
				{
					var inner = new UnityReference( id, source.Guid );
					var found = IsModel( source ) ? models[source.Guid].ModelInfo.RendererSlots.ContainsKey( id ) ? Resolve( inner, depth + 1 ) : null : Resolve( inner, depth + 1 );
					if ( found != null ) return found;
				}
			}
			return null;
		}
		// Scenes can also place models; they count for visibility (so a model shown only in scenes is not collision-only),
		// not for material assignments.
		// A file that cannot be read is left out of the evidence and listed; the package still loads.
		foreach ( var scene in archive.Assets.Where( a => a.Path.EndsWith( ".unity", StringComparison.OrdinalIgnoreCase ) && new FileInfo( a.Source ).Length <= MaxPlacementBytes ) )
		{
			try { Placements( UnityYaml.Parse( File.ReadAllText( scene.Source ) ) ); }
			catch ( InvalidDataException ex ) { archive.Skipped.Add( $"{scene.Path}: not read for placement evidence ({ex.Message})" ); }
		}
		var colliders = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var prefab in archive.Assets.Where( a => a.Path.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) ) )
		{
			if ( new FileInfo( prefab.Source ).Length > MaxPlacementBytes ) continue;
			var text = File.ReadAllText( prefab.Source );
			var local = new Dictionary<string, Candidate>( StringComparer.OrdinalIgnoreCase );
			Candidate For( string guid )
			{
				if ( !local.TryGetValue( guid, out var candidate ) ) local[guid] = candidate = new( prefab.Path );
				return candidate;
			}
			void Assign( string guid, UnityModel.RendererBinding binding, long id, string idSource, int index, UnityReference reference, string unit )
			{
				var candidate = For( guid ); candidate.HasOverrides = true;
				var model = models[guid].ModelInfo;
				var material = reference.Guid ?? "";
				// A reference into the model itself is one of its embedded materials, which Unity generates from the FBX.
				var embedded = material.Equals( guid, StringComparison.OrdinalIgnoreCase );
				if ( embedded ) material = "embedded material " + (model.EmbeddedMaterialName( reference.FileId ) ?? reference.FileId.ToString());
				void Evidence( string slot, string problem ) =>
					candidate.Evidence.Add( new( prefab.Path, slot, id, binding?.Mesh, index, material, binding == null ? "unresolved" : idSource, problem ) );
				if ( binding == null ) { Evidence( null, Unidentified ); return; } // Classified once the whole prefab is read.
				// Lower LOD meshes are LOD levels of the VMDL, whose material remaps are per slot name: their renderers assign
				// only the slots the always-shown meshes (LOD0 and meshes without a LOD suffix) do not have, such as an impostor
				// level's own material. A lower LOD mesh outside the levels (LOD2 without LOD1) is not imported.
				if ( model.IsLowerLod( binding.Mesh ) )
				{
					var lowerLevel = model.LodLevels.Any( l => l.Contains( binding.Mesh ) );
					var slotName = index >= 0 && index < binding.Slots.Length ? binding.Slots[index] : binding.Slots.Length == 0 ? binding.Mesh : null;
					var lodZeroSlots = model.RendererSlots.Values.Where( b => !model.IsLowerLod( b.Mesh ) ).SelectMany( b => b.Slots.Length == 0 ? new[] { b.Mesh } : b.Slots );
					if ( !lowerLevel ) { Evidence( null, "lower LOD not imported" ); return; }
					if ( slotName == null || lodZeroSlots.Contains( slotName, StringComparer.OrdinalIgnoreCase ) ) { Evidence( null, "lower LOD slot shared with LOD0" ); return; }
				}
				if ( index < 0 || index >= Math.Max( 1, binding.Slots.Length ) )
				{
					Evidence( null, "missing slot" );
					candidate.Warnings.Add( "A prefab material override references a missing FBX material slot." );
					// Extra Unity material passes cannot be represented by this mesh's implicit slot.
					if ( binding.Slots.Length == 0 )
					{
						if ( !candidate.Slots.TryGetValue( binding.Mesh, out var invalid ) ) candidate.Slots[binding.Mesh] = invalid = new();
						invalid.Add( "" );
					}
					return;
				}
				var slot = binding.Slots.Length == 0 ? binding.Mesh : binding.Slots[index];
				Evidence( slot, null );
				if ( embedded ) { candidate.Embedded.Add( slot ); return; } // Not a material asset, so no conflict or variant.
				if ( !candidate.Slots.TryGetValue( slot, out var values ) ) candidate.Slots[slot] = values = new( StringComparer.OrdinalIgnoreCase );
				values.Add( material );
				if ( !materialIds.Contains( material ) ) return;
				candidate.Materials.Add( material );
				if ( !candidate.Units.TryGetValue( unit, out var assignments ) ) candidate.Units[unit] = assignments = new( StringComparer.OrdinalIgnoreCase );
				if ( assignments.TryGetValue( slot, out var previous ) && !previous.Equals( material, StringComparison.OrdinalIgnoreCase ) )
				{
					// The same renderer given two materials is malformed. Different renderers sharing a slot name are real,
					// but one ModelDoc slot cannot separate them.
					if ( candidate.Renderers.GetValueOrDefault( (unit, slot) ) == id ) candidate.Contradicted.Add( slot );
					else candidate.Shared.Add( slot );
				}
				assignments[slot] = material;
				candidate.Renderers[(unit, slot)] = id;
			}
			IReadOnlyList<UnityYamlObject> objects;
			try { objects = prefabFiles.ContainsKey( prefab.Guid ) ? PrefabObjects( prefab.Guid ) : UnityYaml.Parse( text ); }
			catch ( InvalidDataException ex ) { archive.Skipped.Add( $"{prefab.Path}: not read for material evidence ({ex.Message})" ); continue; }
			var filters = new Dictionary<long, List<UnityReference>>();
			foreach ( var item in objects )
			{
				var mesh = Ref( item.Body, "m_Mesh" );
				if ( item.ClassId == 64 && IsModel( mesh ) ) colliders.Add( mesh.Guid );
				if ( item.ClassId is 33 or 137 or 199 && IsModel( mesh ) )
				{
					visible.Add( mesh.Guid );
					if ( item.ClassId == 33 && Ref( item.Body, "m_GameObject" ).FileId is var gameObject && gameObject != 0 )
					{
						if ( !filters.TryGetValue( gameObject, out var list ) ) filters[gameObject] = list = new();
						list.Add( mesh );
					}
				}
				if ( item.ClassId == 1001 && Ref( item.Body, "m_SourcePrefab" ) is var source && IsModel( source ) ) visible.Add( source.Guid );
			}
			Placements( objects );
			// Material overrides on placed models: PrefabInstance (and pre-2018.3 Prefab) modification lists.
			foreach ( var instance in objects.Where( o => o.ClassId == 1001 ) )
			{
				foreach ( var modification in instance.Body["m_Modification"]?["m_Modifications"]?.Items ?? Array.Empty<YamlNode>() )
				{
					var target = Ref( modification, "target" );
					var property = MaterialSlot.Match( modification["propertyPath"]?.Value ?? "" );
					// A target inside a nested prefab (a prefab variant or a prefab placed in a prefab) is followed to its model.
					if ( !property.Success || Resolve( target ) is not var (model, id, isMesh) ) continue;
					var asset = models[model];
					visible.Add( asset.Guid );
					if ( !int.TryParse( property.Groups[1].Value, out var index ) ) continue;
					var nested = !IsModel( target );
					var idSource = isMesh ? asset.ModelInfo.MeshIdSource( id ) : asset.ModelInfo.RendererIdSource( id );
					Assign( asset.Guid, isMesh ? asset.ModelInfo.ResolveMesh( id ) : asset.ModelInfo.RendererSlots.GetValueOrDefault( id ), id, nested ? $"nested prefab ({idSource})" : idSource, index,
						Ref( modification, "objectReference" ), "instance " + instance.FileId );
				}
			}
			// Explicit MeshRenderer + MeshFilter blocks, and renderers naming their own mesh (SkinnedMeshRenderer 137, ParticleSystemRenderer 199).
			foreach ( var renderer in objects.Where( o => o.ClassId is 23 or 137 or 199 ) )
			{
				var refs = renderer.ClassId is 137 or 199 ? new[] { Ref( renderer.Body, "m_Mesh" ) } :
					filters.GetValueOrDefault( Ref( renderer.Body, "m_GameObject" ).FileId )?.ToArray() ?? Array.Empty<UnityReference>();
				if ( refs.Length != 1 || !IsModel( refs[0] ) ) continue;
				var asset = models[refs[0].Guid];
				var meshId = refs[0].FileId;
				var binding = asset.ModelInfo.ResolveMesh( meshId );
				var index = 0;
				foreach ( var slot in renderer.Body["m_Materials"]?.Items ?? Array.Empty<YamlNode>() )
					Assign( asset.Guid, binding, meshId, asset.ModelInfo.MeshIdSource( meshId ), index++, slot.Reference, "renderer " + renderer.FileId );
			}
			// Keep the prior single-model fallback for legacy prefabs without renderer-slot evidence.
			var references = UnityMaterial.References( text ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
			var modelIds = references.Where( g => models.ContainsKey( g ) && (!colliders.Contains( g ) || visible.Contains( g )) ).ToArray();
			if ( modelIds.Length == 1 && !For( modelIds[0] ).HasOverrides )
				foreach ( var material in references.Where( materialIds.Contains ) ) For( modelIds[0] ).Materials.Add( material );
			foreach ( var (guid, candidate) in local )
			{
				// Unity keeps modifications whose target was later removed from the model, and ignores them. An unknown ID is
				// treated as such a stale override only when it cannot be a renderer we failed to identify: every mesh node has
				// reconstructed IDs, metadata does not list the ID, and this prefab's other references to the model resolved.
				var model = models[guid].ModelInfo;
				var resolved = candidate.Evidence.Any( e => e.Problem != Unidentified );
				for ( var i = 0; i < candidate.Evidence.Count; i++ )
				{
					var evidence = candidate.Evidence[i];
					if ( evidence.Problem != Unidentified ) continue;
					if ( resolved && model.RendererIdsComplete && !model.MetadataObjectIds.Contains( evidence.Id ) ) candidate.Evidence[i] = evidence with { Problem = "stale override (no such object in model)" };
					else candidate.Warnings.Add( "A prefab material override targets an unsupported or unidentified renderer; no slot was guessed." );
				}
				if ( !candidates.TryGetValue( guid, out var list ) ) candidates[guid] = list = new();
				list.Add( candidate );
			}
		}
		foreach ( var (guid, list) in candidates )
		{
			var asset = models[guid]; var model = asset.ModelInfo;
			var named = list.Where( p => Path.GetFileNameWithoutExtension( p.Path ).Equals( Path.GetFileNameWithoutExtension( asset.Path ), StringComparison.OrdinalIgnoreCase ) ).ToArray();
			var chosen = named.Length > 0 ? named : list.ToArray();
			model.PrefabVariants.AddRange( Variants( list ) );
			model.EmbeddedSlots.UnionWith( chosen.SelectMany( c => c.Embedded ) );
			foreach ( var slot in chosen.SelectMany( c => c.Slots.Keys ).Distinct( StringComparer.OrdinalIgnoreCase ) )
			{
				var values = chosen.SelectMany( c => c.Slots.GetValueOrDefault( slot ) ?? new HashSet<string>() ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
				if ( values.Length == 1 && materialIds.Contains( values[0] ) ) model.PrefabSlotMaterials[slot] = values[0];
				else if ( model.ImporterMaterials.ContainsKey( slot ) )
					model.AssignmentNotes.Add( $"Prefabs assign different materials to slot '{slot}'; the model's own import material is the default and the prefab variants are material groups." );
				else if ( values.Length > 1 && values.All( materialIds.Contains ) && !chosen.Any( c => c.Contradicted.Contains( slot ) ) )
				{
					// Every value is a real Unity assignment: the most placed one becomes the default, the rest stay as groups.
					var counts = chosen.SelectMany( c => c.Units.Values ).Select( u => u.GetValueOrDefault( slot ) ).Where( m => m != null ).GroupBy( m => m, StringComparer.OrdinalIgnoreCase );
					model.PrefabSlotMaterials[slot] = counts.OrderByDescending( g => g.Count() ).ThenBy( g => g.Key, StringComparer.OrdinalIgnoreCase ).First().Key;
					model.MostCommonSlots.Add( slot );
					model.AssignmentNotes.Add( chosen.Any( c => c.Shared.Contains( slot ) )
						? $"Renderers sharing slot '{slot}' need different materials ({string.Join( ", ", values.Select( v => materialPaths.GetValueOrDefault( v, v ) ) )}); one ModelDoc slot cannot separate them, so the most common is used."
						: $"Prefabs assign different materials to slot '{slot}'; the most common is the default and the others are material groups." );
				}
				else
				{
					model.UnresolvedPrefabSlots.Add( slot );
					model.AssignmentWarnings.Add( $"Prefab assignment for slot '{slot}' is missing or conflicting; no material was guessed." );
				}
			}
			model.PrefabMaterials.AddRange( chosen.SelectMany( c => c.Materials ).Distinct( StringComparer.OrdinalIgnoreCase ) );
			model.AssignmentWarnings.AddRange( chosen.SelectMany( c => c.Warnings ).Distinct() );
			// Prefabs not chosen (another prefab shares the model's filename) are kept as unapplied evidence.
			model.PrefabEvidence.AddRange( list.SelectMany( c => c.Evidence.Select( e => e with { Applied = chosen.Contains( c ) } ) ) );
		}
		foreach ( var guid in colliders.Where( g => !visible.Contains( g ) ) ) models[guid].ModelInfo.CollisionOnly = true;
		// Meshes no placement shows (such as collision hulls exported inside the visual model) are left out of the VMDL.
		foreach ( var guid in placedMeshes.Keys.Union( instanceRemovals.Keys, StringComparer.OrdinalIgnoreCase ) )
		{
			var model = models[guid].ModelInfo;
			var shown = new HashSet<string>( StringComparer.Ordinal );
			if ( placedMeshes.TryGetValue( guid, out var ids ) )
			{
				var bindings = ids.Select( model.ResolveMesh ).ToArray();
				if ( bindings.Any( b => b == null ) ) continue; // An unidentified placement could be any mesh.
				shown.UnionWith( bindings.Select( b => b.Mesh ) );
			}
			foreach ( var removed in instanceRemovals.GetValueOrDefault( guid ) ?? new() )
			{
				// Removing one of several same-named renderers would hide its twins too, so such removals are not used.
				var gone = removed.Overlaps( model.DuplicateIds ) ? new HashSet<string>() :
					removed.Select( id => model.RendererSlots.GetValueOrDefault( id )?.Mesh ).Where( m => m != null ).ToHashSet( StringComparer.Ordinal );
				shown.UnionWith( model.Meshes.Where( m => !gone.Contains( m ) ) );
			}
			model.HiddenMeshes.UnionWith( model.Meshes.Where( m => !shown.Contains( m ) ) );
			if ( model.ImportedMeshes.Length == 0 ) model.HiddenMeshes.Clear(); // Never hide everything the VMDL would import.
		}
	}

	/// <summary>Distinct slot-material sets across every prefab placement of a model, so no Unity variant is lost.</summary>
	static IEnumerable<UnityModel.PrefabVariant> Variants( List<Candidate> list )
	{
		static bool Consistent( Dictionary<string, string> a, Dictionary<string, string> b ) =>
			a.All( p => !b.TryGetValue( p.Key, out var other ) || other.Equals( p.Value, StringComparison.OrdinalIgnoreCase ) );
		var variants = new List<(SortedSet<string> Prefabs, Dictionary<string, string> Slots)>();
		foreach ( var candidate in list )
		{
			// Within one prefab, placements that agree are one variant; disagreeing placements are separate variants.
			var local = new List<Dictionary<string, string>>();
			foreach ( var unit in candidate.Units.Values.Select( u => u.Where( p => !candidate.Contradicted.Contains( p.Key ) && !candidate.Shared.Contains( p.Key ) ).ToDictionary( p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase ) ) )
			{
				if ( unit.Count == 0 ) continue;
				var target = local.FirstOrDefault( v => Consistent( unit, v ) );
				if ( target == null ) local.Add( target = new( StringComparer.OrdinalIgnoreCase ) );
				foreach ( var (slot, material) in unit ) target[slot] = material;
			}
			foreach ( var slots in local ) variants.Add( (new SortedSet<string>( new[] { candidate.Path }, StringComparer.OrdinalIgnoreCase ), slots) );
		}
		// Across prefabs, a set contained in another (same materials for all its slots) adds nothing; fold it in.
		var distinct = new List<(SortedSet<string> Prefabs, Dictionary<string, string> Slots)>();
		foreach ( var variant in variants.OrderByDescending( v => v.Slots.Count ) )
		{
			var container = distinct.FirstOrDefault( d => variant.Slots.All( p => d.Slots.TryGetValue( p.Key, out var m ) && m.Equals( p.Value, StringComparison.OrdinalIgnoreCase ) ) );
			if ( container.Slots != null ) container.Prefabs.UnionWith( variant.Prefabs );
			else distinct.Add( variant );
		}
		return distinct.OrderBy( d => d.Prefabs.Min, StringComparer.OrdinalIgnoreCase ).Select( d => new UnityModel.PrefabVariant( d.Prefabs.ToArray(), d.Slots ) );
	}
}