Editor/UnrealImportWindow.cs

Editor UI window for importing assets from an Unreal project into s&box. It scans a .uproject Content folder for .uasset and .umap files, classifies meshes and materials, shows a tree/flat view with thumbnails and tri counts, and runs a headless Unreal export (via HeadlessExporter/ue_export.py and UnrealEditor-Cmd) then hands the exported staging to AssetImporter to generate vmat/vmdl and write into the selected Assets output layout.

File AccessProcess ExecutionNetworking
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;

namespace Editor.UnrealImporter;

/// <summary>
/// Editor tool: pick an Unreal project folder, tick the static meshes and materials to bring
/// over, and export them to sbox (FBX + vmat + vmdl) via a headless Unreal pass + kv3 generation.
///
/// Materials can be picked on their own - they import as a standalone vmat, which is the whole
/// point of surface packs (Megascans Surfaces are a material plus its textures, no mesh).
///
/// The browser is a folder tree mirroring /Game. Folder checkboxes (tri-state) tick whole
/// subtrees, maps sit inline in their folders (double-click to import), and each mesh row
/// shows the triangle count read straight from the .uasset's embedded asset-registry tags.
/// Searching flattens the tree to matches.
///
/// TODO: max texture resolution selection
/// TODO: make async with progress bar
/// </summary>
[EditorApp( "Unreal Importer", "move_to_inbox", "Import Unreal / Fab meshes and materials into s&box" )]
public class UnrealImportWindow : Widget
{
	/// <summary>What an entry turns into once imported.</summary>
	enum AssetKind
	{
		/// <summary>StaticMesh -> fbx + vmdl (+ the vmats of its slots).</summary>
		Mesh,

		/// <summary>Material / Material Instance -> a standalone vmat.</summary>
		Material,
	}

	class AssetEntry
	{
		public AssetKind Kind;
		public string GamePath;     // /Game/.../SM_X
		public string AbsPath;      // ...\Content\...\SM_X.uasset
		public string Display;      // GamePath without the /Game/ prefix
		public long SizeBytes;      // .uasset on disk (uncooked, so this is the whole asset)
		public long Triangles = -1; // meshes only: from the uasset's asset-registry tags; -1 until read
		public bool Selected;       // opt-in: nothing ticked until the user picks

		public bool IsMesh => Kind == AssetKind.Mesh;
	}

	class MapEntry
	{
		public string GamePath;     // /Game/.../Maps/Demonstration
		public string AbsPath;      // ...\Content\...\Demonstration.umap
		public string Display;
	}

	class FolderBucket
	{
		public readonly SortedSet<string> Subfolders = new( StringComparer.OrdinalIgnoreCase );
		public readonly List<AssetEntry> Assets = new();
		public readonly List<MapEntry> Maps = new();

		/// <summary>Every asset anywhere below this folder - drives the tri-state checkbox.</summary>
		public readonly List<AssetEntry> Subtree = new();
	}

	const float CheckWidth = 26;
	const float ThumbSize = 34;

	interface ICheckRow
	{
		void OnCheckClicked();
	}

	/// <summary>A row that can supply a large hover preview.</summary>
	interface IPreviewRow
	{
		Pixmap PreviewPixmap { get; }
		string PreviewCaption { get; }
	}

	/// <summary>
	/// Frameless tooltip window showing a row's embedded thumbnail at full size
	/// (Unreal stores them at 256x256; the list shrinks them to 34px).
	/// </summary>
	class ThumbPreview : Widget
	{
		const float ImageSize = 256;
		const float CaptionHeight = 20;
		const float Pad = 8;

		readonly Pixmap pixmap;
		readonly string caption;

		public object Key;

		public ThumbPreview( Pixmap pixmap, string caption, Vector2 screenPos ) : base( null )
		{
			this.pixmap = pixmap;
			this.caption = caption;

			WindowFlags = WindowFlags.ToolTip | WindowFlags.FramelessWindowHint | WindowFlags.WindowDoesNotAcceptFocus;
			FocusMode = FocusMode.None;
			TransparentForMouseEvents = true;
			ShowWithoutActivating = true;
			NoSystemBackground = true;

			Size = new Vector2( ImageSize + Pad * 2, ImageSize + CaptionHeight + Pad * 2 );
			Position = screenPos;
			Show();
		}

