Editor/UnityPackageWindow.cs
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
namespace ImportUnityPackage;
public sealed class UnityPackageWindow : Widget
{
UnityArchive archive;
ImportCatalog catalog;
ImportPlan plan;
UnityAsset describedAsset;
CancellationTokenSource cancellation;
Task<UnityArchive> readTask;
Task<ImportResult> importTask;
ImportPreparationResult preparation;
ImportCompletion completion;
string reportPath;
readonly Button showReport;
volatile ImportProgress latestProgress;
bool closeWhenCancelled;
readonly Button browse;
readonly Button confirm;
readonly Button cancel;
readonly Label packageLabel;
readonly Label status;
readonly Label selection;
readonly Checkbox additionalTerrain;
readonly Button selectAll;
readonly Button selectNone;
readonly Button expandAll;
readonly Button collapseAll;
readonly IconButton expandLayer;
readonly IconButton collapseLayer;
readonly Button advancedToggle;
readonly Widget advanced;
readonly Label details;
readonly UnityAssetTree tree;
readonly Widget progressBar;
bool Busy => readTask != null || importTask != null;
ImportOptions Options => ImportOptions.Auto( additionalTerrain.Value );
public UnityPackageWindow() : base( null, true )
{
WindowTitle = "Import Unity Package";
SetWindowIcon( "move_to_inbox" );
Size = new Vector2( 760, 680 );
MinimumSize = new Vector2( 580, 500 );
Layout = Layout.Column();
Layout.Margin = 16;
Layout.Spacing = 10;
var fileRow = Layout.AddRow();
fileRow.Spacing = 8;
packageLabel = fileRow.Add( new Label( "Choose a .unitypackage file" ), 1 );
browse = fileRow.Add( new Button( "Browse…" ) );
browse.Clicked = Browse;
var contents = Layout.AddRow();
contents.Spacing = 8;
contents.Add( new Label( "Package contents" ), 1 );
expandAll = contents.Add( new Button( "Expand all" ) );
collapseAll = contents.Add( new Button( "Collapse all" ) );
expandAll.Clicked = () => tree.SetExpanded( true );
collapseAll.Clicked = () => tree.SetExpanded( false );
collapseLayer = contents.Add( new IconButton( "remove", () => tree.ChangeExpandedLayer( false ) ) { ToolTip = "Collapse one layer" } );
expandLayer = contents.Add( new IconButton( "add", () => tree.ChangeExpandedLayer( true ) ) { ToolTip = "Expand one layer" } );
var shortcuts = Layout.AddRow();
shortcuts.Spacing = 8;
selectAll = shortcuts.Add( new Button( "Select all" ) );
selectNone = shortcuts.Add( new Button( "Clear" ) );
shortcuts.AddStretchCell();
selectAll.Clicked = () => SetSelection( a => a.Kind != UnityAssetKind.Unsupported );
selectNone.Clicked = () => SetSelection( _ => false );
var instructions = Layout.Add( new Label( "Select files or folders. Required materials and textures are included automatically; original sources are kept." ) );
instructions.WordWrap = true;
tree = Layout.Add( new UnityAssetTree( this, () => plan, RefreshSelection, DescribeAsset ), 1 );
tree.MinimumHeight = 200;
details = Layout.Add( new Label( "Select a row to see its output and dependency details." ) );
details.WordWrap = true;
selection = Layout.Add( new Label( "No package loaded." ) );
selection.WordWrap = true;
advancedToggle = Layout.Add( new Button( "Advanced ▸" ) );
advanced = Layout.Add( new Widget( this ) );
advanced.Layout = Layout.Column();
additionalTerrain = advanced.Layout.Add( new Checkbox( "Also generate terrain materials (TMAT)" ) );
additionalTerrain.ToolTip = "Adds TMAT output while keeping VMATs for models. Unity terrain layers always generate TMATs. This does not recreate Unity terrain.";
additionalTerrain.StateChanged += _ => RefreshSelection();
advanced.Visible = false;
advancedToggle.Clicked = () => { advanced.Visible = !advanced.Visible; advancedToggle.Text = advanced.Visible ? "Advanced ▾" : "Advanced ▸"; };
var scope = Layout.Add( new Label( "Custom shader appearance and Unity prefab assembly are not reproduced. Further conversion issues appear in the import report." ) );
scope.WordWrap = true;
progressBar = Layout.Add( new Widget( this ) { FixedHeight = 16 } );
progressBar.OnPaintOverride = PaintProgress;
status = Layout.Add( new Label( "Destination: original package folders under Assets/Imported." ) );
status.WordWrap = true;
var buttons = Layout.AddRow();
buttons.Spacing = 8;
showReport = buttons.Add( new Button( "Show report" ) { Visible = false } );
showReport.Clicked = () => EditorUtility.OpenFolder( reportPath );
buttons.AddStretchCell();
cancel = buttons.Add( new Button( "Cancel" ) );
cancel.Clicked = Cancel;
confirm = buttons.Add( new Button.Primary( "Confirm import" ) );
confirm.Clicked = Import;
RefreshSelection();
}
public void Browse()
{
if ( Busy ) return;
var dialog = new FileDialog( this ) { Title = "Import Unity Package", DefaultSuffix = ".unitypackage" };
dialog.SetFindFile();
dialog.SetNameFilter( "Unity Package (*.unitypackage)" );
var appData = Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData );
var unityDirectory = Path.Combine( appData, "Unity" );
if ( !string.IsNullOrEmpty( appData ) && Directory.Exists( unityDirectory ) )
dialog.Directory = unityDirectory;
if ( !dialog.Execute() ) return;
LoadPackage( dialog.SelectedFile );
}
/// <summary>Load a package into the selection window, including from other editor tools.</summary>
public void LoadPackage( string file )
{
if ( Busy ) return;
archive?.Dispose();
archive = null;
catalog = null;
plan = null;
describedAsset = null;
tree.SetItems( Array.Empty<TreeNode>() );
completion = null;
showReport.Visible = false;
WindowTitle = "Import Unity Package";
packageLabel.Text = Path.GetFileName( file );
packageLabel.ToolTip = file;
cancellation = new CancellationTokenSource();
latestProgress = new( 0, "Reading package…" );
readTask = Task.Run( () =>
{
var loaded = UnityArchive.Read( file, new ProgressSink( this ), cancellation.Token );
try
{
latestProgress = new( 1, "Resolving package dependencies…" );
catalog = ImportCatalog.Read( loaded, cancellation.Token );
return loaded;
}
catch { loaded.Dispose(); throw; }
} );
RefreshSelection();
}
void Import()
{
if ( Busy || plan == null || plan.Assets.Count == 0 ) return;
var assets = Project.Current?.GetAssetsPath();
if ( string.IsNullOrEmpty( assets ) )
{
EditorUtility.DisplayDialog( "Import unavailable", "Open a local s&box project before importing." );
return;
}
var confirmedPlan = plan;
cancellation = new CancellationTokenSource();
latestProgress = new( 0, "Preparing import…" );
preparation = null;
completion = null;
showReport.Visible = false;
WindowTitle = "Import Unity Package";
importTask = ImportAndPrepare( confirmedPlan, assets, cancellation.Token );
RefreshSelection();
}
async Task<ImportResult> ImportAndPrepare( ImportPlan confirmedPlan, string assets, CancellationToken token )
{
using var merge = await Task.Run( () => ImportMergePlan.Create( confirmedPlan, assets, token ) );
latestProgress = new( 0, "Review destination files…" );
await ImportConflictWindow.Review( "Review import destinations", merge.Destination, merge.Sources, token );
await Task.Run( () => merge.Prepare( new ProgressSink( this, 0, 0.75 ), token, TextureChannels.Extract ) );
var generatedConflicts = merge.Outputs.Where( c => c.Conflict && !merge.Sources.Any( a => a.Path.Equals( c.Path, StringComparison.OrdinalIgnoreCase ) ) ).ToArray();
if ( generatedConflicts.Length > 0 )
{
latestProgress = new( 0.75, "Review converted resource conflicts…" );
await ImportConflictWindow.Review( "Review converted resources", merge.Destination, generatedConflicts, token );
}
var result = await Task.Run( () => merge.Commit( new ProgressSink( this, 0, 0.8 ), token ) );
latestProgress = new( 0.8, "Preparing imported resources…" );
preparation = await ImportAssetPreparation.RunImport( result, new ProgressSink( this, 0.8, 0.2 ), token );
return result;
}
void Cancel()
{
if ( !Busy ) { Close(); return; }
cancellation.Cancel();
cancel.Enabled = false;
status.Text = "Cancelling…";
}
[EditorEvent.Frame]
void Tick()
{
if ( !IsValid ) return;
if ( Busy && latestProgress != null )
{
status.Text = cancellation.IsCancellationRequested ? "Cancelling…" : $"{latestProgress.Fraction:P0} — {latestProgress.Message}";
progressBar.Update();
}
if ( readTask?.IsCompleted == true )
{
var task = readTask;
readTask = null;
FinishOperation( () =>
{
archive = task.GetAwaiter().GetResult();
tree.Load( archive.Assets );
status.Text = "Ready. Original folders will be preserved under Assets/Imported. Existing files are reviewed before writing.";
} );
}
if ( importTask?.IsCompleted == true )
{
var task = importTask;
importTask = null;
FinishOperation( () =>
{
var result = task.GetAwaiter().GetResult();
SetCompletion( result );
EditorUtility.DisplayDialog( completion.Title, completion.Message );
} );
}
}
void SetCompletion( ImportResult result )
{
completion = ImportCompletion.Create( result, preparation?.Warnings, preparation?.Errors, preparation?.Cancelled == true );
foreach ( var error in completion.Errors ) Log.Error( error );
// Detailed compatibility warnings belong in the report, not hundreds of console lines.
Log.Info( $"{completion.Title}: {result.AssetCount} assets. {completion.Counts}. Report: {result.ReportPath}" );
reportPath = result.ReportPath;
try { completion.WriteReport( reportPath ); }
catch ( Exception ex ) { Log.Warning( $"Could not append completion details to the import report: {ex.Message}" ); }
showReport.Visible = !string.IsNullOrWhiteSpace( reportPath ) && File.Exists( reportPath );
ApplyCompletion();
}
void ApplyCompletion()
{
status.Text = completion.Status;
WindowTitle = $"Import Unity Package — {completion.Title}";
if ( !completion.Cancelled ) latestProgress = new( 1, completion.Title );
progressBar.Update();
}
void FinishOperation( Action action )
{
try { action(); }
catch ( OperationCanceledException ex )
{
status.Text = "Cancelled. No import changes were kept.";
if ( ex.Data["StagingCleanupError"] is string cleanup ) { status.Text += " Temporary files could not be removed; see the console."; Log.Warning( cleanup ); }
}
catch ( Exception ex )
{
status.Text = $"Import failed: {ex.Message}";
Log.Error( $"{status.Text}\n{ex}" );
if ( ex.Data["StagingCleanupError"] is string cleanup ) Log.Warning( cleanup );
EditorUtility.DisplayDialog( "Unity package import failed", ex.Message );
}
finally
{
cancellation.Dispose();
cancellation = null;
RefreshSelection();
if ( closeWhenCancelled ) Close();
}
}
void SetSelection( Func<UnityAsset, bool> select )
{
if ( Busy || archive == null ) return;
foreach ( var asset in archive.Assets ) asset.Selected = select( asset );
RefreshSelection();
}
void DescribeAsset( UnityAsset asset )
{
describedAsset = asset;
if ( asset == null ) { details.Text = "Select a row to see its output and dependency details."; return; }
var entry = plan?.Find( asset );
var reasons = entry?.RequiredBy.Select( d => $"{d.Asset.Path} ({d.Reason})" ).Distinct().ToArray() ?? Array.Empty<string>();
var problems = plan?.Issues.Where( i => i.Asset == asset.Path ).Select( i => i.Message ).ToArray() ?? Array.Empty<string>();
details.Text = asset.Path + "\n" + (entry?.OutputLabel ?? (asset.Kind == UnityAssetKind.Unsupported ? "Unsupported" : "Not included")) +
(reasons.Length == 0 ? "" : " · Required by: " + string.Join( "; ", reasons.Take( 3 ) ) + (reasons.Length > 3 ? $"; +{reasons.Length - 3} more" : "")) +
(problems.Length == 0 ? "" : "\n" + string.Join( " ", problems.Take( 2 ) ));
details.ToolTip = string.Join( "\n", reasons.Concat( problems ) );
}
void RefreshSelection()
{
if ( confirm == null ) return;
browse.Enabled = tree.Enabled = additionalTerrain.Enabled = advancedToggle.Enabled = !Busy;
selectAll.Enabled = selectNone.Enabled = expandAll.Enabled = collapseAll.Enabled = !Busy && archive != null;
expandLayer.Enabled = collapseLayer.Enabled = !Busy && archive != null;
cancel.Enabled = !Busy || cancellation?.IsCancellationRequested != true;
cancel.Text = Busy ? "Cancel operation" : completion == null ? "Cancel" : "Done";
if ( !Busy ) plan = catalog?.CreatePlan( Options );
confirm.Enabled = !Busy && plan?.Assets.Count > 0;
confirm.Text = completion == null ? "Review import" : "Import again";
selection.Text = plan == null ? "No package loaded." :
$"{plan.Assets.Count( a => a.Explicit ):N0} selected + {plan.Assets.Count( a => !a.Explicit ):N0} dependencies = {plan.Assets.Count:N0} files to import\n" +
$"Outputs: {plan.Assets.Count( a => a.Vmdl ):N0} VMDL · {plan.Assets.Count( a => a.Vmat ):N0} VMAT · {plan.Assets.Count( a => a.Tmat ):N0} TMAT · {plan.Issues.Count:N0} reference / source issues · {archive.Assets.Count( a => a.Kind == UnityAssetKind.Unsupported ):N0} unsupported files";
selection.ToolTip = plan == null ? "" : string.Join( "\n", plan.Issues.Select( i => $"{i.Asset}: {i.Message}" ) );
DescribeAsset( describedAsset );
tree.Update();
}
bool PaintProgress()
{
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( progressBar.LocalRect, 3 );
var rect = progressBar.LocalRect;
rect.Width *= (float)Math.Clamp( latestProgress?.Fraction ?? 0, 0, 1 );
Paint.SetBrush( Theme.Blue );
Paint.DrawRect( rect, 3 );
return false;
}
protected override bool OnClose()
{
if ( Busy ) { closeWhenCancelled = true; Cancel(); return false; }
archive?.Dispose();
archive = null;
return true;
}
sealed class ProgressSink( UnityPackageWindow window, double start = 0, double scale = 1 ) : IProgress<ImportProgress>
{
public void Report( ImportProgress value ) => window.latestProgress = new( start + scale * value.Fraction, value.Message );
}
}