Editor code that converts Unity prefabs and scenes into s&box prefab and scene JSON. It parses Unity YAML objects, maps GameObjects, components, meshes, colliders, terrains, lights, cameras, reflection probes and audio sources, composes VMDLs from meshes, and emits scene/prefab JSON with stable deterministic IDs and reports.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace ImportUnityPackage;
/// <summary>An imported model as prefabs and scenes reference it.</summary>
internal sealed class PrefabModel
{
public string Vmdl { get; init; }
public UnityModel Info { get; init; }
public double Scale { get; init; }
public string Name { get; init; }
/// <summary>The mesh file the VMDLs read (the source, or a repaired or ASCII-named copy).</summary>
public string MeshFile { get; init; }
/// <summary>A VMDL holding only one mesh node, for renderers that show one mesh of a multi-mesh model.</summary>
public Func<string, string> MeshVmdl { get; init; }
/// <summary>The material group matching a slot → material GUID assignment ("" for the default group), and whether it matched exactly.</summary>
public Func<IReadOnlyDictionary<string, string>, (string Group, bool Exact)> Group { get; init; }
/// <summary>A physics-only VMDL for a mesh node (null for the whole model), as a convex hull or the exact mesh.</summary>
public Func<string, bool, string> CollisionVmdl { get; init; }
}
/// <summary>
/// A VMDL to compose from mesh files: LOD levels of render meshes (empty for a physics-only model), switch thresholds
/// between levels, material remaps (slot.vmat to material) and an optional physics shape. Name is the output path
/// without extension, relative to the import destination.
/// </summary>
internal sealed record ComposedModel( string Name, IReadOnlyList<IReadOnlyList<MeshPart>> Lods, IReadOnlyList<double> Thresholds, IReadOnlyDictionary<string, string> Remaps, MeshPart Physics = null, bool Convex = false );
/// <summary>A converted skybox: its sky material, the SkyBox2D tint (null for none) and the sky object's yaw in degrees.</summary>
internal sealed record ConvertedSky( string Material, double[] Tint, double Yaw );
/// <summary>An OBJ written from a Unity mesh: its path relative to the destination, its resource path and submesh count, or why it could not be.</summary>
internal sealed record GeneratedMesh( string Relative, string Reference, int Submeshes, string Error );
/// <summary>An imported terrain as scenes and prefabs place it.</summary>
internal sealed class PrefabTerrain
{
/// <summary>The .terrain resource.</summary>
public string Path { get; init; }
/// <summary>Side length and height range in s&box units.</summary>
public double Size { get; init; }
public double Height { get; init; }
/// <summary>The Unity terrain's size and trees (its maps are released).</summary>
public UnityTerrainData Data { get; init; }
}
/// <summary>
/// Converts Unity prefabs and scenes into s&box prefab/scene JSON: GameObject hierarchy and transforms, mesh renderers as
/// ModelRenderers (with the material group matching their materials), colliders, terrains, lights, cameras, reflection
/// probes and audio sources, nested prefabs as prefab references and model instances as whole-model renderers. Scripts
/// and effects are not converted.
/// </summary>
internal sealed partial class UnityPrefabConverter
{
readonly Dictionary<string, UnityAsset> assets;
readonly Dictionary<string, PrefabModel> models;
readonly Dictionary<string, string> prefabOutputs;
readonly Func<string, IReadOnlyList<UnityYamlObject>> parse;
public UnityPrefabConverter( Dictionary<string, UnityAsset> assets, Dictionary<string, PrefabModel> models, Dictionary<string, string> prefabOutputs, Func<string, IReadOnlyList<UnityYamlObject>> parse )
{
this.assets = assets; this.models = models; this.prefabOutputs = prefabOutputs; this.parse = parse;
}
/// <summary>Converted materials by material GUID (VMAT resource paths).</summary>
public Dictionary<string, string> Materials { get; init; } = new( StringComparer.OrdinalIgnoreCase );
/// <summary>Writes a Unity mesh (by owning file GUID and file ID, or a built-in mesh) as an OBJ.</summary>
public Func<string, long, GeneratedMesh> MeshObj { get; init; }
/// <summary>Writes a composed VMDL and returns its resource path.</summary>
public Func<ComposedModel, string> Compose { get; init; }
/// <summary>Puts a collider's physics shape (exact, or one convex hull) in a VMDL written by this import; false when it holds a different shape.</summary>
public Func<string, MeshPart, bool, bool> EmbedPhysics { get; init; }
/// <summary>Converted skybox materials by material GUID.</summary>
public Dictionary<string, ConvertedSky> Skies { get; init; } = new( StringComparer.OrdinalIgnoreCase );
/// <summary>Converted terrains by TerrainData GUID.</summary>
public Dictionary<string, PrefabTerrain> Terrains { get; init; } = new( StringComparer.OrdinalIgnoreCase );
/// <summary>What one conversion produced, for the import report.</summary>
public sealed class Summary
{
public string Source { get; init; }
public int Objects { get; set; }
public int Renderers { get; set; }
public int ModelInstances { get; set; }
public int PrefabInstances { get; set; }
public int InlinedInstances { get; set; }
public int Colliders { get; set; }
public int Terrains { get; set; }
public int GeneratedMeshes { get; set; }
public int LodGroups { get; set; }
public int Lights { get; set; }
public int Cameras { get; set; }
public int Probes { get; set; }
public int Sounds { get; set; }
public int Skies { get; set; }
public int Trees { get; set; }
public SortedDictionary<string, int> Skipped { get; } = new( StringComparer.Ordinal );
public List<string> Notes { get; } = new();
internal void Skip( string type ) => Skipped[type] = Skipped.GetValueOrDefault( type ) + 1;
}
/// <summary>GUIDs of the meshes, prefabs, terrains and audio clips a prefab or scene places.</summary>
internal static IEnumerable<string> PlacedAssets( string text ) =>
Regex.Matches( text, @"(?:m_Mesh|m_SourcePrefab|m_TerrainData|m_audioClip):\s*\{[^}\r\n]*guid:\s*([0-9a-fA-F]{32})" ).Select( m => m.Groups[1].Value.ToLowerInvariant() ).Distinct();
/// <summary>
/// Materials of renderers whose meshes the importer writes itself (built-in primitives, meshes stored in prefabs,
/// scenes or .asset files), so a plan imports them; model meshes bring their materials through the model.
/// </summary>
internal static IEnumerable<string> GeneratedMeshMaterials( IReadOnlyList<UnityYamlObject> objects, Func<string, bool> isModel )
{
var filters = objects.Where( o => o.ClassId == 33 && !o.Stripped ).GroupBy( o => Ref( o.Body, "m_GameObject" ).FileId ).ToDictionary( g => g.Key, g => g.First() );
foreach ( var renderer in objects.Where( o => o.ClassId is 23 or 137 && !o.Stripped ) )
{
var mesh = renderer.ClassId == 137 ? Ref( renderer.Body, "m_Mesh" ) : filters.TryGetValue( Ref( renderer.Body, "m_GameObject" ).FileId, out var filter ) ? Ref( filter.Body, "m_Mesh" ) : default;
if ( mesh.FileId == 0 || mesh.HasGuid && !mesh.Guid.Equals( UnityMesh.BuiltinGuid, StringComparison.OrdinalIgnoreCase ) && isModel( mesh.Guid ) ) continue;
foreach ( var material in renderer.Body["m_Materials"]?.Items ?? Array.Empty<YamlNode>() )
if ( material.Reference.HasGuid ) yield return material.Reference.Guid;
}
}
/// <summary>Deterministic GUIDs, so an unchanged source converts to identical files and references stay stable.</summary>
internal static string Id( string guid, long fileId, string part )
{
var hash = MD5.HashData( Encoding.UTF8.GetBytes( $"{guid}:{fileId}:{part}" ) );
hash[6] = (byte)((hash[6] & 0x0F) | 0x30); hash[8] = (byte)((hash[8] & 0x3F) | 0x80);
return new Guid( hash ).ToString();
}
static UnityReference Ref( YamlNode body, string key ) => body?[key]?.Reference ?? default;
static string F( double v ) => (Math.Abs( v ) < 1e-9 ? 0 : v).ToString( "0.######", CultureInfo.InvariantCulture );
static string Vec( double[] v ) => string.Join( ",", v.Select( F ) );
/// <summary>One parsed file: objects by ID, GameObjects' components and transforms.</summary>
sealed class Document
{
public string Guid;
public Dictionary<long, UnityYamlObject> Objects = new();
public Dictionary<long, List<UnityYamlObject>> Components = new();
public Dictionary<long, UnityYamlObject> TransformOf = new();
public HashSet<long> HiddenRenderers = new();
/// <summary>LOD groups by their LOD0 renderer (when LOD0 has one renderer): each level's screen-relative height and renderers.</summary>
public Dictionary<long, List<(double Height, List<long> Renderers)>> LodGroups = new();
/// <summary>Each LOD group's size (Unity's local m_Size, its largest bounds axis) in inches, by its LOD0 renderer.</summary>
public Dictionary<long, double> LodSizes = new();
/// <summary>The HDRP exposure reference of this file, once computed.</summary>
public double? HdrpReference;
/// <summary>VMDLs of generated meshes by GameObject, mesh GUID and file ID, for colliders on the same object.</summary>
public Dictionary<(long, string, long), string> GeneratedRenders = new();
// Built once per file, so per-object lookups do not scan the whole document (scenes hold tens of thousands of objects).
/// <summary>Root transforms (no father), in file order.</summary>
public List<UnityYamlObject> RootTransforms = new();
/// <summary>Prefab instances by the transform they are placed under (0 for the file's roots), in file order.</summary>
public Dictionary<long, List<UnityYamlObject>> InstancesByParent = new();
/// <summary>Per prefab instance: the source objects of the stripped transforms this file keeps for it.</summary>
public Dictionary<long, List<long>> StrippedTransformSources = new();
/// <summary>Per prefab instance: transforms this file adds under that instance's objects.</summary>
public Dictionary<long, List<UnityYamlObject>> AddedUnderInstance = new();
/// <summary>Per prefab instance: the stripped GameObjects this file keeps for it (components added to its objects hang off them).</summary>
public Dictionary<long, List<long>> StrippedGameObjects = new();
public IEnumerable<UnityYamlObject> InstancesUnder( long parent ) => InstancesByParent.GetValueOrDefault( parent ) ?? Enumerable.Empty<UnityYamlObject>();
public IEnumerable<UnityYamlObject> AddedUnder( long instance ) => AddedUnderInstance.GetValueOrDefault( instance ) ?? Enumerable.Empty<UnityYamlObject>();
public Document( string guid, IReadOnlyList<UnityYamlObject> objects )
{
Guid = guid;
foreach ( var o in objects ) Objects.TryAdd( o.FileId, o );
static void Add<T>( Dictionary<long, List<T>> index, long key, T value )
{
if ( !index.TryGetValue( key, out var list ) ) index[key] = list = new();
list.Add( value );
}
foreach ( var o in Objects.Values )
{
if ( o.ClassId == 1001 && o.Type == "PrefabInstance" && !o.Stripped ) Add( InstancesByParent, Ref( o.Body["m_Modification"], "m_TransformParent" ).FileId, o );
if ( o.ClassId == 1 && o.Stripped ) Add( StrippedGameObjects, Ref( o.Body, "m_PrefabInstance" ).FileId, o.FileId );
if ( o.ClassId is not (4 or 224) ) continue;
if ( o.Stripped ) { Add( StrippedTransformSources, Ref( o.Body, "m_PrefabInstance" ).FileId, Ref( o.Body, "m_CorrespondingSourceObject" ).FileId ); continue; }
var father = Ref( o.Body, "m_Father" ).FileId;
if ( father == 0 ) RootTransforms.Add( o );
else if ( Objects.TryGetValue( father, out var parent ) && parent.Stripped ) Add( AddedUnderInstance, Ref( parent.Body, "m_PrefabInstance" ).FileId, o );
}
foreach ( var o in objects.Where( o => !o.Stripped && o.ClassId != 1 && o.Body?["m_GameObject"] != null ) )
{
var owner = Ref( o.Body, "m_GameObject" ).FileId;
if ( !Components.TryGetValue( owner, out var list ) ) Components[owner] = list = new();
list.Add( o );
if ( o.ClassId is 4 or 224 ) TransformOf[owner] = o;
}
// LODGroup: renderers only in lower levels are not converted on their own; a LOD0 renderer whose levels line up
// gets one VMDL holding every level (LodModel). A renderer also in LOD0 stays.
foreach ( var group in objects.Where( o => o.ClassId == 205 && !o.Stripped ) )
{
var lods = group.Body["m_LODs"]?.Items ?? Array.Empty<YamlNode>();
var levels = lods.Select( l => (Height: l["screenRelativeHeight"]?.Number ?? 0, Renderers: (l["renderers"]?.Items ?? Array.Empty<YamlNode>()).Select( r => Ref( r, "renderer" ).FileId ).Where( id => id != 0 ).ToList()) ).ToList();
// A trailing level without renderers is Unity's culled range, not a level to show.
while ( levels.Count > 1 && levels[^1].Renderers.Count == 0 ) levels.RemoveAt( levels.Count - 1 );
for ( var i = 1; i < levels.Count; i++ )
foreach ( var renderer in levels[i].Renderers )
if ( levels.Count == 0 || !levels[0].Renderers.Contains( renderer ) ) HiddenRenderers.Add( renderer );
if ( levels.Count > 1 && levels[0].Renderers.Count == 1 && LodGroups.TryAdd( levels[0].Renderers[0], levels ) )
LodSizes[levels[0].Renderers[0]] = (group.Body["m_Size"]?.Number ?? 1) * UnityTransform.InchesPerMeter;
}
}
public long GameObjectOf( UnityYamlObject transform ) => Ref( transform.Body, "m_GameObject" ).FileId;
}
readonly Dictionary<string, Document> documents = new( StringComparer.OrdinalIgnoreCase );
Document Load( string guid )
{
if ( !documents.TryGetValue( guid, out var document ) ) documents[guid] = document = new( guid, parse( guid ) );
return document;
}
/// <summary>Unity modifications grouped by the source object they target.</summary>
sealed class Overrides
{
readonly Dictionary<long, Dictionary<string, YamlNode>> byTarget = new();
public Overrides( YamlNode modifications )
{
foreach ( var m in modifications?.Items ?? Array.Empty<YamlNode>() )
{
var target = Ref( m, "target" ).FileId;
var path = m["propertyPath"]?.Value;
if ( path == null ) continue;
if ( !byTarget.TryGetValue( target, out var list ) ) byTarget[target] = list = new( StringComparer.Ordinal );
list[path] = m;
}
}
public IReadOnlyDictionary<string, YamlNode> For( long target ) => byTarget.GetValueOrDefault( target ) ?? (IReadOnlyDictionary<string, YamlNode>)new Dictionary<string, YamlNode>();
public IEnumerable<long> Targets => byTarget.Keys;
/// <summary>The name the outermost placement gives the instance root, when it renames it.</summary>
public string RootName { get; private set; }
/// <summary>Whether an outer modification applied to this instance.</summary>
public bool Merged { get; private set; }
/// <summary>
/// This instance's modifications with an outer file's modifications on top, as Unity applies them (after and over
/// the instance's own). The outer ones target objects of this instance as <paramref name="document"/> sees them:
/// a stripped copy naming its source object, or (Unity 2018.3+) an unserialized ID equal to instance ID XOR source
/// ID with the sign bit cleared. Of the two possible source IDs, one this instance already modifies or one
/// <paramref name="known"/> recognizes is used.
/// </summary>
public Overrides With( Overrides outer, Document document, long instance, Func<long, bool> known )
{
if ( outer == null ) return this;
RootName = outer.RootName;
foreach ( var target in outer.Targets )
{
long source;
if ( document.Objects.TryGetValue( target, out var stripped ) )
{
if ( !stripped.Stripped || Ref( stripped.Body, "m_PrefabInstance" ).FileId != instance ) continue;
source = Ref( stripped.Body, "m_CorrespondingSourceObject" ).FileId;
}
else
{
var masked = (target ^ instance) & long.MaxValue;
var match = new[] { masked, masked | long.MinValue }.Where( c => byTarget.ContainsKey( c ) || known( c ) ).ToArray();
if ( match.Length != 1 ) continue;
source = match[0];
}
if ( !byTarget.TryGetValue( source, out var list ) ) byTarget[source] = list = new( StringComparer.Ordinal );
foreach ( var (path, m) in outer.For( target ) ) list[path] = m;
Merged = true;
if ( RootName == null && Text( outer.For( target ), "m_Name" ) is string name ) RootName = name;
}
return this;
}
public static double? Number( IReadOnlyDictionary<string, YamlNode> values, string path ) => values.TryGetValue( path, out var m ) && m["value"]?.Number is double v ? v : null;
public static string Text( IReadOnlyDictionary<string, YamlNode> values, string path ) => values.TryGetValue( path, out var m ) ? m["value"]?.Value : null;
public static UnityReference Object( IReadOnlyDictionary<string, YamlNode> values, string path ) => values.TryGetValue( path, out var m ) ? Ref( m, "objectReference" ) : default;
}
static (double[] Position, double[] Rotation, double[] Scale) Local( YamlNode transform, IReadOnlyDictionary<string, YamlNode> overrides,
(double[] Position, double[] Rotation, double[] Scale)? defaults = null )
{
var fallback = defaults ?? (new double[] { 0, 0, 0 }, new double[] { 0, 0, 0, 1 }, new double[] { 1, 1, 1 });
double[] Axis( string property, double[] fallbacks, params string[] axes ) =>
axes.Select( ( a, i ) => overrides != null && Overrides.Number( overrides, $"{property}.{a}" ) is double v ? v : transform?[property]?[a]?.Number ?? fallbacks[i] ).ToArray();
var rotation = Axis( "m_LocalRotation", fallback.Rotation, "x", "y", "z", "w" );
return (Axis( "m_LocalPosition", fallback.Position, "x", "y", "z" ), rotation.All( c => c == 0 ) ? new double[] { 0, 0, 0, 1 } : rotation, Axis( "m_LocalScale", fallback.Scale, "x", "y", "z" ));
}
/// <summary>The change of basis from a model's file space (its units and FBX axes) into s&box model space, as the VMDL import applies it.</summary>
static UnityTransform Basis( PrefabModel model ) =>
UnityTransform.Scaling( model.Scale, model.Scale, model.Scale ) * UnityTransform.Linear( new double[,] { { 0, 0, 1 }, { 1, 0, 0 }, { 0, 1, 0 } } );
static void SetTransform( JsonObject target, (double[] Position, double[] Rotation, double[] Scale) unity )
{
var (position, rotation, scale) = UnityTransform.FromUnity( unity.Position, unity.Rotation, unity.Scale );
target["Position"] = Vec( position ); target["Rotation"] = Vec( rotation ); target["Scale"] = Vec( scale );
}
static JsonObject GameObject( string id, string name, bool enabled ) => new()
{
["__guid"] = id, ["__version"] = 2, ["Flags"] = 0, ["Name"] = name, ["Position"] = "0,0,0", ["Rotation"] = "0,0,0,1", ["Scale"] = "1,1,1",
["Tags"] = "", ["Enabled"] = enabled, ["Components"] = new JsonArray(), ["Children"] = new JsonArray()
};
static JsonObject Component( string type, string id, bool enabled ) => new() { ["__type"] = "Sandbox." + type, ["__guid"] = id, ["__enabled"] = enabled, ["Flags"] = 0 };
public string ConvertPrefab( UnityAsset prefab, Summary summary )
{
var document = Load( prefab.Guid );
var root = document.RootTransforms.FirstOrDefault();
JsonObject rootObject;
if ( root != null ) rootObject = Node( document, root, summary, document.Guid, 0 );
else if ( document.InstancesUnder( 0 ).FirstOrDefault() is { } variant )
rootObject = Inline( document, variant, summary, document.Guid, 0 ) ?? GameObject( Id( prefab.Guid, 0, "go" ), System.IO.Path.GetFileNameWithoutExtension( prefab.Path ), true );
else throw new System.IO.InvalidDataException( "The prefab has no root object." );
// Whatever places the prefab sets its position; the root keeps its rotation and scale, as Unity instantiates them.
rootObject["Position"] = "0,0,0";
convertedRoots[prefab.Guid] = rootObject;
var file = new JsonObject { ["RootObject"] = rootObject, ["ShowInMenu"] = false, ["MenuPath"] = null, ["MenuIcon"] = null, ["DontBreakAsTemplate"] = false, ["ResourceVersion"] = 2, ["__references"] = new JsonArray(), ["__version"] = 2 };
return file.ToJsonString( new JsonSerializerOptions { WriteIndented = true } );
}
readonly Dictionary<string, JsonObject> convertedRoots = new( StringComparer.OrdinalIgnoreCase );
readonly HashSet<string> converting = new( StringComparer.OrdinalIgnoreCase );
/// <summary>The root object of a converted prefab (converting it when needed), whose GUID instances refer to.</summary>
JsonObject PrefabRoot( string guid )
{
if ( convertedRoots.TryGetValue( guid, out var root ) ) return root;
if ( !assets.TryGetValue( guid, out var prefab ) || !converting.Add( guid ) ) return null;
try { ConvertPrefab( prefab, new Summary { Source = prefab.Path } ); }
catch ( System.IO.InvalidDataException ) { }
finally { converting.Remove( guid ); }
return convertedRoots.GetValueOrDefault( guid );
}
/// <summary>A Unity scene as an s&box scene: its placed objects, lights, cameras and environment, without scripts.</summary>
public string ConvertScene( UnityAsset scene, Summary summary, string title )
{
var document = Load( scene.Guid );
var objects = new JsonArray();
if ( Environment( document, summary, document.Guid ) is { } environment ) objects.Add( environment );
var roots = document.RootTransforms
.OrderBy( o => o.Body["m_RootOrder"]?.Integer ?? 0 ).ToList();
foreach ( var root in roots ) if ( Node( document, root, summary, document.Guid, 0 ) is { } node && Keep( node ) ) objects.Add( node );
foreach ( var instance in document.InstancesUnder( 0 ) )
if ( Instance( document, instance, summary, document.Guid, 0 ) is { } node ) objects.Add( node );
var file = new JsonObject
{
["__guid"] = Id( scene.Guid, 0, "scene" ),
["GameObjects"] = objects,
["SceneProperties"] = new JsonObject { ["NetworkInterpolation"] = true, ["PhysicsMode"] = "Physics3D", ["TimeScale"] = 1, ["WantsSystemScene"] = true,
["Metadata"] = new JsonObject { ["Title"] = title }, ["NavMesh"] = new JsonObject { ["Enabled"] = false } },
["ResourceVersion"] = 2, ["Title"] = null, ["Description"] = null, ["__references"] = new JsonArray(), ["__version"] = 2
};
return file.ToJsonString( new JsonSerializerOptions { WriteIndented = true } );
}
// Scene roots that hold nothing convertible (cameras, lights, managers) are left out.
static bool Keep( JsonObject node ) => (node["Components"] as JsonArray)?.Count > 0 || ((node["Children"] as JsonArray)?.Any( c => c is JsonObject child && Keep( child ) ) ?? false) || node.ContainsKey( "__Prefab" );
/// <summary>
/// Converts one transform's GameObject and its children. <paramref name="space"/>/<paramref name="salt"/> name the GUID
/// namespace (the file, or an inlined instance), and <paramref name="overrides"/> applies an inlined instance's modifications.
/// </summary>
// Hierarchy depth while converting: a cyclic hierarchy or a prefab that contains itself stops with an error.
int nesting;
void Enter()
{
if ( ++nesting > 512 ) throw new System.IO.InvalidDataException( "The object hierarchy is cyclic or nested too deeply." );
}
JsonObject Node( Document document, UnityYamlObject transform, Summary summary, string space, long salt, Overrides overrides = null )
{
Enter();
try { return NodeBody( document, transform, summary, space, salt, overrides ); }
finally { nesting--; }
}
JsonObject NodeBody( Document document, UnityYamlObject transform, Summary summary, string space, long salt, Overrides overrides )
{
var goId = document.GameObjectOf( transform );
var go = document.Objects.GetValueOrDefault( goId );
var goOverrides = overrides?.For( goId );
var name = Overrides.Text( goOverrides ?? new Dictionary<string, YamlNode>(), "m_Name" ) ?? go?.Body["m_Name"]?.Value ?? "GameObject";
var active = Overrides.Number( goOverrides ?? new Dictionary<string, YamlNode>(), "m_IsActive" ) ?? go?.Body["m_IsActive"]?.Number ?? 1;
var node = GameObject( Id( space, goId ^ salt, "go" ), name.Trim(), active != 0 );
SetTransform( node, Local( transform.Body, overrides?.For( transform.FileId ) ) );
summary.Objects++;
AddComponents( document, goId, go, transform, document.Components.GetValueOrDefault( goId ) ?? new(), node, overrides, summary, space, salt );
var children = node["Children"].AsArray();
// Children in Unity's order, then nested prefab instances parented here.
var childIds = (transform.Body["m_Children"]?.Items ?? Array.Empty<YamlNode>()).Select( c => c.Reference.FileId ).ToList();
foreach ( var childId in childIds )
{
if ( !document.Objects.TryGetValue( childId, out var child ) ) continue;
if ( !child.Stripped ) { if ( Node( document, child, summary, space, salt, overrides ) is { } converted ) children.Add( converted ); }
}
// Instances nested here also receive the outer modifications aimed at their objects.
foreach ( var instance in document.InstancesUnder( transform.FileId ) )
{
var nestedOverrides = overrides == null ? null : new Overrides( instance.Body["m_Modification"]?["m_Modifications"] ).With( overrides, document, instance.FileId, id => SourceHas( instance, id ) );
if ( nestedOverrides is { Merged: false } ) nestedOverrides = null;
if ( Instance( document, instance, summary, space, salt, nestedOverrides ) is { } placed ) children.Add( placed );
}
return node;
}
/// <summary>
/// Converts a GameObject's components onto <paramref name="node"/>. <paramref name="transform"/> is null for components
/// a placement adds to the root object of a nested prefab or model (Unity keeps them on a stripped copy of the object).
/// </summary>
void AddComponents( Document document, long goId, UnityYamlObject go, UnityYamlObject transform, List<UnityYamlObject> parts, JsonObject node,
Overrides overrides, Summary summary, string space, long salt )
{
var components = node["Components"].AsArray();
var children = node["Children"].AsArray();
var filter = parts.FirstOrDefault( c => c.ClassId == 33 );
// Mesh colliders after renderers, so a collider can put its shape in the model the object's renderer shows.
foreach ( var part in parts.OrderBy( p => p.ClassId == 64 ) )
{
switch ( part.ClassId )
{
case 4 or 224 or 33: break;
case 23 or 137:
{
if ( document.HiddenRenderers.Contains( part.FileId ) ) { summary.Skip( "LOD1+ renderer" ); break; }
var mesh = part.ClassId == 137 ? Ref( part.Body, "m_Mesh" ) : Ref( filter?.Body, "m_Mesh" );
if ( part.ClassId == 23 && overrides != null && filter != null && Overrides.Object( overrides.For( filter.FileId ), "m_Mesh" ) is { HasGuid: true } swapped ) mesh = swapped;
var materials = (part.Body["m_Materials"]?.Items ?? Array.Empty<YamlNode>()).Select( m => m.Reference.Guid ).ToList();
var renderOverrides = overrides?.For( part.FileId );
if ( renderOverrides != null )
foreach ( var (path, m) in renderOverrides )
if ( Regex.Match( path, @"^m_Materials\.Array\.data\[([0-9]{1,6})\]$" ) is { Success: true } slot && int.Parse( slot.Groups[1].Value ) is var index && index < 64 )
{
while ( materials.Count <= index ) materials.Add( null );
materials[index] = Ref( m, "objectReference" ).Guid;
}
var enabled = (Overrides.Number( renderOverrides ?? new Dictionary<string, YamlNode>(), "m_Enabled" ) ?? part.Body["m_Enabled"]?.Number ?? 1) != 0;
// Unity's Cast Shadows: 0 Off, 1 On, 2 Two Sided, 3 Shadows Only (invisible, casting only).
var castShadows = Overrides.Number( renderOverrides ?? new Dictionary<string, YamlNode>(), "m_CastShadows" ) ?? part.Body["m_CastShadows"]?.Number ?? 1;
Renderer( document, part, components, children, mesh, materials, enabled, summary, space, goId ^ salt, castShadows );
break;
}
case 64:
MeshCollider( document, components, children, part, overrides?.For( part.FileId ), summary, space, salt );
break;
case 218:
// A terrain is drawn from its object's own transform, which an added component's stripped object doesn't have.
if ( transform == null ) summary.Notes.Add( "A terrain added to an object inside a nested prefab or model is not converted." );
else Terrain( children, part, parts, transform, overrides, summary, space, salt );
break;
case 108:
if ( Light( document, part, parts, overrides?.For( part.FileId ), summary, space, salt ) is { } light ) components.Add( light );
break;
case 20:
components.Add( Camera( part, go, overrides?.For( part.FileId ), summary, space, salt ) );
break;
case 215:
components.Add( ReflectionProbe( part, overrides?.For( part.FileId ), summary, space, salt ) );
break;
case 82:
if ( AudioSource( part, overrides?.For( part.FileId ), summary, space, salt ) is { } sound ) components.Add( sound );
break;
case 154:
// A terrain's collider becomes its EnableCollision setting.
if ( !parts.Any( p => p.ClassId == 218 ) ) summary.Skip( "TerrainCollider without a terrain" );
break;
case 65 or 135 or 136:
if ( Collider( part, overrides?.For( part.FileId ), space, salt ) is { } collider ) { components.Add( collider ); summary.Colliders++; }
break;
default: summary.Skip( part.Type ?? $"class {part.ClassId}" ); break;
}
}
}
/// <summary>
/// A Unity MeshCollider as a static ModelCollider using a collision VMDL of the same mesh: one convex hull when the
/// collider is convex, else the exact mesh. It sits at the renderer offset for that mesh, so both line up.
/// </summary>
void MeshCollider( Document document, JsonArray components, JsonArray children, UnityYamlObject collider, IReadOnlyDictionary<string, YamlNode> overrides, Summary summary, string space, long salt )
{
var ownerGuid = document.Guid;
overrides ??= new Dictionary<string, YamlNode>();
var mesh = Ref( collider.Body, "m_Mesh" );
if ( Overrides.Object( overrides, "m_Mesh" ) is { FileId: not 0 } swapped ) mesh = swapped;
bool Flag( string property, double fallback ) => (Overrides.Number( overrides, property ) ?? collider.Body[property]?.Number ?? fallback) != 0;
if ( mesh.FileId == 0 && !mesh.HasGuid )
{
summary.Skip( "MeshCollider without a mesh" );
return;
}
if ( Generated( mesh ) )
{
// Built-in, embedded and .asset meshes: a collision VMDL from the OBJ written for the mesh.
var generated = GeneratedObj( mesh, ownerGuid, summary );
if ( generated == null ) { summary.Skip( "MeshCollider with an unreadable mesh" ); return; }
var convexShape = Flag( "m_Convex", 0 );
var shape = Component( "ModelCollider", Id( space, collider.FileId ^ salt, "collider" ), Flag( "m_Enabled", 1 ) );
var shapePart = new MeshPart( generated.Reference, Array.Empty<string>(), UnityTransform.InchesPerMeter );
// The shape goes in the model this object's renderer shows for the same mesh; otherwise a physics-only VMDL.
shape["Model"] = document.GeneratedRenders.TryGetValue( (Ref( collider.Body, "m_GameObject" ).FileId, mesh.Guid ?? "", mesh.FileId), out var rendered ) &&
EmbedPhysics != null && EmbedPhysics( rendered, shapePart, convexShape )
? rendered
: Compose( new ComposedModel( ModelBase( mesh, ownerGuid, generated ) + "_iup_collision" + (convexShape ? "_hull" : ""), Array.Empty<IReadOnlyList<MeshPart>>(), null,
new Dictionary<string, string>(), shapePart, convexShape ) );
shape["Static"] = true;
shape["IsTrigger"] = Flag( "m_IsTrigger", 0 );
summary.Colliders++;
components.Add( shape );
return;
}
if ( !models.TryGetValue( mesh.Guid, out var model ) || model.CollisionVmdl == null )
{
summary.Skip( "MeshCollider with a mesh that is not a model" );
return;
}
var convex = Flag( "m_Convex", 0 );
var binding = model.Info.ResolveMesh( mesh.FileId );
if ( binding == null ) summary.Notes.Add( $"A MeshCollider's mesh in '{model.Name}' could not be identified; the whole model's collision is used." );
var offset = binding != null && model.Info.MeshNodes.TryGetValue( binding.Mesh, out var nodeTransform )
? (model.Info.FileAxes * nodeTransform).Inverse().In( Basis( model ) ) : UnityTransform.Identity;
var component = Component( "ModelCollider", Id( space, collider.FileId ^ salt, "collider" ), Flag( "m_Enabled", 1 ) );
component["Model"] = model.CollisionVmdl( binding?.Mesh, convex );
component["Static"] = true;
component["IsTrigger"] = Flag( "m_IsTrigger", 0 );
summary.Colliders++;
if ( offset.IsIdentity() ) { components.Add( component ); return; }
var holder = GameObject( Id( space, collider.FileId ^ salt, "collision" ), "collision", true );
var (position, rotation, scale) = offset.Decompose();
holder["Position"] = Vec( position ); holder["Rotation"] = Vec( rotation ); holder["Scale"] = Vec( scale );
holder["Components"].AsArray().Add( component );
children.Add( holder );
}
/// <summary>
/// Whether a mesh reference is to a mesh the importer writes itself: a Unity built-in primitive, a mesh stored in this
/// file or in a prefab or scene, or a mesh asset (.asset). Model files are imported as models instead.
/// </summary>
bool Generated( UnityReference mesh ) =>
mesh.FileId != 0 && (!mesh.HasGuid || mesh.Guid.Equals( UnityMesh.BuiltinGuid, StringComparison.OrdinalIgnoreCase ) ||
!models.ContainsKey( mesh.Guid ) && assets.GetValueOrDefault( mesh.Guid ) is { Kind: not UnityAssetKind.Model });
static bool Builtin( UnityReference mesh ) => mesh.HasGuid && mesh.Guid.Equals( UnityMesh.BuiltinGuid, StringComparison.OrdinalIgnoreCase );
/// <summary>
/// Where a VMDL made from a generated mesh goes, without extension: beside the mesh's OBJ, except for built-in meshes,
/// whose OBJs are shared in one folder while their VMDLs go beside the prefab or scene that places them.
/// </summary>
string ModelBase( UnityReference mesh, string ownerGuid, GeneratedMesh generated )
{
if ( !Builtin( mesh ) || !assets.TryGetValue( ownerGuid ?? "", out var owner ) ) return System.IO.Path.ChangeExtension( generated.Relative, null );
var folder = (System.IO.Path.GetDirectoryName( owner.Path ) ?? "").Replace( '\\', '/' );
return (folder.Length > 0 ? folder + "/" : "") + System.IO.Path.GetFileNameWithoutExtension( generated.Relative );
}
GeneratedMesh GeneratedObj( UnityReference mesh, string ownerGuid, Summary summary )
{
if ( MeshObj == null || Compose == null ) return null;
var generated = MeshObj( mesh.HasGuid ? mesh.Guid : ownerGuid, mesh.FileId );
if ( generated.Error != null ) { summary.Notes.Add( generated.Error ); return null; }
return generated;
}
/// <summary>A VMDL for a generated mesh with the renderer's materials on its submeshes, in order.</summary>
string GeneratedModel( UnityReference mesh, string ownerGuid, List<string> materials, Summary summary )
{
var generated = GeneratedObj( mesh, ownerGuid, summary );
if ( generated == null ) return null;
var remaps = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
for ( var i = 0; i < generated.Submeshes && i < materials.Count; i++ )
if ( materials[i] != null && Materials.TryGetValue( materials[i], out var vmat ) ) remaps[$"submesh{i}.vmat"] = vmat;
// A built-in mesh is shared by every placement, so its VMDL is also named after the material it shows.
var name = ModelBase( mesh, ownerGuid, generated );
if ( Builtin( mesh ) && remaps.Count > 0 ) name += "_" + System.IO.Path.GetFileNameWithoutExtension( remaps.Values.First() );
return Compose( new ComposedModel( name, new[] { (IReadOnlyList<MeshPart>)new[] { new MeshPart( generated.Reference, Array.Empty<string>(), UnityTransform.InchesPerMeter ) } }, null, remaps ) );
}
/// <summary>
/// A VMDL holding every level of the renderer's LOD group, when each lower level is one renderer on the same object
/// or a sibling with the same transform, showing one identified mesh of an imported model at the same offset. Each
/// level's materials come from its renderer; thresholds from Unity's screen-relative heights. Otherwise null.
/// </summary>
string LodModel( Document document, UnityYamlObject renderer, List<(double Height, List<long> Renderers)> levels, PrefabModel model, UnityModel.RendererBinding binding, UnityTransform offset, List<string> materials )
{
if ( Compose == null || model.MeshFile == null ) return null;
var lods = new List<IReadOnlyList<MeshPart>>();
var thresholds = new List<double>();
var remaps = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
void Remap( UnityModel.RendererBinding b, List<string> mats )
{
for ( var i = 0; i < mats.Count; i++ )
{
var slot = b.Slots.Length == 0 ? (i == 0 ? b.Mesh : null) : i < b.Slots.Length ? b.Slots[i] : null;
if ( slot != null && mats[i] != null && Materials.TryGetValue( mats[i], out var vmat ) ) remaps.TryAdd( slot.ToLowerInvariant() + ".vmat", vmat );
}
}
lods.Add( new[] { new MeshPart( model.MeshFile, new[] { binding.Mesh }, model.Scale ) } );
Remap( binding, materials );
var go = Ref( renderer.Body, "m_GameObject" ).FileId;
var transform = document.TransformOf.GetValueOrDefault( go );
for ( var i = 1; i < levels.Count; i++ )
{
if ( levels[i].Renderers.Count != 1 || !document.Objects.TryGetValue( levels[i].Renderers[0], out var lower ) || lower.Stripped || lower.ClassId is not (23 or 137) ) return null;
var lowerGo = Ref( lower.Body, "m_GameObject" ).FileId;
if ( lowerGo != go )
{
var lowerTransform = document.TransformOf.GetValueOrDefault( lowerGo );
if ( transform == null || lowerTransform == null || Ref( lowerTransform.Body, "m_Father" ).FileId != Ref( transform.Body, "m_Father" ).FileId ) return null;
var a = Local( transform.Body, null ); var b = Local( lowerTransform.Body, null );
if ( a.Position.Zip( b.Position ).Concat( a.Rotation.Zip( b.Rotation ) ).Concat( a.Scale.Zip( b.Scale ) ).Any( p => Math.Abs( p.First - p.Second ) > 1e-4 ) ) return null;
}
var filter = (document.Components.GetValueOrDefault( lowerGo ) ?? new()).FirstOrDefault( c => c.ClassId == 33 );
var mesh = lower.ClassId == 137 ? Ref( lower.Body, "m_Mesh" ) : Ref( filter?.Body, "m_Mesh" );
if ( !mesh.HasGuid || !models.TryGetValue( mesh.Guid, out var lowerModel ) || lowerModel.Vmdl == null || lowerModel.MeshFile == null ) return null;
var lowerBinding = lowerModel.Info.ResolveMesh( mesh.FileId );
if ( lowerBinding == null ) return null;
var lowerOffset = lowerModel.Info.MeshNodes.TryGetValue( lowerBinding.Mesh, out var node ) ? (lowerModel.Info.FileAxes * node).Inverse().In( Basis( lowerModel ) ) : UnityTransform.Identity;
if ( !(lowerOffset * offset.Inverse()).IsIdentity() ) return null;
lods.Add( new[] { new MeshPart( lowerModel.MeshFile, new[] { lowerBinding.Mesh }, lowerModel.Scale ) } );
Remap( lowerBinding, (lower.Body["m_Materials"]?.Items ?? Array.Empty<YamlNode>()).Select( m => m.Reference.Guid ).ToList() );
thresholds.Add( UnityImport.LodThreshold( levels[i - 1].Height, document.LodSizes.GetValueOrDefault( renderer.FileId, UnityImport.DefaultLodSize ) ) );
}
var name = System.IO.Path.ChangeExtension( model.Vmdl.StartsWith( "Imported/", StringComparison.Ordinal ) ? model.Vmdl["Imported/".Length..] : model.Vmdl, null ) + "_iup_lod_" + Regex.Replace( binding.Mesh.Trim(), "[^A-Za-z0-9_-]+", "_" ).Trim( '_' );
return Compose( new ComposedModel( name, lods, thresholds, remaps ) );
}
static string ShadowMode( double castShadows ) => castShadows switch { 0 => "Off", 3 => "ShadowsOnly", _ => "On" };
void Renderer( Document document, UnityYamlObject part, JsonArray components, JsonArray children, UnityReference mesh, List<string> materials, bool enabled, Summary summary, string space, long id, double castShadows = 1 )
{
if ( mesh.HasGuid && models.TryGetValue( mesh.Guid, out var collisionOnly ) && collisionOnly.Vmdl == null )
{
summary.Skip( "renderer of a collision-only mesh" );
return;
}
if ( Generated( mesh ) )
{
if ( GeneratedModel( mesh, document.Guid, materials, summary ) is not { } generatedVmdl ) return;
document.GeneratedRenders[(Ref( part.Body, "m_GameObject" ).FileId, mesh.Guid ?? "", mesh.FileId)] = generatedVmdl;
var generatedRenderer = Component( "ModelRenderer", Id( space, id, "renderer" ), enabled );
generatedRenderer["Model"] = generatedVmdl; generatedRenderer["MaterialGroup"] = null;
generatedRenderer["Tint"] = "1,1,1,1"; generatedRenderer["RenderType"] = ShadowMode( castShadows ); generatedRenderer["CreateAttachments"] = false;
summary.Renderers++; summary.GeneratedMeshes++;
components.Add( generatedRenderer );
return;
}
if ( !mesh.HasGuid || !models.TryGetValue( mesh.Guid, out var model ) )
{
if ( mesh.HasGuid ) summary.Notes.Add( $"A renderer uses a mesh from '{assets.GetValueOrDefault( mesh.Guid )?.Path ?? mesh.Guid}', which is not imported as a model." );
else summary.Skip( "renderer without a model mesh" );
return;
}
var binding = model.Info.ResolveMesh( mesh.FileId );
var slots = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
if ( binding != null )
for ( var i = 0; i < materials.Count; i++ )
{
var slot = binding.Slots.Length == 0 ? (i == 0 ? binding.Mesh : null) : i < binding.Slots.Length ? binding.Slots[i] : null;
if ( slot != null && !string.IsNullOrEmpty( materials[i] ) ) slots[slot] = materials[i];
}
var (group, exact) = model.Group( slots );
if ( !exact ) summary.Notes.Add( $"No material group of '{model.Name}' matches a renderer's materials exactly; the closest is used." );
// One mesh of a multi-mesh model: a VMDL with only that mesh, offset to undo the node transform ModelDoc bakes in.
var vmdl = model.Vmdl;
var offset = UnityTransform.Identity;
if ( binding == null ) summary.Notes.Add( $"A renderer's mesh in '{model.Name}' could not be identified; the whole model is shown." );
else
{
if ( model.Info.ImportedMeshes.Length > 1 || !model.Info.ImportedMeshes.Contains( binding.Mesh ) ) vmdl = model.MeshVmdl( binding.Mesh );
if ( model.Info.MeshNodes.TryGetValue( binding.Mesh, out var nodeTransform ) )
{
// Unity converts a file's axis system with one rotation at the model root, folded into the node transforms
// (a Z-up file's +90 degree X node becomes unrotated); the mesh itself stays in the node's space.
offset = (model.Info.FileAxes * nodeTransform).Inverse().In( Basis( model ) );
}
else summary.Notes.Add( $"Mesh '{binding.Mesh}' of '{model.Name}' has no unique node transform; it is placed without compensation." );
}
// A LOD group: one VMDL with every level, when the levels line up.
if ( binding != null && document.LodGroups.TryGetValue( part.FileId, out var levels ) )
{
if ( LodModel( document, part, levels, model, binding, offset, materials ) is { } lodVmdl ) { vmdl = lodVmdl; group = null; summary.LodGroups++; }
else summary.Notes.Add( $"A LOD group of '{model.Name}' shows only LOD0: its lower levels are not one renderer each at the same place, or not identified meshes." );
}
var renderer = Component( "ModelRenderer", Id( space, id, "renderer" ), enabled );
renderer["Model"] = vmdl; renderer["MaterialGroup"] = string.IsNullOrEmpty( group ) ? null : group;
renderer["Tint"] = "1,1,1,1"; renderer["RenderType"] = ShadowMode( castShadows ); renderer["CreateAttachments"] = false;
summary.Renderers++;
if ( offset.IsIdentity() ) { components.Add( renderer ); return; }
var holder = GameObject( Id( space, id, "mesh" ), "mesh", true );
var (position, rotation, scale) = offset.Decompose();
holder["Position"] = Vec( position ); holder["Rotation"] = Vec( rotation ); holder["Scale"] = Vec( scale );
holder["Components"].AsArray().Add( renderer );
children.Add( holder );
}
/// <summary>
/// A Unity terrain as an s&box Terrain, with its painted trees. Unity draws a terrain from its object's position
/// only, ignoring rotation and scale, with the terrain's corner there; s&box terrains start at the corner on the
/// Unity terrain's +X edge (their local X runs along Unity Z and Y along Unity -X). The terrain and its trees sit on
/// children that undo the object's rotation and scale. Trees are instances of their prototype prefabs or models with
/// the tree's position, rotation about the up axis and width/height scale, which Unity uses in place of the
/// prototype root's own transform.
/// </summary>
void Terrain( JsonArray children, UnityYamlObject component, List<UnityYamlObject> parts, UnityYamlObject transform, Overrides overrides, Summary summary, string space, long salt )
{
var values = overrides?.For( component.FileId ) ?? new Dictionary<string, YamlNode>();
var reference = Ref( component.Body, "m_TerrainData" );
if ( Overrides.Object( values, "m_TerrainData" ) is { HasGuid: true } swapped ) reference = swapped;
if ( !reference.HasGuid || !Terrains.TryGetValue( reference.Guid, out var terrain ) )
{
summary.Notes.Add( reference.HasGuid ? $"Terrain data '{assets.GetValueOrDefault( reference.Guid )?.Path ?? reference.Guid}' is not imported; the terrain was left out." : "A terrain without terrain data was left out." );
return;
}
var local = Local( transform.Body, overrides?.For( transform.FileId ) );
var (_, ownRotation, ownScale) = UnityTransform.FromUnity( local.Position, local.Rotation, local.Scale );
var counter = (UnityTransform.Rotation( ownRotation[0], ownRotation[1], ownRotation[2], ownRotation[3] ) * UnityTransform.Scaling( ownScale[0], ownScale[1], ownScale[2] )).Inverse();
JsonObject Holder( string part, string name, UnityTransform offset )
{
var holder = GameObject( Id( space, component.FileId ^ salt, part ), name, true );
var (position, rotation, scale) = offset.Decompose();
holder["Position"] = Vec( position ); holder["Rotation"] = Vec( rotation ); holder["Scale"] = Vec( scale );
children.Add( holder );
return holder;
}
var size = terrain.Data.Size;
var surface = Holder( "terrain", "terrain", counter * UnityTransform.Translation( 0, -size[0] * UnityTransform.InchesPerMeter, 0 ) );
var result = Component( "Terrain", Id( space, component.FileId ^ salt, "component" ), (Overrides.Number( values, "m_Enabled" ) ?? component.Body["m_Enabled"]?.Number ?? 1) != 0 );
result["Storage"] = terrain.Path;
result["TerrainSize"] = Math.Round( terrain.Size, 3 );
result["TerrainHeight"] = Math.Round( terrain.Height, 3 );
result["EnableCollision"] = parts.Any( p => p.ClassId == 154 && (Overrides.Number( overrides?.For( p.FileId ) ?? new Dictionary<string, YamlNode>(), "m_Enabled" ) ?? p.Body["m_Enabled"]?.Number ?? 1) != 0 );
result["Static"] = true;
surface["Components"].AsArray().Add( result );
summary.Terrains++;
if ( terrain.Data.Trees.Count == 0 ) return;
var trees = Holder( "trees", "Trees", counter )["Children"].AsArray();
var missing = new HashSet<string>();
for ( var i = 0; i < terrain.Data.Trees.Count; i++ )
{
var tree = terrain.Data.Trees[i];
var prototype = tree.Prototype >= 0 && tree.Prototype < terrain.Data.TreePrototypes.Count ? terrain.Data.TreePrototypes[tree.Prototype] : default;
var half = tree.Rotation / 2;
var placement = UnityTransform.FromUnity( new[] { tree.X * size[0], tree.Y * size[1], tree.Z * size[2] }, new[] { 0, Math.Sin( half ), 0, Math.Cos( half ) }, new[] { tree.Width, tree.Height, tree.Width } );
var id = Id( space, component.FileId ^ salt, "tree:" + i );
JsonObject placed = null;
if ( prototype.HasGuid && models.TryGetValue( prototype.Guid, out var model ) && model.Vmdl != null )
{
placed = GameObject( id, model.Name, true );
placed["Position"] = Vec( placement.Position ); placed["Rotation"] = Vec( placement.Rotation ); placed["Scale"] = Vec( placement.Scale );
AddModel( placed, model, "", summary, space, component.FileId ^ salt ^ ((i + 1L) << 32) );
summary.ModelInstances++;
}
else if ( prototype.HasGuid && prefabOutputs.TryGetValue( prototype.Guid, out var path ) && PrefabRoot( prototype.Guid ) is { } root )
{
var rootId = root["__guid"].GetValue<string>();
var properties = new JsonArray();
void Set( string property, JsonNode value ) =>
properties.Add( new JsonObject { ["Target"] = new JsonObject { ["Type"] = "GameObject", ["IdValue"] = rootId }, ["Property"] = property, ["Value"] = value } );
Set( "Name", root["Name"]?.GetValue<string>() ?? "Tree" );
Set( "Position", Vec( placement.Position ) ); Set( "Rotation", Vec( placement.Rotation ) ); Set( "Scale", Vec( placement.Scale ) );
placed = PrefabReference( id, path, rootId, properties );
summary.PrefabInstances++;
}
else missing.Add( prototype.HasGuid ? assets.GetValueOrDefault( prototype.Guid )?.Path ?? prototype.Guid : "a missing prototype" );
if ( placed != null ) { trees.Add( placed ); summary.Trees++; }
}
foreach ( var name in missing ) summary.Notes.Add( $"Trees using '{name}' were left out; the prototype is not imported." );
}
static JsonObject PrefabReference( string instanceId, string path, string rootId, JsonArray properties ) => new()
{
["__guid"] = instanceId, ["__version"] = 2, ["__Prefab"] = path,
["__PrefabInstancePatch"] = new JsonObject { ["AddedObjects"] = new JsonArray(), ["RemovedObjects"] = new JsonArray(), ["PropertyOverrides"] = properties, ["MovedObjects"] = new JsonArray() },
["__PrefabIdToInstanceId"] = new JsonObject { [rootId] = instanceId }
};
static JsonObject Collider( UnityYamlObject collider, IReadOnlyDictionary<string, YamlNode> overrides, string space, long salt )
{
// Prefab instances can override any of these values.
double N( string property, double fallback ) => Num( collider, overrides, property, fallback );
double[] V( string property, double fallback ) => new[] { "x", "y", "z" }.Select( a => N( property + "." + a, fallback ) ).ToArray();
double[] Center() => V( "m_Center", 0 );
double[] S( double[] u ) => new[] { u[2] * UnityTransform.InchesPerMeter, -u[0] * UnityTransform.InchesPerMeter, u[1] * UnityTransform.InchesPerMeter };
var enabled = N( "m_Enabled", 1 ) != 0;
var trigger = N( "m_IsTrigger", 0 ) != 0;
JsonObject result;
switch ( collider.ClassId )
{
case 65:
var size = V( "m_Size", 1 );
result = Component( "BoxCollider", Id( space, collider.FileId ^ salt, "collider" ), enabled );
result["Center"] = Vec( S( Center() ) );
result["Scale"] = Vec( new[] { size[2], size[0], size[1] }.Select( v => Math.Abs( v ) * UnityTransform.InchesPerMeter ).ToArray() );
break;
case 135:
result = Component( "SphereCollider", Id( space, collider.FileId ^ salt, "collider" ), enabled );
result["Center"] = Vec( S( Center() ) );
result["Radius"] = F( N( "m_Radius", 0.5 ) * UnityTransform.InchesPerMeter );
break;
default:
var radius = N( "m_Radius", 0.5 );
var half = Math.Max( 0, N( "m_Height", 2 ) / 2 - radius );
var axis = new double[3];
axis[(int)Math.Clamp( N( "m_Direction", 1 ), 0, 2 )] = half;
var center = Center();
result = Component( "CapsuleCollider", Id( space, collider.FileId ^ salt, "collider" ), enabled );
result["Start"] = Vec( S( center.Zip( axis, ( c, a ) => c - a ).ToArray() ) );
result["End"] = Vec( S( center.Zip( axis, ( c, a ) => c + a ).ToArray() ) );
result["Radius"] = F( radius * UnityTransform.InchesPerMeter );
break;
}
result["IsTrigger"] = trigger; result["Static"] = true;
return result;
}
/// <summary>
/// Components a placement adds to objects inside its nested prefab or model. Unity keeps each such object as a stripped
/// copy, naming its source object, with the added components on it. Returns the copy, its added components, and whether
/// it is the source's root object (by <paramref name="isRoot"/>, given the source object ID).
/// </summary>
IEnumerable<(UnityYamlObject Go, List<UnityYamlObject> Parts, bool Root)> AddedComponents( Document document, UnityYamlObject instance, Func<long, bool> isRoot )
{
foreach ( var id in document.StrippedGameObjects.GetValueOrDefault( instance.FileId ) ?? new List<long>() )
if ( document.Objects.GetValueOrDefault( id ) is { } go && document.Components.GetValueOrDefault( id ) is { Count: > 0 } parts )
yield return (go, parts, isRoot( Ref( go.Body, "m_CorrespondingSourceObject" ).FileId ));
}
// A model's root GameObject: the legacy IDs, or the hashed path of the root node, as for RootTransformIds.
static readonly HashSet<long> RootGameObjectIds = new() { 100000, 100002, UnityFileId.Hash( "Type:GameObject->//RootNode0" ), UnityFileId.Hash( "Type:GameObject->//RootNode/root0" ) };
// Component classes the conversion handles on an object of its own: those of AddComponents, and LOD groups (205), which
// hide their lower levels' renderers in the file that holds them.
static readonly HashSet<int> ConvertedClasses = new() { 20, 23, 33, 64, 65, 82, 108, 135, 136, 137, 154, 205, 215, 218 };
/// <summary>
/// Notes the changes a placed instance makes inside its prefab or model that the conversion does not apply. Components
/// added to the source's root object are converted onto the placed object (see AddComponents), so only those added to
/// objects deeper inside count: as warnings when the conversion would convert them on an object of their own, else like
/// any other unconverted component type. An added LOD group always counts, as its renderers are inside the source, so
/// every level shows. Also GameObjects removed from the source (m_RemovedGameObjects, Unity 2022.2+).
/// </summary>
void ReportUnappliedChanges( Document document, UnityYamlObject instance, string sourcePath, Func<long, bool> isRoot, Summary summary )
{
var modification = instance.Body["m_Modification"];
var seen = new HashSet<long>();
var lost = new List<string>();
foreach ( var (_, parts, root) in AddedComponents( document, instance, isRoot ) )
foreach ( var part in parts )
{
seen.Add( part.FileId );
if ( root && part.ClassId != 205 ) continue;
if ( ConvertedClasses.Contains( part.ClassId ) ) lost.Add( part.Type ?? $"class {part.ClassId}" );
else summary.Skip( part.Type ?? $"class {part.ClassId}" );
}
// Unity 2022.2+ also lists added components; any not on a stripped copy here can't be placed.
foreach ( var entry in modification?["m_AddedComponents"]?.Items ?? Array.Empty<YamlNode>() )
if ( Ref( entry, "addedObject" ).FileId is var id && id != 0 && seen.Add( id ) && document.Objects.GetValueOrDefault( id ) is { } part )
{
if ( ConvertedClasses.Contains( part.ClassId ) ) lost.Add( part.Type ?? $"class {part.ClassId}" );
else summary.Skip( part.Type ?? $"class {part.ClassId}" );
}
if ( lost.Count > 0 )
summary.Notes.Add( $"{lost.Count} component(s) added to objects inside an instance of '{sourcePath}' are not converted: " +
string.Join( ", ", lost.GroupBy( t => t ).Select( g => g.Count() > 1 ? $"{g.Key} ×{g.Count()}" : g.Key ) ) + "." );
var removedObjects = (modification?["m_RemovedGameObjects"]?.Items ?? Array.Empty<YamlNode>()).Count;
if ( removedObjects > 0 ) summary.Notes.Add( $"{removedObjects} GameObject(s) removed from an instance of '{sourcePath}' are still shown." );
}
/// <summary>The root GameObject of a nested prefab (0 for a variant, which has no root object of its own).</summary>
long PrefabRootObject( string guid ) => Load( guid ) is { } source && source.RootTransforms.FirstOrDefault() is { } root ? source.GameObjectOf( root ) : 0;
/// <summary>
/// A nested prefab or model placed by <paramref name="instance"/>. <paramref name="given"/> are the instance's
/// modifications already merged with those of the files placing it, when it sits inside another instance.
/// </summary>
JsonObject Instance( Document document, UnityYamlObject instance, Summary summary, string space, long salt, Overrides given = null )
{
var source = Ref( instance.Body, "m_SourcePrefab" );
var overrides = given ?? new Overrides( instance.Body["m_Modification"]?["m_Modifications"] );
var sourcePath = assets.GetValueOrDefault( source.Guid ?? "" )?.Path ?? source.Guid;
if ( models.TryGetValue( source.Guid ?? "", out var model ) )
{
ReportUnappliedChanges( document, instance, sourcePath, RootGameObjectIds.Contains, summary );
return model.Vmdl == null ? null : ModelInstance( document, instance, model, overrides, summary, space, salt );
}
if ( !assets.TryGetValue( source.Guid ?? "", out var nested ) || !nested.Path.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) )
{
summary.Notes.Add( $"A nested prefab or model '{sourcePath}' is not imported; it was left out." );
return null;
}
var nestedRoot = PrefabRootObject( nested.Guid );
ReportUnappliedChanges( document, instance, sourcePath, id => id == nestedRoot, summary );
// Objects added under objects inside the nested prefab, and components added to its root, need the nested prefab's
// content here, so it is inlined.
var addsInside = document.AddedUnder( instance.FileId ).Any() ||
AddedComponents( document, instance, id => id == nestedRoot ).Any( a => a.Root && a.Parts.Any( p => ConvertedClasses.Contains( p.ClassId ) && p.ClassId != 205 ) );
var removed = instance.Body["m_Modification"]?["m_RemovedComponents"]?.Items.Count > 0;
if ( !prefabOutputs.TryGetValue( nested.Guid, out var path ) || addsInside || removed || given != null ) return Inline( document, instance, summary, space, salt, overrides );
var sourceDocument = Load( nested.Guid );
var sourceRoot = sourceDocument.RootTransforms.FirstOrDefault();
// A prefab variant has no root object of its own to override, so it is copied in place with these overrides on top.
if ( sourceRoot == null ) return Inline( document, instance, summary, space, salt, overrides );
var instanceId = Id( space, instance.FileId ^ salt, "go" );
var rootGo = sourceDocument.GameObjectOf( sourceRoot );
var rootId = Id( sourceDocument.Guid, rootGo, "go" );
var properties = new JsonArray();
void Set( string type, string target, string property, JsonNode value ) =>
properties.Add( new JsonObject { ["Target"] = new JsonObject { ["Type"] = type, ["IdValue"] = target }, ["Property"] = property, ["Value"] = value } );
summary.PrefabInstances++;
// Root: name and transform always, as s&box prefab instances store them.
var rootGoOverrides = overrides.For( rootGo );
Set( "GameObject", rootId, "Name", (Overrides.Text( rootGoOverrides, "m_Name" ) ?? sourceDocument.Objects.GetValueOrDefault( rootGo )?.Body["m_Name"]?.Value ?? nested.Path).Trim() );
var (position, rotation, scale) = UnityTransform.FromUnity( Local( sourceRoot.Body, overrides.For( sourceRoot.FileId ) ).Position, Local( sourceRoot.Body, overrides.For( sourceRoot.FileId ) ).Rotation, Local( sourceRoot.Body, overrides.For( sourceRoot.FileId ) ).Scale );
Set( "GameObject", rootId, "Position", Vec( position ) ); Set( "GameObject", rootId, "Rotation", Vec( rotation ) ); Set( "GameObject", rootId, "Scale", Vec( scale ) );
if ( Overrides.Number( rootGoOverrides, "m_IsActive" ) is double rootActive ) Set( "GameObject", rootId, "Enabled", rootActive != 0 );
// Other modifications on objects defined directly in the nested prefab.
foreach ( var target in overrides.Targets.Where( t => t != rootGo && t != sourceRoot.FileId ) )
{
var values = overrides.For( target );
if ( !sourceDocument.Objects.TryGetValue( target, out var targeted ) || targeted.Stripped )
{
if ( values.Keys.Any( k => k.StartsWith( "m_Materials", StringComparison.Ordinal ) || k.StartsWith( "m_Local", StringComparison.Ordinal ) ) )
summary.Notes.Add( $"A modification of an object nested deeper inside '{nested.Path}' is not applied." );
continue;
}
switch ( targeted.ClassId )
{
case 1:
if ( Overrides.Text( values, "m_Name" ) is string name ) Set( "GameObject", Id( sourceDocument.Guid, target, "go" ), "Name", name.Trim() );
if ( Overrides.Number( values, "m_IsActive" ) is double active ) Set( "GameObject", Id( sourceDocument.Guid, target, "go" ), "Enabled", active != 0 );
break;
case 4 or 224:
if ( values.Keys.Any( k => k.StartsWith( "m_Local", StringComparison.Ordinal ) ) )
{
var local = Local( targeted.Body, values );
var converted = UnityTransform.FromUnity( local.Position, local.Rotation, local.Scale );
var go = Id( sourceDocument.Guid, sourceDocument.GameObjectOf( targeted ), "go" );
Set( "GameObject", go, "Position", Vec( converted.Position ) ); Set( "GameObject", go, "Rotation", Vec( converted.Rotation ) ); Set( "GameObject", go, "Scale", Vec( converted.Scale ) );
}
break;
case 23 or 137:
if ( values.Keys.Any( k => k.StartsWith( "m_Materials", StringComparison.Ordinal ) ) && MaterialGroupFor( sourceDocument, targeted, values ) is var (group, known) && known )
Set( "Component", Id( sourceDocument.Guid, sourceDocument.GameObjectOf( targeted ), "renderer" ), "MaterialGroup", string.IsNullOrEmpty( group ) ? null : group );
break;
}
}
return PrefabReference( instanceId, path, rootId, properties );
}
(string Group, bool Known) MaterialGroupFor( Document document, UnityYamlObject renderer, IReadOnlyDictionary<string, YamlNode> values )
{
var filter = (document.Components.GetValueOrDefault( document.GameObjectOf( renderer ) ) ?? new()).FirstOrDefault( c => c.ClassId == 33 );
var mesh = renderer.ClassId == 137 ? Ref( renderer.Body, "m_Mesh" ) : Ref( filter?.Body, "m_Mesh" );
if ( !mesh.HasGuid || !models.TryGetValue( mesh.Guid, out var model ) || model.Info.ResolveMesh( mesh.FileId ) is not { } binding ) return (null, false);
var materials = (renderer.Body["m_Materials"]?.Items ?? Array.Empty<YamlNode>()).Select( m => m.Reference.Guid ).ToList();
foreach ( var (path, m) in values )
if ( Regex.Match( path, @"^m_Materials\.Array\.data\[([0-9]{1,6})\]$" ) is { Success: true } slot && int.Parse( slot.Groups[1].Value ) is var index && index < 64 )
{
while ( materials.Count <= index ) materials.Add( null );
materials[index] = Ref( m, "objectReference" ).Guid;
}
var slots = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
for ( var i = 0; i < materials.Count; i++ )
{
var name = binding.Slots.Length == 0 ? (i == 0 ? binding.Mesh : null) : i < binding.Slots.Length ? binding.Slots[i] : null;
if ( name != null && !string.IsNullOrEmpty( materials[i] ) ) slots[name] = materials[i];
}
return (model.Group( slots ).Group, true);
}
/// <summary>A nested prefab copied in place with the instance's modifications applied.</summary>
JsonObject Inline( Document document, UnityYamlObject instance, Summary summary, string space, long salt, Overrides given = null )
{
Enter();
try { return InlineBody( document, instance, summary, space, salt, given ); }
finally { nesting--; }
}
JsonObject InlineBody( Document document, UnityYamlObject instance, Summary summary, string space, long salt, Overrides given )
{
var source = Ref( instance.Body, "m_SourcePrefab" );
if ( !assets.TryGetValue( source.Guid ?? "", out var nested ) || !nested.Path.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) )
return models.ContainsKey( source.Guid ?? "" ) ? ModelInstance( document, instance, models[source.Guid], given ?? new Overrides( instance.Body["m_Modification"]?["m_Modifications"] ), summary, space, salt )
: Instance( document, instance, summary, space, salt, given );
var sourceDocument = Load( nested.Guid );
var sourceRoot = sourceDocument.RootTransforms.FirstOrDefault();
var overrides = given ?? new Overrides( instance.Body["m_Modification"]?["m_Modifications"] );
summary.InlinedInstances++;
// Unity names objects inside an instance (instance ID XOR source ID) with the sign bit cleared; the same derivation keeps GUIDs unique.
var nestedSalt = salt ^ instance.FileId;
JsonObject result;
if ( sourceRoot != null ) result = Node( sourceDocument, sourceRoot, summary, space, nestedSalt, overrides );
else if ( sourceDocument.InstancesUnder( 0 ).FirstOrDefault() is { } variant )
result = Inline( sourceDocument, variant, summary, space, nestedSalt, new Overrides( variant.Body["m_Modification"]?["m_Modifications"] ).With( overrides, sourceDocument, variant.FileId, id => SourceHas( variant, id ) ) );
else return null;
if ( result == null ) return null;
var removed = (instance.Body["m_Modification"]?["m_RemovedComponents"]?.Items ?? Array.Empty<YamlNode>()).Count;
if ( removed > 0 ) summary.Notes.Add( $"{removed} component(s) removed by an instance of '{nested.Path}' are still shown." );
// Components this file adds to the nested prefab's root object go on the inlined copy's root.
var rootObject = sourceRoot != null ? sourceDocument.GameObjectOf( sourceRoot ) : 0;
foreach ( var (go, parts, root) in AddedComponents( document, instance, id => id == rootObject ) )
if ( root ) AddComponents( document, go.FileId, go, null, parts, result, null, summary, space, salt );
// Objects this file adds under objects of the nested prefab go under the inlined copy's root.
foreach ( var added in document.AddedUnder( instance.FileId ) )
if ( Node( document, added, summary, space, salt ) is { } node ) result["Children"].AsArray().Add( node );
return result;
}
// A model's root transform: the legacy IDs, or the hashed path of the root node (named "root" when the file has one top node).
static readonly HashSet<long> RootTransformIds = new() { 400000, 400002, UnityFileId.Hash( "Type:Transform->//RootNode/Transform0" ), UnityFileId.Hash( "Type:Transform->//RootNode/root/Transform0" ) };
/// <summary>Whether the source of <paramref name="instance"/> (a prefab or model) has an object with this ID.</summary>
bool SourceHas( UnityYamlObject instance, long id )
{
var source = Ref( instance.Body, "m_SourcePrefab" ).Guid ?? "";
if ( models.TryGetValue( source, out var model ) )
return RootTransformIds.Contains( id ) || model.Info.MetadataObjectIds.Contains( id ) || model.Info.RendererSlots.ContainsKey( id ) || model.Info.ResolveMesh( id ) != null;
return assets.TryGetValue( source, out var prefab ) && prefab.Kind == UnityAssetKind.Prefab && Load( prefab.Guid ).Objects.ContainsKey( id );
}
/// <summary>
/// The whole model's renderer (and its generated collider) on a placed model's root object. When the model's single
/// top node is the root, the VMDL bakes that node's transform into the mesh, so the parts sit under its inverse.
/// </summary>
void AddModel( JsonObject node, PrefabModel model, string group, Summary summary, string space, long id )
{
var collapsed = model.Info.CollapsedRoot is { } top ? (model.Info.FileAxes * top).In( Basis( model ) ) : null;
var renderer = Component( "ModelRenderer", Id( space, id, "renderer" ), true );
renderer["Model"] = model.Vmdl; renderer["MaterialGroup"] = string.IsNullOrEmpty( group ) ? null : group;
renderer["Tint"] = "1,1,1,1"; renderer["RenderType"] = "On"; renderer["CreateAttachments"] = false;
var parts = new List<JsonObject> { renderer };
// Unity's "Generate Colliders" puts an exact collider on every mesh; the VMDL carries that physics mesh.
if ( model.Info.GenerateColliders )
{
var collider = Component( "ModelCollider", Id( space, id, "collider" ), true );
collider["Model"] = model.Vmdl; collider["Static"] = true; collider["IsTrigger"] = false;
parts.Add( collider );
summary.Colliders++;
}
if ( collapsed == null || collapsed.IsIdentity() ) foreach ( var part in parts ) node["Components"].AsArray().Add( part );
else
{
var holder = GameObject( Id( space, id, "mesh" ), "mesh", true );
var (position, rotation, scale) = collapsed.Inverse().Decompose();
holder["Position"] = Vec( position ); holder["Rotation"] = Vec( rotation ); holder["Scale"] = Vec( scale );
foreach ( var part in parts ) holder["Components"].AsArray().Add( part );
node["Children"].AsArray().Add( holder );
}
}
/// <summary>A model placed as a prefab instance: the whole model's VMDL with the material group of its overrides.</summary>
JsonObject ModelInstance( Document document, UnityYamlObject instance, PrefabModel model, Overrides overrides, Summary summary, string space, long salt )
{
// The instance root: the stripped transform this file keeps for it, or the model's root transform IDs.
var rootIds = RootTransformIds;
var strippedRoot = document.StrippedTransformSources.GetValueOrDefault( instance.FileId ) ?? new List<long>();
// The root transform: a known root ID, else the transform carrying m_RootOrder (only an instance root has one),
// else a stripped transform this file keeps for the instance, else the only transformed target.
var transformed = overrides.Targets.Where( t => overrides.For( t ).Keys.Any( k => k.StartsWith( "m_Local", StringComparison.Ordinal ) || k == "m_RootOrder" ) ).ToList();
var rootTransform = strippedRoot.Concat( transformed ).FirstOrDefault( rootIds.Contains, 0 );
if ( rootTransform == 0 ) rootTransform = transformed.FirstOrDefault( t => overrides.For( t ).ContainsKey( "m_RootOrder" ) );
if ( rootTransform == 0 ) rootTransform = strippedRoot.FirstOrDefault();
if ( rootTransform == 0 && transformed.Count == 1 ) rootTransform = transformed[0];
var named = overrides.Targets.FirstOrDefault( t => Overrides.Text( overrides.For( t ), "m_Name" ) != null );
var name = overrides.RootName ?? (named != 0 ? Overrides.Text( overrides.For( named ), "m_Name" ) : model.Name);
// The root GameObject: the renamed target (Unity records the instance root's name), else the only GameObject target
// that is switched on or off (not a transform or renderer of the model).
var activeTargets = overrides.Targets.Where( t => Overrides.Number( overrides.For( t ), "m_IsActive" ) != null && !rootIds.Contains( t ) && !model.Info.RendererSlots.ContainsKey( t ) ).ToList();
var rootObject = named != 0 ? named : activeTargets.Count == 1 ? activeTargets[0] : 0;
var active = rootObject != 0 ? Overrides.Number( overrides.For( rootObject ), "m_IsActive" ) ?? 1 : 1;
var node = GameObject( Id( space, instance.FileId ^ salt, "go" ), name.Trim(), active != 0 );
// A file with one top node: Unity makes that node the prefab root, so its transform is the root's default, which the
// placement's overrides replace. The VMDL bakes it into the mesh, so the renderer sits under its inverse.
var collapsed = model.Info.CollapsedRoot is { } top ? (model.Info.FileAxes * top).In( Basis( model ) ) : null;
SetTransform( node, Local( null, overrides.For( rootTransform ), collapsed == null ? null : UnityTransform.ToUnity( collapsed.Decompose() ) ) );
// Material overrides on the model's renderers give the slot assignment for this placement.
var slots = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
foreach ( var target in overrides.Targets )
{
var binding = model.Info.RendererSlots.GetValueOrDefault( target );
foreach ( var (path, m) in overrides.For( target ) )
if ( binding != null && Regex.Match( path, @"^m_Materials\.Array\.data\[([0-9]{1,6})\]$" ) is { Success: true } slot && int.Parse( slot.Groups[1].Value ) is var index )
{
var slotName = binding.Slots.Length == 0 ? (index == 0 ? binding.Mesh : null) : index < binding.Slots.Length ? binding.Slots[index] : null;
if ( slotName != null && Ref( m, "objectReference" ).HasGuid ) slots[slotName] = Ref( m, "objectReference" ).Guid;
}
}
var (group, exact) = model.Group( slots );
if ( !exact ) summary.Notes.Add( $"No material group of '{model.Name}' matches an instance's materials exactly; the closest is used." );
AddModel( node, model, group, summary, space, instance.FileId ^ salt );
summary.ModelInstances++;
// Components this file adds to the model's root object (often a MeshCollider) go on the placed object.
foreach ( var (go, parts, root) in AddedComponents( document, instance, RootGameObjectIds.Contains ) )
if ( root ) AddComponents( document, go.FileId, go, null, parts, node, null, summary, space, salt );
if ( overrides.Targets.Any( t => t != rootTransform && overrides.For( t ).Keys.Any( k => k.StartsWith( "m_Local", StringComparison.Ordinal ) ) ) )
summary.Notes.Add( $"Moved parts inside an instance of '{model.Name}' keep their model positions." );
// Removed renderers matter only when their meshes are still in the VMDL (meshes no placement shows are left out of it).
var removedMeshes = (instance.Body["m_Modification"]?["m_RemovedComponents"]?.Items ?? Array.Empty<YamlNode>())
.Select( r => model.Info.RendererSlots.GetValueOrDefault( r.Reference.FileId )?.Mesh ).Where( m => m != null && model.Info.ImportedMeshes.Contains( m ) ).Distinct().ToArray();
if ( removedMeshes.Length > 0 ) summary.Notes.Add( $"Meshes removed from an instance of '{model.Name}' are still shown: {string.Join( ", ", removedMeshes )}." );
// Objects this file adds under the model's objects.
foreach ( var added in document.AddedUnder( instance.FileId ) )
if ( Node( document, added, summary, space, salt ) is { } child ) node["Children"].AsArray().Add( child );
return node;
}
}