		protected override void OnPaint()
		{
			Paint.ClearPen();
			Paint.SetBrushAndPen( Theme.ControlBackground, Theme.Border );
			Paint.DrawRect( LocalRect );

			var img = LocalRect.Shrink( Pad );
			img.Height = ImageSize;
			Paint.Draw( img, pixmap );

			var text = LocalRect.Shrink( Pad );
			text.Top += ImageSize;
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.8f ) );
			Paint.SetDefaultFont( 7 );
			Paint.DrawText( text, caption, TextFlag.Center );
		}
	}

	/// <summary>
	/// TreeView that routes clicks on the leading checkbox column to the row, and pops a
	/// large thumbnail preview after hovering a mesh/map row briefly.
	/// </summary>
	class ImportTreeView : TreeView
	{
		ThumbPreview preview;
		object hoverNode;
		RealTimeSince hoverSince;

		public ImportTreeView( Widget parent ) : base( parent )
		{
			MouseTracking = true;
		}

		protected override bool OnItemPressed( VirtualWidget pressedItem, MouseEvent e )
		{
			if ( e.LeftMouseButton && pressedItem.Object is ICheckRow row )
			{
				var box = pressedItem.Rect;
				box.Left += IndentWidth * pressedItem.Column + ExpandWidth;
				box.Width = CheckWidth;

				if ( box.IsInside( e.LocalPosition ) )
				{
					row.OnCheckClicked();
					Update();
					return false;
				}
			}

			return base.OnItemPressed( pressedItem, e );
		}

		protected override void OnMouseMove( MouseEvent e )
		{
			base.OnMouseMove( e );

			var node = GetItemAt( e.LocalPosition )?.Object;
			if ( node == hoverNode )
				return;

			hoverNode = node;
			hoverSince = 0;

			if ( preview.IsValid() && preview.Key != node )
			{
				preview.Destroy();
				preview = null;
			}
		}

		protected override void OnMouseLeave()
		{
			base.OnMouseLeave();
			ClearPreview();
		}

		public override void OnDestroyed()
		{
			base.OnDestroyed();
			ClearPreview();
		}

		void ClearPreview()
		{
			hoverNode = null;
			preview?.Destroy();
			preview = null;
		}

		[EditorEvent.Frame]
		public void ShowPreviewWhenSettled()
		{
			if ( preview.IsValid() || hoverNode is not IPreviewRow row || hoverSince < 0.35f )
				return;

			if ( row.PreviewPixmap is null )
				return;

			// To the right of the cursor, nudged up so the image is centred on the row.
			var pos = Application.CursorPosition + new Vector2( 28, -140 );
			preview = new ThumbPreview( row.PreviewPixmap, row.PreviewCaption, pos ) { Key = hoverNode };
		}
	}

	class FolderNode : TreeNode, ICheckRow
	{
		readonly UnrealImportWindow win;
		readonly string path;   // folder path relative to /Game ("" only for the virtual root)

		public FolderNode( UnrealImportWindow win, string path )
		{
			this.win = win;
			this.path = path;
			Value = "folder:" + path;
			Height = 26;
		}

		public override bool HasChildren => win.FolderHasChildren( path );

		protected override void BuildChildren()
		{
			Clear();
			AddItems( win.BuildFolderChildNodes( path ) );
		}

		public override void OnPaint( VirtualWidget item )
		{
			ImportStyle.PaintRow( item, TreeView );
			var r = item.Rect;

			var (sel, total) = win.SubtreeSelection( path );

			var check = r;
			check.Width = CheckWidth;
			Paint.SetPen( sel > 0 ? Theme.Primary : Theme.TextControl.WithAlpha( 0.5f ) );
			Paint.DrawIcon( check, sel == 0 ? "check_box_outline_blank" : sel == total ? "check_box" : "indeterminate_check_box", 16, TextFlag.Center );

			var icon = r;
			icon.Left += CheckWidth;
			icon.Width = 22;
			Paint.SetPen( Theme.Yellow.WithAlpha( 0.8f ) );
			Paint.DrawIcon( icon, item.IsOpen ? "folder_open" : "folder", 16, TextFlag.Center );

			var meta = r;
			meta.Right -= 6;
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );
			Paint.SetDefaultFont( 7 );
			Paint.DrawText( meta, win.SubtreeSummary( path ), TextFlag.RightCenter );

			var text = r;
			text.Left += CheckWidth + 26;
			text.Right -= 80;
			Paint.SetPen( Theme.Text );
			Paint.SetDefaultFont();
			var name = path[(path.LastIndexOf( '/' ) + 1)..];
			Paint.DrawText( text, name, TextFlag.LeftCenter );
		}

		public void OnCheckClicked()
		{
			var (sel, total) = win.SubtreeSelection( path );
			win.SetFolderSelected( path, sel < total );
		}
	}

	class AssetNode : TreeNode, ICheckRow, IPreviewRow
	{
		readonly UnrealImportWindow win;
		readonly AssetEntry entry;
		readonly bool fullPath;

		Pixmap pixmap;
		bool thumbResolved;

		/// <summary>Placeholder + material-row icon: a mesh reads as a solid, a material as a swatch.</summary>
		string Icon => entry.IsMesh ? "view_in_ar" : "palette";

		public Pixmap PreviewPixmap => pixmap;
		public string PreviewCaption => entry.Triangles >= 0
			? $"{entry.Display}  ·  {FormatCount( entry.Triangles )} tris"
			: entry.IsMesh ? entry.Display : $"{entry.Display}  ·  material";

		public AssetNode( UnrealImportWindow win, AssetEntry entry, bool fullPath )
		{
			this.win = win;
			this.entry = entry;
			this.fullPath = fullPath;
			Value = entry;
			Height = 40;

			if ( UassetThumbnail.TryGetCached( entry.AbsPath, out pixmap ) )
				thumbResolved = true;
			else
				_ = ResolveThumb();

			// Triangle counts are a mesh-only asset-registry tag.
			if ( entry.IsMesh && entry.Triangles < 0 )
				_ = ResolveStats();
		}

		async Task ResolveThumb()
		{
			pixmap = await UassetThumbnail.LoadAsync( entry.AbsPath );
			thumbResolved = true;
			TreeView?.Update();
		}

		async Task ResolveStats()
		{
			var stats = await UassetMeshStats.LoadAsync( entry.AbsPath );
			if ( stats is not null )
			{
				entry.Triangles = stats.Triangles;
				win.UpdateStatus();
			}
			TreeView?.Update();
		}

		public override void OnPaint( VirtualWidget item )
		{
			ImportStyle.PaintRow( item, TreeView );
			var r = item.Rect;

			var check = r;
			check.Width = CheckWidth;
			Paint.SetPen( entry.Selected ? Theme.Primary : Theme.TextControl.WithAlpha( 0.5f ) );
			Paint.DrawIcon( check, entry.Selected ? "check_box" : "check_box_outline_blank", 16, TextFlag.Center );

			var thumb = r;
			thumb.Left += CheckWidth;
			thumb.Width = ThumbSize;
			thumb.Top += (r.Height - ThumbSize) / 2;
			thumb.Height = ThumbSize;
			Paint.ClearPen();
			Paint.SetBrush( Theme.ControlBackground );
			Paint.DrawRect( thumb, 3 );
			if ( pixmap is not null )
			{
				Paint.Draw( thumb, pixmap, 1, 3 );
			}
			else
			{
				Paint.SetPen( Theme.TextControl.WithAlpha( thumbResolved ? 0.25f : 0.1f ) );
				Paint.DrawIcon( thumb, Icon, 18 );
			}

			var meta = r;
			meta.Right -= 6;
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
			Paint.SetDefaultFont( 7 );
			var label = entry.Triangles >= 0
				? $"{FormatCount( entry.Triangles )} tris · {FormatSize( entry.SizeBytes )}"
				: entry.IsMesh
					? FormatSize( entry.SizeBytes )
					: $"material · {FormatSize( entry.SizeBytes )}";
			Paint.DrawText( meta, label, TextFlag.RightCenter );

			var text = r;
			text.Left += CheckWidth + ThumbSize + 8;
			text.Right -= 120;
			Paint.SetPen( Theme.Text );
			Paint.SetDefaultFont();
			var name = fullPath ? entry.Display : entry.Display[(entry.Display.LastIndexOf( '/' ) + 1)..];
			Paint.DrawText( text, name, TextFlag.LeftCenter );
		}

		public void OnCheckClicked()
		{
			entry.Selected = !entry.Selected;
			win.UpdateStatus();
		}

		public override void OnActivated()
		{
			OnCheckClicked();
			TreeView?.Update();
		}
	}

	class MapNode : TreeNode, IPreviewRow
	{
		readonly UnrealImportWindow win;
		readonly MapEntry entry;
		readonly bool fullPath;

		Pixmap pixmap;
		bool thumbResolved;

		public Pixmap PreviewPixmap => pixmap;
		public string PreviewCaption => $"{entry.Display}  ·  map";

		public MapNode( UnrealImportWindow win, MapEntry entry, bool fullPath )
		{
			this.win = win;
			this.entry = entry;
			this.fullPath = fullPath;
			Value = entry;
			Height = 40;

			if ( UassetThumbnail.TryGetCached( entry.AbsPath, out pixmap ) )
				thumbResolved = true;
			else
				_ = ResolveThumb();
		}

		async Task ResolveThumb()
		{
			pixmap = await UassetThumbnail.LoadAsync( entry.AbsPath );
			thumbResolved = true;
			TreeView?.Update();
		}

		public override void OnPaint( VirtualWidget item )
		{
			ImportStyle.PaintRow( item, TreeView );
			var r = item.Rect;

			var icon = r;
			icon.Width = CheckWidth;
			Paint.SetPen( Theme.Green.WithAlpha( 0.8f ) );
			Paint.DrawIcon( icon, "public", 16, TextFlag.Center );

			var thumb = r;
			thumb.Left += CheckWidth;
			thumb.Width = ThumbSize;
			thumb.Top += (r.Height - ThumbSize) / 2;
			thumb.Height = ThumbSize;
			Paint.ClearPen();
			Paint.SetBrush( Theme.ControlBackground );
			Paint.DrawRect( thumb, 3 );
			if ( pixmap is not null )
			{
				Paint.Draw( thumb, pixmap, 1, 3 );
			}
			else
			{
				Paint.SetPen( Theme.TextControl.WithAlpha( thumbResolved ? 0.25f : 0.1f ) );
				Paint.DrawIcon( thumb, "public", 18 );
			}

			var meta = r;
			meta.Right -= 6;
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );
			Paint.SetDefaultFont( 7 );
			Paint.DrawText( meta, "map · double-click to import", TextFlag.RightCenter );

			var text = r;
			text.Left += CheckWidth + ThumbSize + 8;
			text.Right -= 160;
			Paint.SetPen( Theme.Text );
			Paint.SetDefaultFont();
			var name = fullPath ? entry.Display : entry.Display[(entry.Display.LastIndexOf( '/' ) + 1)..];
			Paint.DrawText( text, name, TextFlag.LeftCenter );
		}

		public override void OnActivated()
		{
			win.DoImportMap( entry );
		}
	}

	string uprojectPath;
	string uprojectFolder;
	string outputFolder;
	string searchFilter = "";
	bool flatView;

	readonly List<AssetEntry> entries = new();
	readonly List<MapEntry> mapEntries = new();
	readonly Dictionary<string, FolderBucket> folders = new( StringComparer.OrdinalIgnoreCase );
	List<TreeNode> rootNodes = new();

	/// <summary>Width of the left-hand label column, so the settings rows line up.</summary>
	const float LabelWidth = 110;

	LineEdit projectLabel;
	LineEdit outputLabel;
	Label statusLabel;
	LineEdit searchEdit;
	ImportTreeView tree;
	Button exportButton;
	ComboBox layoutCombo;
	LineEdit subfolderEdit;
	Label subfolderLabel;
	Checkbox lodCheckbox;
	LineEdit lightScaleEdit;
	ComboBox materialOutputCombo;
	ComboBox perAssetFolderCombo;
	Label perAssetFolderLabel;
	ComboBox maxTextureSizeCombo;

	/// <summary>Combo item order - the layout row adds items in exactly this order.</summary>
	static readonly ImportLayout[] LayoutOrder = { ImportLayout.Grouped, ImportLayout.Flat, ImportLayout.ClassicSource, ImportLayout.PerAsset };

	ImportLayout SelectedLayout => layoutCombo is null ? ImportLayout.Grouped : LayoutOrder[Math.Clamp( layoutCombo.CurrentIndex, 0, LayoutOrder.Length - 1 )];

	/// <summary>Combo item order - the material output row adds items in exactly this order.</summary>
	static readonly MaterialOutput[] MaterialOutputOrder = { MaterialOutput.Material, MaterialOutput.Terrain, MaterialOutput.Decal };

	MaterialOutput SelectedMaterialOutput => materialOutputCombo is null
		? MaterialOutput.Material
		: MaterialOutputOrder[Math.Clamp( materialOutputCombo.CurrentIndex, 0, MaterialOutputOrder.Length - 1 )];

	/// <summary>Per-asset folder-name depth: the combo index IS the depth (0 = asset's own name).</summary>
	int PerAssetFolderDepth => perAssetFolderCombo?.CurrentIndex ?? 0;

	/// <summary>Combo item order - the texture size row adds items in exactly this order. 0 = no cap.</summary>
	static readonly int[] MaxTextureSizeOrder = { 0, 4096, 2048, 1024, 512 };

	int MaxTextureSize => maxTextureSizeCombo is null
		? 0
		: MaxTextureSizeOrder[Math.Clamp( maxTextureSizeCombo.CurrentIndex, 0, MaxTextureSizeOrder.Length - 1 )];

	string Subfolder() => subfolderEdit?.Text ?? "";

	/// <summary>The light-brightness multiplier from the UI, defensively parsed.</summary>
	float LightScale()
	{
		if ( lightScaleEdit is null )
			return 1f;

		return float.TryParse( lightScaleEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v ) && v > 0
			? Math.Clamp( v, 0.01f, 20f )
			: 1f;
	}

	public UnrealImportWindow() : this( null ) { }

	public UnrealImportWindow( Widget parent ) : base( parent )
	{
		WindowFlags = WindowFlags.Dialog | WindowFlags.Customized | WindowFlags.WindowTitle | WindowFlags.CloseButton | WindowFlags.WindowSystemMenuHint;
		DeleteOnClose = true;
		WindowTitle = "Unreal Importer";
		SetWindowIcon( "move_to_inbox" );

		outputFolder = Sandbox.Project.Current is not null
			? Path.Combine( Sandbox.Project.Current.GetAssetsPath(), "unrealimport" )
			: null;

		Layout = Layout.Column();
		Layout.Spacing = 8;
		Layout.Margin = 16;

		Layout.Add( new WarningBox(
			"Select an Unreal project folder, tick the meshes and materials you want, and export.\n" +
			"This runs a headless Unreal pass to extract FBX + textures, then generates vmdl/vmat.\n" +
			"A big pack takes minutes - the status line below tracks every phase.", this ) );

		// Project row
		{
			var row = Layout.Row();
			row.Spacing = 8;
			row.Add( new Label( "Unreal Project", this ) { FixedWidth = LabelWidth } );

			projectLabel = new LineEdit( this )
			{
				ReadOnly = true,
				PlaceholderText = "No Unreal project selected",
				ToolTip = "The .uproject the assets are read from",
			}.StyleInput();
			row.Add( projectLabel, 1 );
			row.Add( new Button( "Browse Project...", "folder_open", this ) { Clicked = PickProject } );
			Layout.Add( row );
		}

		// ---- Asset Selection ----
		{
			var section = new Fieldset( "Asset Selection", this );

			var toolRow = Layout.Row();
			toolRow.Spacing = 8;

			searchEdit = new LineEdit( this ) { PlaceholderText = "⌕  Search meshes and materials", ToolTip = "Filter the list by name or path" };
			searchEdit.StyleInput();
			searchEdit.TextEdited += t =>
			{
				searchFilter = t ?? "";
				RefreshTree();
				UpdateStatus();
			};
			toolRow.Add( searchEdit, 1 );

			toolRow.Add( new Button( "Select All", "done_all", this ) { Clicked = () => SetAll( true ) } );
			toolRow.Add( new Button( "Select None", "remove_done", this ) { Clicked = () => SetAll( false ) } );

			flatView = EditorCookie.Get( "unreal_import_flat_view", false );
			var flatToggle = new Checkbox( "Flat list", this )
			{
				Value = flatView,
				ToolTip = "Show every asset as one flat list instead of the folder tree",
			};
			flatToggle.Toggled = () =>
			{
				flatView = flatToggle.Value;
				EditorCookie.Set( "unreal_import_flat_view", flatView );
				RefreshTree();
			};
			toolRow.Add( flatToggle );

			section.Layout.Add( toolRow );

			tree = new ImportTreeView( this );
			tree.MultiSelect = false;
			// Sunk into the section: darker than the panel so the row stripes read against it.
			tree.SetStyles(
				$"background-color: {Theme.WindowBackground.Hex};" +
				$"border: 1px solid {Theme.Border.WithAlpha( 0.5f ).Hex};" +
				$"border-radius: {Theme.ControlRadius}px;" );
			section.Layout.Add( tree, 1 );

			// The section (and the tree inside it) takes all the leftover height.
			Layout.Add( section, 1 );
		}

		// ---- Export Settings ----
		{
			var section = new Fieldset( "Export Settings", this );

			var grid = Layout.Grid();
			grid.Spacing = 8;
			section.Layout.Add( grid );

			// Row 0: output directory, spanning the full width.
			grid.AddCell( 0, 0, new Label( "Output Directory", this ) { FixedWidth = LabelWidth } );
			outputLabel = new LineEdit( this )
			{
				ReadOnly = true,
				PlaceholderText = "No output folder selected",
				ToolTip = "Where generated assets are written",
			}.StyleInput();
			grid.AddCell( 1, 0, outputLabel, xSpan: 3 );
			grid.AddCell( 4, 0, new Button( "Output...", "drive_file_move", this ) { Clicked = PickOutput } );

			// Row 1: layout | map light brightness.
			grid.AddCell( 0, 1, new Label( "Layout", this ) { FixedWidth = LabelWidth } );

			layoutCombo = new ComboBox( this ) { MinimumWidth = 180 };
			layoutCombo.AddItem( "Grouped", icon: "folder",
				description: "<output>/models, /materials, /textures" );
			layoutCombo.AddItem( "Flat", icon: "folder_open",
				description: "Everything directly in the output folder" );
			layoutCombo.AddItem( "Classic Source", icon: "account_tree",
				description: "Assets/models/<subdir> for fbx+vmdl, Assets/materials/<subdir> for vmat+textures" );
			layoutCombo.AddItem( "Per Asset", icon: "inventory_2",
				description: "<output>/<asset>/ - each asset's model, materials and textures together" );

			var savedLayout = EditorCookie.Get( "unreal_import_layout", 0 );
			layoutCombo.CurrentIndex = Math.Clamp( savedLayout, 0, LayoutOrder.Length - 1 );
			layoutCombo.ItemChanged += () =>
			{
				EditorCookie.Set( "unreal_import_layout", layoutCombo.CurrentIndex );
				UpdateLayoutRow();
			};
			grid.AddCell( 1, 1, layoutCombo.StyleInput() );

			// Scene-light brightness: the conversion is calibrated, but UE maps lean on
			// auto-exposure that s&box doesn't have - taste (and pack) varies, so expose a knob.
			grid.AddCell( 2, 1, new Label( "Map light brightness", this ), alignment: TextFlag.RightCenter );
			lightScaleEdit = new LineEdit( this )
			{
				Text = EditorCookie.Get( "unreal_import_light_scale", 1f ).ToString( System.Globalization.CultureInfo.InvariantCulture ),
				ToolTip = "Multiplier on converted map light intensity. 1 = calibrated default; lower for moodier interiors, higher if too dark. Applies on (re)import.",
			};
			lightScaleEdit.TextEdited += _ => EditorCookie.Set( "unreal_import_light_scale", LightScale() );
			grid.AddCell( 3, 1, lightScaleEdit.StyleInput(), xSpan: 2 );

			// Row 2: subfolder | generate LODs.
			subfolderLabel = new Label( "Subfolder", this ) { FixedWidth = LabelWidth };
			grid.AddCell( 0, 2, subfolderLabel );

			subfolderEdit = new LineEdit( this )
			{
				Text = EditorCookie.Get( "unreal_import_subfolder", "unrealimport" ),
				PlaceholderText = "(none)",
				ToolTip = "Subfolder under Assets/models and Assets/materials. Leave empty to write straight into them.",
			};
			subfolderEdit.TextEdited += t =>
			{
				EditorCookie.Set( "unreal_import_subfolder", t ?? "" );
				if ( outputLabel is not null )
					outputLabel.Text = OutputDisplay();
			};
			grid.AddCell( 1, 2, subfolderEdit.StyleInput() );

			lodCheckbox = new Checkbox( "Generate LODs", this )
			{
				Value = EditorCookie.Get( "unreal_import_lods", true ),
				ToolTip = "5-level auto chain; untick for full detail at every distance",
			};
			grid.AddCell( 2, 2, lodCheckbox, xSpan: 3 );
			lodCheckbox.Toggled = () => EditorCookie.Set( "unreal_import_lods", lodCheckbox.Value );

			// Row 3: what a material picked on its own becomes.
			grid.AddCell( 0, 3, new Label( "Material Output", this ) { FixedWidth = LabelWidth } );

			materialOutputCombo = new ComboBox( this ) { MinimumWidth = 180 };
			materialOutputCombo.AddItem( "Material (.vmat)", icon: "palette",
				description: "Standard complex.shader material" );
			materialOutputCombo.AddItem( "Terrain (.tmat)", icon: "landscape",
				description: "Terrain Material - tiling ground surface with height blending" );
			materialOutputCombo.AddItem( "Decal (.decal)", icon: "approval",
				description: "Decal Definition - projected decal masked by the colour alpha" );

			materialOutputCombo.CurrentIndex = Math.Clamp(
				EditorCookie.Get( "unreal_import_material_output", 0 ), 0, MaterialOutputOrder.Length - 1 );
			materialOutputCombo.ItemChanged += () =>
			{
				EditorCookie.Set( "unreal_import_material_output", materialOutputCombo.CurrentIndex );
				UpdateStatus();
			};
			grid.AddCell( 1, 3, materialOutputCombo.StyleInput() );

			grid.AddCell( 2, 3, new Label( "Meshes always use .vmat", this )
			{
				Color = Theme.TextControl.WithAlpha( 0.5f ),
				ToolTip = "A model's material slots can't reference a terrain or decal resource, so this only applies to materials imported on their own.",
			}, xSpan: 3 );

			// Row 4: Per Asset only - which folder to name each asset's subfolder after.
			// Fab/Megascans MIs are named like "mi_sjfnbeaa"; the readable name is a couple
			// folders up (.../Fine_American_Road_sjfnbeaa/Medium/MI_sjfnbeaa).
			perAssetFolderLabel = new Label( "Folder name", this ) { FixedWidth = LabelWidth };
			grid.AddCell( 0, 4, perAssetFolderLabel );

			perAssetFolderCombo = new ComboBox( this ) { MinimumWidth = 180 };
			perAssetFolderCombo.AddItem( "Asset name", icon: "description",
				description: "Name each folder after the asset itself (e.g. mi_sjfnbeaa)" );
			perAssetFolderCombo.AddItem( "1 folder up", icon: "north",
				description: "Name it after the asset's parent folder" );
			perAssetFolderCombo.AddItem( "2 folders up", icon: "north",
				description: "Grandparent folder - the readable pack name for Fab/Megascans" );
			perAssetFolderCombo.AddItem( "3 folders up", icon: "north",
				description: "Great-grandparent folder" );

			perAssetFolderCombo.CurrentIndex = Math.Clamp( EditorCookie.Get( "unreal_import_perasset_depth", 0 ), 0, 3 );
			perAssetFolderCombo.ItemChanged += () => EditorCookie.Set( "unreal_import_perasset_depth", perAssetFolderCombo.CurrentIndex );
			grid.AddCell( 1, 4, perAssetFolderCombo.StyleInput() );

			grid.AddCell( 2, 4, new Label( "Per Asset layout only", this )
			{
				Color = Theme.TextControl.WithAlpha( 0.5f ),
				ToolTip = "Which folder each asset's subfolder is named after, when using the Per Asset layout.",
			}, xSpan: 3 );

			// Row 5: texture size ceiling. Fab/Megascans ship 4K (sometimes 8K) maps that a
			// prop the size of a crate has no use for - capping them here cuts the import time
			// as well as the disk, since every per-pixel pass runs on the smaller bitmap.
			grid.AddCell( 0, 5, new Label( "Max texture size", this ) { FixedWidth = LabelWidth } );

			maxTextureSizeCombo = new ComboBox( this ) { MinimumWidth = 180 };
			maxTextureSizeCombo.AddItem( "Original", icon: "photo_size_select_actual",
				description: "Keep whatever the pack ships - no resizing" );
			maxTextureSizeCombo.AddItem( "4096", icon: "photo_size_select_large",
				description: "Downscale anything larger than 4K" );
			maxTextureSizeCombo.AddItem( "2048", icon: "photo_size_select_large",
				description: "Downscale anything larger than 2K - a good default for props" );
			maxTextureSizeCombo.AddItem( "1024", icon: "photo_size_select_small",
				description: "Downscale anything larger than 1K" );
			maxTextureSizeCombo.AddItem( "512", icon: "photo_size_select_small",
				description: "Downscale anything larger than 512 - small props and blockout" );

			maxTextureSizeCombo.CurrentIndex = Math.Clamp(
				EditorCookie.Get( "unreal_import_max_texture_size", 0 ), 0, MaxTextureSizeOrder.Length - 1 );
			maxTextureSizeCombo.ItemChanged += () =>
				EditorCookie.Set( "unreal_import_max_texture_size", maxTextureSizeCombo.CurrentIndex );
			grid.AddCell( 1, 5, maxTextureSizeCombo.StyleInput() );

			grid.AddCell( 2, 5, new Label( "Smaller = faster import", this )
			{
				Color = Theme.TextControl.WithAlpha( 0.5f ),
				ToolTip = "Textures bigger than this are resampled down on the longest edge, keeping their aspect ratio. Never upscales.",
			}, xSpan: 3 );

			// Only the field columns absorb extra width; the label columns stay tight.
			grid.SetColumnStretch( 0, 3, 0, 2, 0 );

			Layout.Add( section );
			UpdateLayoutRow();
		}

		statusLabel = new Label( "", this );
		statusLabel.Color = Theme.TextControl.WithAlpha( 0.6f );
		Layout.Add( statusLabel );

		// Bottom bar
		{
			var row = Layout.Row();
			row.Margin = new Sandbox.UI.Margin( 0, 8, 0, 0 );
			row.AddStretchCell();
			exportButton = new Button.Primary( "Export to s&box", "move_to_inbox", this ) { Clicked = () => _ = DoExport() };
			exportButton.Enabled = false;
			row.Add( exportButton );
			Layout.Add( row );
		}

		Width = 640;
		MinimumWidth = 480;
		Height = 680;

		Show();
		Focus();

		var outputPath = EditorCookie.Get( "unreal_import_project_path", "" );
		if ( !string.IsNullOrEmpty( outputPath ) )
		{
			Log.Info( $"UnrealImportWindow: restoring last project path: {outputPath}" );
			uprojectPath = outputPath;
			uprojectFolder = Path.GetDirectoryName( outputPath );
			projectLabel.Text = $"{Path.GetFileName( outputPath )}  ({Path.GetFileName( uprojectFolder )})";
			ScanAssets();
		}
	}

	string OutputDisplay()
	{
		// Classic Source ignores the picked folder entirely - it writes off the Assets root.
		if ( SelectedLayout == ImportLayout.ClassicSource )
		{
			var assets = Sandbox.Project.Current?.GetAssetsPath();
			if ( string.IsNullOrEmpty( assets ) )
				return "Assets/models + Assets/materials";

			var paths = AssetImporter.ResolvePaths( outputFolder, assets, ImportLayout.ClassicSource, Subfolder() );
			return $"{paths.ModelsDir}  +  {paths.MaterialsDir}";
		}

		if ( string.IsNullOrEmpty( outputFolder ) )
			return "";

		// Per Asset fans out into a folder per asset - show that rather than implying one folder.
		return SelectedLayout == ImportLayout.PerAsset
			? Path.Combine( outputFolder, "<asset>" )
			: outputFolder;
	}

	/// <summary>
	/// The subfolder field only means anything in Classic Source; grey it out elsewhere.
	/// (Per Asset names its folders after the assets themselves, so there's nothing to type.)
	/// </summary>
	void UpdateLayoutRow()
	{
		var classic = SelectedLayout == ImportLayout.ClassicSource;
		var perAsset = SelectedLayout == ImportLayout.PerAsset;

		if ( subfolderEdit is not null )
			subfolderEdit.Enabled = classic;
		if ( subfolderLabel is not null )
			subfolderLabel.Enabled = classic;

		// The folder-name depth only matters when each asset gets its own folder.
		if ( perAssetFolderCombo is not null )
			perAssetFolderCombo.Enabled = perAsset;
		if ( perAssetFolderLabel is not null )
			perAssetFolderLabel.Enabled = perAsset;

		if ( outputLabel is not null )
			outputLabel.Text = OutputDisplay();

		UpdateExportEnabled();
	}

	void PickProject()
	{
		var fd = new FileDialog( null ) { Title = "Select Unreal Project Folder" };
		fd.SetFindDirectory();
		fd.SetModeOpen();
		if ( !fd.Execute() )
			return;

		var folder = fd.SelectedFile;
		var uproject = UnrealLocator.FindUprojectInFolder( folder );
		if ( uproject is null )
		{
			EditorUtility.DisplayDialog( "Not an Unreal project", $"No .uproject found in:\n{folder}" );
			return;
		}

		uprojectFolder = folder;
		uprojectPath = uproject;
		projectLabel.Text = $"{Path.GetFileName( uproject )}  ({Path.GetFileName( folder )})";

		EditorCookie.Set( "unreal_import_project_path", uprojectPath );
		Log.Info( $"UnrealImportWindow: storing last project path: {uprojectPath}" );

		ScanAssets();
	}

	void PickOutput()
	{
		var fd = new FileDialog( null ) { Title = "Select Output Folder (inside Assets/)", Directory = outputFolder };
		fd.SetFindDirectory();
		fd.SetModeOpen();
		if ( !string.IsNullOrEmpty( outputFolder ) )
			fd.Directory = outputFolder;
		if ( !fd.Execute() )
			return;

		outputFolder = fd.SelectedFile;
		outputLabel.Text = OutputDisplay();
		UpdateExportEnabled();
	}

	void ScanAssets()
	{
		entries.Clear();
		mapEntries.Clear();

		var content = Path.Combine( uprojectFolder, "Content" );
		if ( Directory.Exists( content ) )
		{
			foreach ( var file in new DirectoryInfo( content ).EnumerateFiles( "*.umap", SearchOption.AllDirectories ) )
			{
				var gamePath = HeadlessExporter.ToGamePath( uprojectFolder, file.FullName );
				if ( gamePath.EndsWith( ".umap", StringComparison.OrdinalIgnoreCase ) )
					gamePath = gamePath[..^".umap".Length];

				mapEntries.Add( new MapEntry
				{
					AbsPath = file.FullName,
					GamePath = gamePath,
					Display = gamePath.StartsWith( "/Game/" ) ? gamePath["/Game/".Length..] : gamePath,
				} );
			}
			mapEntries.Sort( ( a, b ) => string.CompareOrdinal( a.GamePath, b.GamePath ) );

			// FileInfo rather than plain paths so we get the size without a second stat per file.
			foreach ( var file in new DirectoryInfo( content ).EnumerateFiles( "*.uasset", SearchOption.AllDirectories ) )
			{
				var kind = ClassifyUasset( file );
				if ( kind is null )
					continue;

				var gamePath = HeadlessExporter.ToGamePath( uprojectFolder, file.FullName );

				entries.Add( new AssetEntry
				{
					Kind = kind.Value,
					AbsPath = file.FullName,
					GamePath = gamePath,
					// Show the path relative to /Game for readability.
					Display = gamePath.StartsWith( "/Game/" ) ? gamePath["/Game/".Length..] : gamePath,
					SizeBytes = file.Length,
				} );
			}
		}

		if ( entries.Count == 0 )
		{
			Log.Warning( $"No static meshes or materials found in {uprojectFolder}/Content." );
		}

		entries.Sort( ( a, b ) => string.CompareOrdinal( a.GamePath, b.GamePath ) );
		BuildFolderIndex();
		RefreshTree();
		UpdateExportEnabled();
		UpdateStatus();

		_ = WarmStats( entries.ToList() );
	}

	/// <summary>
	/// What a .uasset is, from its name and folder - null for anything we can't import.
	///
	/// Reading the real class out of the package would need version-dependent header parsing;
	/// Unreal/Fab naming is conventional enough that prefixes plus the type folder do the job.
	/// The exporter re-checks the actual type when it loads the asset, so a wrong guess here
	/// costs a warning, not a broken import.
	/// </summary>
	static AssetKind? ClassifyUasset( FileInfo file )
	{
		var dir = (file.DirectoryName ?? "").Replace( '\\', '/' );
		var name = Path.GetFileNameWithoutExtension( file.Name );

		// Name prefixes are stronger evidence than the folder - a material parked in a
		// Meshes/ folder is still a material.
		if ( name.StartsWith( "SM_", StringComparison.OrdinalIgnoreCase ) )
			return AssetKind.Mesh;

		// MI_ = Material Instance, M_/MM_ = Material (master). Textures are T_/TX_, so the
		// single-letter M_ prefix doesn't collide with anything else we'd want to list.
		if ( name.StartsWith( "MI_", StringComparison.OrdinalIgnoreCase )
			|| name.StartsWith( "M_", StringComparison.OrdinalIgnoreCase )
			|| name.StartsWith( "MM_", StringComparison.OrdinalIgnoreCase ) )
			return AssetKind.Material;

		if ( dir.Contains( "/Meshes", StringComparison.OrdinalIgnoreCase ) )
			return AssetKind.Mesh;

		if ( dir.Contains( "/Materials", StringComparison.OrdinalIgnoreCase ) )
			return AssetKind.Material;

		return null;
	}

	/// <summary>
	/// Background pass reading tri counts for everything, so folder rows and the status
	/// total become accurate without expanding every folder. Throttled inside
	/// UassetMeshStats; cached on disk so later opens are instant.
	/// </summary>
	async Task WarmStats( List<AssetEntry> list )
	{
		int done = 0;
		foreach ( var e in list )
		{
			if ( !IsValid || !entries.Contains( e ) )
				return;

			if ( e.Triangles < 0 )
			{
				var stats = await UassetMeshStats.LoadAsync( e.AbsPath );
				if ( stats is not null )
					e.Triangles = stats.Triangles;
			}

			if ( ++done % 64 == 0 )
			{
				UpdateStatus();
				tree?.Update();
			}
		}

		if ( IsValid )
		{
			UpdateStatus();
			tree?.Update();
		}
	}

	// ---- folder index ----

	static string ParentOf( string path ) => path.Contains( '/' ) ? path[..path.LastIndexOf( '/' )] : "";
	static string DirOf( string display ) => display.Contains( '/' ) ? display[..display.LastIndexOf( '/' )] : "";

	FolderBucket Bucket( string path )
	{
		if ( !folders.TryGetValue( path, out var b ) )
			folders[path] = b = new FolderBucket();
		return b;
	}

	void BuildFolderIndex()
	{
		folders.Clear();
		Bucket( "" );

		void RegisterChain( string dir )
		{
			while ( dir.Length > 0 )
			{
				var parent = ParentOf( dir );
				Bucket( parent ).Subfolders.Add( dir );
				Bucket( dir );
				dir = parent;
			}
		}

		foreach ( var e in entries )
		{
			var dir = DirOf( e.Display );
			RegisterChain( dir );
			Bucket( dir ).Assets.Add( e );

			for ( var p = dir; ; p = ParentOf( p ) )
			{
				Bucket( p ).Subtree.Add( e );
				if ( p.Length == 0 )
					break;
			}
		}

		foreach ( var m in mapEntries )
		{
			var dir = DirOf( m.Display );
			RegisterChain( dir );
			Bucket( dir ).Maps.Add( m );
		}

		rootNodes = BuildFolderChildNodes( "" ).ToList();
	}

	bool FolderHasChildren( string path )
		=> folders.TryGetValue( path, out var b ) && (b.Subfolders.Count > 0 || b.Assets.Count > 0 || b.Maps.Count > 0);

	IEnumerable<TreeNode> BuildFolderChildNodes( string path )
	{
		if ( !folders.TryGetValue( path, out var b ) )
			yield break;

		foreach ( var sub in b.Subfolders )
			yield return new FolderNode( this, sub );

		foreach ( var m in b.Maps )
			yield return new MapNode( this, m, fullPath: false );

		foreach ( var e in b.Assets )
			yield return new AssetNode( this, e, fullPath: false );
	}

	(int selected, int total) SubtreeSelection( string path )
	{
		if ( !folders.TryGetValue( path, out var b ) )
			return (0, 0);

		int sel = 0;
		foreach ( var e in b.Subtree )
			if ( e.Selected )
				sel++;

		return (sel, b.Subtree.Count);
	}

	/// <summary>Right-hand folder label: "12 meshes · 3 materials", omitting whichever is zero.</summary>
	string SubtreeSummary( string path )
	{
		if ( !folders.TryGetValue( path, out var b ) )
			return "";

		int meshes = b.Subtree.Count( e => e.IsMesh );
		int mats = b.Subtree.Count - meshes;

		var parts = new List<string>();
		if ( meshes > 0 )
			parts.Add( meshes == 1 ? "1 mesh" : $"{meshes} meshes" );
		if ( mats > 0 )
			parts.Add( mats == 1 ? "1 material" : $"{mats} materials" );

		return string.Join( " · ", parts );
	}

	void SetFolderSelected( string path, bool on )
	{
		if ( !folders.TryGetValue( path, out var b ) )
			return;

		foreach ( var e in b.Subtree )
			e.Selected = on;

		UpdateStatus();
		tree?.Update();
	}

	// ---- filtering / tree ----

	/// <summary>Entries matching the current search box, in list order.</summary>
	IEnumerable<AssetEntry> Filtered()
	{
		if ( string.IsNullOrWhiteSpace( searchFilter ) )
			return entries;

		var term = searchFilter.Trim();
		return entries.Where( e => e.Display.Contains( term, StringComparison.OrdinalIgnoreCase ) );
	}

	/// <summary>Maps matching the current search box.</summary>
	IEnumerable<MapEntry> FilteredMaps()
	{
		if ( string.IsNullOrWhiteSpace( searchFilter ) )
			return mapEntries;

		var term = searchFilter.Trim();
		return mapEntries.Where( e => e.Display.Contains( term, StringComparison.OrdinalIgnoreCase ) );
	}

	/// <summary>Tree of folders normally; a flat list while searching or when toggled flat.</summary>
	void RefreshTree()
	{
		if ( tree is null )
			return;

		bool searching = !string.IsNullOrWhiteSpace( searchFilter );

		if ( !searching && !flatView )
		{
			// Persistent nodes, so folder expansion survives search/flat round-trips.
			tree.SetItems( rootNodes );

			if ( rootNodes.Count == 1 )
				tree.Open( rootNodes[0] );
		}
		else
		{
			// Filtered()/FilteredMaps() return everything when the search box is empty,
			// so this doubles as the plain flat view.
			var flat = new List<TreeNode>();
			flat.AddRange( FilteredMaps().Select( m => (TreeNode)new MapNode( this, m, fullPath: true ) ) );
			flat.AddRange( Filtered().Select( e => (TreeNode)new AssetNode( this, e, fullPath: true ) ) );

			if ( flat.Count == 0 && searching )
				flat.Add( new TreeNode( $"No matches for \"{searchFilter.Trim()}\"" ) );

			tree.SetItems( flat );
		}
	}

	static string FormatSize( long bytes )
	{
		if ( bytes >= 1024L * 1024 * 1024 ) return $"{bytes / (1024f * 1024 * 1024):0.##} GB";
		if ( bytes >= 1024 * 1024 ) return $"{bytes / (1024f * 1024):0.#} MB";
		if ( bytes >= 1024 ) return $"{bytes / 1024f:0} KB";
		return $"{bytes} B";
	}

	static string FormatCount( long n )
	{
		if ( n >= 1_000_000 ) return $"{n / 1_000_000f:0.##}M";
		if ( n >= 1_000 ) return $"{n / 1_000f:0.#}k";
		return $"{n}";
	}

	/// <summary>Ticks or unticks everything currently shown - the search filter narrows this.</summary>
	void SetAll( bool on )
	{
		foreach ( var e in Filtered() )
			e.Selected = on;

		tree?.Update();
		UpdateStatus();
	}

	void UpdateStatus()
	{
		if ( statusLabel is null )
			return;

		if ( entries.Count == 0 )
		{
			statusLabel.Text = "";
			return;
		}

		var selected = entries.Where( e => e.Selected ).ToList();
		var shown = Filtered().Count();

		int meshCount = entries.Count( e => e.IsMesh );
		var found = $"{meshCount} static mesh(es), {entries.Count - meshCount} material(s)";
		var text = shown == entries.Count ? $"{found} found." : $"{shown} of {found} shown.";

		if ( selected.Count > 0 )
		{
			text += $"  {selected.Count} selected ({FormatSize( selected.Sum( e => e.SizeBytes ) )}";

			// Tri counts only exist for meshes - "+" means some are still being read.
			long tris = selected.Sum( e => Math.Max( 0, e.Triangles ) );
			bool partial = selected.Any( e => e.IsMesh && e.Triangles < 0 );
			if ( tris > 0 )
				text += $", {FormatCount( tris )}{(partial ? "+" : "")} tris";

			text += ").";
		}
		else
		{
			text += "  Nothing selected.";
		}

		statusLabel.Text = text;
	}

	void UpdateExportEnabled()
	{
		if ( exportButton is null )
			return;

		// Classic Source writes off the Assets root, so it doesn't need a picked output folder.
		var haveOutput = SelectedLayout == ImportLayout.ClassicSource || !string.IsNullOrEmpty( outputFolder );
		exportButton.Enabled = entries.Count > 0 && haveOutput && !string.IsNullOrEmpty( uprojectPath );
	}

	/// <summary>Push a live export/import event into the progress toast + status line.</summary>
	void ApplyProgress( IProgressSection progress, ExportEvent ev )
	{
		if ( ev.Total is > 0 )
			progress.TotalCount = ev.Total.Value;
		if ( ev.Done is > 0 )
			progress.Current = ev.Done.Value;
		if ( !string.IsNullOrEmpty( ev.Message ) )
		{
			progress.Subtitle = ev.Message;
			statusLabel.Text = ev.Done is > 0 && ev.Total is > 0 ? $"[{ev.Done}/{ev.Total}] {ev.Message}" : ev.Message;
		}
	}

	/// <summary>Locate ue_export.py + the right UnrealEditor-Cmd, dialoging on failure.</summary>
	bool TryResolveTools( out string script, out string editorCmd )
	{
		editorCmd = null;
		script = HeadlessExporter.FindExportScript();
		if ( script is null )
		{
			EditorUtility.DisplayDialog( "Export script missing", "Could not find Tools/ue_export.py in this library." );
			return false;
		}

		var engineVersion = UnrealLocator.ReadEngineAssociation( uprojectPath );
		editorCmd = UnrealLocator.FindEditorCmd( engineVersion );
		if ( editorCmd is null )
		{
			EditorUtility.DisplayDialog( "Unreal not found",
				$"Couldn't locate UnrealEditor-Cmd.exe for engine '{engineVersion}'.\nIs Unreal installed under Epic Games?" );
			return false;
		}

		return true;
	}

	/// <summary>Double-clicking a map row lands here - confirm before kicking a long export.</summary>
	void DoImportMap( MapEntry map )
	{
		EditorUtility.DisplayDialog( "Import map?",
			$"Import {map.Display}?\n\nThis exports every mesh the level uses and builds a prefab of its layout. It can take a while.",
			"Cancel", "Import", () => _ = RunImportMap( map ), "🌍" );
	}

	async Task RunImportMap( MapEntry map )
	{
		if ( string.IsNullOrEmpty( outputFolder ) && SelectedLayout != ImportLayout.ClassicSource )
		{
			EditorUtility.DisplayDialog( "No output folder", "Pick an output folder (inside Assets/) first." );
			return;
		}

		if ( !TryResolveTools( out var script, out var editorCmd ) )
			return;

		await Task.Delay( 100 );

		statusLabel.Text = $"Importing map {map.Display}... this exports every mesh the level uses and can take a while.";

		using var progress = Application.Editor.ProgressSection();
		progress.Title = $"Exporting map {map.Display}";
		var progressToken = progress.GetCancel();

		try
		{
			var export = await HeadlessExporter.Run( editorCmd, uprojectPath, Enumerable.Empty<string>(), script, progressToken, mapGamePath: map.GamePath,
				onProgress: ev => ApplyProgress( progress, ev ) );
			if ( !export.Success )
			{
				EditorUtility.DisplayDialog( "Map export failed", export.Error ?? "Unknown error.", icon: "⚠️" );
				statusLabel.Text = "Map export failed.";
				return;
			}

			progress.Title = $"Importing map {map.Display}";
			var manifest = ImportManifest.Load( export.ManifestPath );
			var summary = await AssetImporter.Import( manifest, export.StagingDir, outputFolder, progressToken, SelectedLayout, Subfolder(),
				generateLods: lodCheckbox is null || lodCheckbox.Value,
				lightScale: LightScale(),
				materialOutput: SelectedMaterialOutput,
				perAssetFolderDepth: PerAssetFolderDepth,
				maxTextureSize: MaxTextureSize,
				onProgress: ( done, total, name ) => ApplyProgress( progress, new ExportEvent( done, total, $"Importing {name}" ) ) );

			var msg = $"Imported {summary.Models} model(s), {summary.Materials} material(s), {summary.Textures} texture(s).\n" +
				$"{summary.Placements} placement(s) written to:\n{summary.PrefabPath}";
			if ( summary.Warnings.Count > 0 )
				msg += "\n\nWarnings:\n - " + string.Join( "\n - ", summary.Warnings.Take( 10 ) );

			EditorUtility.DisplayDialog( "Map import complete", msg, icon: "✅" );
			statusLabel.Text = $"Done: {summary.Placements} placements, {summary.Models} models.";
		}
		catch ( Exception e )
		{
			EditorUtility.DisplayDialog( "Map import error", e.ToString(), icon: "⚠️" );
			statusLabel.Text = "Map import error.";
		}
	}

	async Task DoExport()
	{
		// Deliberately ignores the search filter - ticks persist across filtering, so everything
		// the user has selected gets exported whether or not it's on screen right now.
		var selectedEntries = entries.Where( e => e.Selected ).ToList();
		var selected = selectedEntries.Select( e => e.GamePath ).ToList();
		if ( selected.Count == 0 )
		{
			EditorUtility.DisplayDialog( "Nothing selected", "Tick at least one mesh or material to export." );
			return;
		}

		if ( !TryResolveTools( out var script, out var editorCmd ) )
			return;

		await Task.Delay( 100 );

		// Meshes and materials go over in one selection - the export script routes by asset type.
		int meshCount = selectedEntries.Count( e => e.IsMesh );
		var what = meshCount == selected.Count ? $"{meshCount} mesh(es)"
			: meshCount == 0 ? $"{selected.Count} material(s)"
			: $"{meshCount} mesh(es) + {selected.Count - meshCount} material(s)";
		statusLabel.Text = $"Exporting {what} via headless Unreal... this can take a minute.";

		using var progress = Application.Editor.ProgressSection();

		progress.Title = "Exporting from Unreal";
		progress.TotalCount = selected.Count;
		var progressToken = progress.GetCancel();

		try
		{
			var export = await HeadlessExporter.Run( editorCmd, uprojectPath, selected, script, progressToken,
				onProgress: ev => ApplyProgress( progress, ev ) );
			if ( !export.Success )
			{
				EditorUtility.DisplayDialog( "Export failed", export.Error ?? "Unknown error.", icon: "⚠️" );
				statusLabel.Text = "Export failed.";
				return;
			}

			progress.Title = "Importing into s&box";
			var manifest = ImportManifest.Load( export.ManifestPath );
			var summary = await AssetImporter.Import( manifest, export.StagingDir, outputFolder, progressToken, SelectedLayout, Subfolder(),
				generateLods: lodCheckbox is null || lodCheckbox.Value,
				lightScale: LightScale(),
				materialOutput: SelectedMaterialOutput,
				perAssetFolderDepth: PerAssetFolderDepth,
				maxTextureSize: MaxTextureSize,
				onProgress: ( done, total, name ) => ApplyProgress( progress, new ExportEvent( done, total, $"Importing {name}" ) ) );

			var msg = $"Imported {summary.Models} model(s), {summary.Materials} material(s), {summary.Textures} texture(s).\n\n" +
				$"Output:\n{summary.OutputDir}";
			if ( summary.Warnings.Count > 0 )
				msg += "\n\nWarnings:\n - " + string.Join( "\n - ", summary.Warnings.Take( 10 ) );

			EditorUtility.DisplayDialog( "Import complete", msg, icon: "✅" );
			statusLabel.Text = $"Done: {summary.Models} models, {summary.Materials} materials, {summary.Textures} textures.";
		}
		catch ( Exception e )
		{
			EditorUtility.DisplayDialog( "Import error", e.ToString(), icon: "⚠️" );
			statusLabel.Text = "Import error.";
		}
	}
}