Editor/PbrMapGeneratorWindow.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using Button = Editor.Button;
using Checkbox = Editor.Checkbox;
using ComboBox = Editor.ComboBox;
using Label = Editor.Label;
using LineEdit = Editor.LineEdit;
using TextEdit = Editor.TextEdit;
using Widget = Editor.Widget;

[Dock( "Editor", "PBR Generator", "texture" )]
public sealed class PbrMapGeneratorWindow : Widget
{
	public static PbrMapGeneratorWindow Current { get; private set; }

	private PbrGeneratorSettings settings = new();
	private readonly Dictionary<PbrMapType, Button> mapButtons = new();

	private PbrPreviewWidget previewWidget;
	private PbrMaterialPreviewWidget materialPreviewWidget;
	private Splitter mainSplitter;
	private Splitter contentSplitter;
	private Splitter previewSplitter;
	private ScrollArea inspectorScroll;
	private TextEdit consoleOutput;
	private Label currentFileLabel;
	private Label imageInfoLabel;
	private Label noteLabel;
	private LineEdit outputFolderEdit;

	private Bitmap sourceBitmap;
	private PbrGeneratorResult generatedResult;
	private string sourceImagePath;
	private string sourceDisplayName;
	private string outputFolder;
	private string lastExportPath;
	private bool outputFolderFollowsSource = true;
	private bool generationQueued;
	private bool generationRunning;
	private bool queuedGenerationWasManual;
	private bool windowDestroyed;
	private bool experimentalMaterialPreviewEnabled;
	private bool autoRefreshMaterialPreview;
	private bool parallaxMaterialPreviewEnabled;
	private DateTime queuedGenerationTime;
	private int latestGenerationRequestId;
	private int runningGenerationRequestId;
	private PbrMapType selectedMap = PbrMapType.Albedo;
	private PbrPreviewLayout previewLayout = PbrPreviewLayout.Atlas;
	private PbrPreviewShape previewShape = PbrPreviewShape.Sphere;

	private const int InspectorLabelWidth = 132;
	private const string MainSplitterCookie = "SeamLessPbrGenerator.MainSplitter";
	private const string ContentSplitterCookie = "SeamLessPbrGenerator.ContentSplitter";
	private const string PreviewSplitterCookie = "SeamLessPbrGenerator.PreviewSplitter";
	private static readonly PbrPreviewLayout[] PreviewLayoutComboOrder =
	{
		PbrPreviewLayout.Atlas,
		PbrPreviewLayout.Single,
		PbrPreviewLayout.Tiled2X2,
		PbrPreviewLayout.Tiled3X3
	};

	public PbrMapGeneratorWindow( Widget parent ) : base( parent )
	{
		Current = this;

		Name = "PBR Generator";
		WindowTitle = "PBR Generator";
		MinimumSize = new Vector2( 1040, 680 );
		AcceptDrops = true;

		ApplySavedPreferences( PbrGeneratorPreferences.Load() );
		SetWindowIcon( "texture" );
		BuildLayout();

		LogMessage( "Ready. Open an albedo texture, drag one into the preview, or use the current suite texture." );
		ShowStatus( "Ready", 4f );
	}

	public override void OnDestroyed()
	{
		windowDestroyed = true;
		latestGenerationRequestId++;
		generationQueued = false;
		queuedGenerationWasManual = false;
		previewWidget?.SetLoading( false );

		SavePbrPreferences();
		SaveSplitterState();
		DisposeBitmaps();

		if ( Current == this )
			Current = null;

		base.OnDestroyed();
	}

	public override void OnDragHover( Widget.DragEvent ev )
	{
		var files = SeamlessSuiteImageUtility.GetFilesFromDragData( ev.Data );
		ev.Action = SeamlessSuiteImageUtility.FindFirstSupportedFile( files ) != null ? DropAction.Copy : DropAction.Ignore;
	}

	public override void OnDragDrop( Widget.DragEvent ev )
	{
		var files = SeamlessSuiteImageUtility.GetFilesFromDragData( ev.Data );

		if ( files.Count == 0 )
		{
			ev.Action = DropAction.Ignore;
			return;
		}

		ev.Action = DropAction.Copy;
		LoadDroppedFiles( files.ToArray() );
	}

	[EditorEvent.Frame]
	public void Frame()
	{
		if ( generationQueued || generationRunning )
			previewWidget?.Update();

		if ( generationRunning )
			return;

		if ( !generationQueued || DateTime.Now < queuedGenerationTime )
			return;

		generationQueued = false;
		var manual = queuedGenerationWasManual;
		queuedGenerationWasManual = false;
		StartGenerateMaps( latestGenerationRequestId, manual );
	}

	private void BuildLayout()
	{
		Layout = Layout.Column();
		Layout.Margin = 8;
		Layout.Spacing = 8;

		Layout.Add( BuildToolbar() );

		mainSplitter = new Splitter( this );
		mainSplitter.IsVertical = true;
		mainSplitter.OpaqueResize = true;
		mainSplitter.HandleWidth = 5;
		Layout.Add( mainSplitter, 1 );

		contentSplitter = new Splitter( mainSplitter );
		contentSplitter.IsHorizontal = true;
		contentSplitter.OpaqueResize = true;
		contentSplitter.HandleWidth = 5;

		previewSplitter = new Splitter( contentSplitter );
		previewSplitter.IsVertical = true;
		previewSplitter.OpaqueResize = true;
		previewSplitter.HandleWidth = 5;

		previewWidget = new PbrPreviewWidget( previewSplitter );
		previewWidget.FilesDropped = LoadDroppedFiles;

		materialPreviewWidget = new PbrMaterialPreviewWidget( previewSplitter );
		materialPreviewWidget.WarningLogged = LogMessage;
		materialPreviewWidget.SetPreviewEnabled( experimentalMaterialPreviewEnabled );
		materialPreviewWidget.SetParallaxEnabled( parallaxMaterialPreviewEnabled );
		materialPreviewWidget.SetPreviewShape( previewShape );

		previewSplitter.AddWidget( previewWidget );
		previewSplitter.AddWidget( materialPreviewWidget );
		previewSplitter.SetStretch( 0, 3 );
		previewSplitter.SetStretch( 1, 2 );
		previewSplitter.SetCollapsible( 0, false );
		previewSplitter.SetCollapsible( 1, true );
		ApplyMaterialPreviewPanelState();

		inspectorScroll = new ScrollArea( contentSplitter );
		inspectorScroll.MinimumWidth = 360;
		inspectorScroll.Canvas = BuildInspector( inspectorScroll );
		inspectorScroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
		inspectorScroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;

		contentSplitter.AddWidget( previewSplitter );
		contentSplitter.AddWidget( inspectorScroll );
		contentSplitter.SetStretch( 0, 5 );
		contentSplitter.SetStretch( 1, 2 );
		contentSplitter.SetCollapsible( 0, false );
		contentSplitter.SetCollapsible( 1, false );

		var console = BuildConsole( mainSplitter );

		mainSplitter.AddWidget( contentSplitter );
		mainSplitter.AddWidget( console );
		mainSplitter.SetStretch( 0, 5 );
		mainSplitter.SetStretch( 1, 1 );
		mainSplitter.SetCollapsible( 0, false );
		mainSplitter.SetCollapsible( 1, false );

		RestoreSplitterState();
		ApplyMaterialPreviewPanelState();
	}

	private ToolBar BuildToolbar()
	{
		var toolbar = new ToolBar( this, "PbrMapGeneratorToolbar" );
		toolbar.Movable = false;
		toolbar.Floatable = false;
		toolbar.SetIconSize( new Vector2( 18, 18 ) );

		toolbar.AddOption( "Load Texture", "folder_open", OpenTextureDialog );
		toolbar.AddOption( "Link Seamless Output", "link", UseCurrentSuiteTexture );
		toolbar.AddOption( "Generate Maps", "auto_fix_high", () => GenerateMaps( true ) );
		toolbar.AddOption( "Reset Settings", "refresh", ResetSettings );
		toolbar.AddSeparator();
		toolbar.AddOption( "Export All", "save", () => ExportAllMaps( false ) );
		toolbar.AddOption( "Reveal Output", "folder", RevealOutput );
		toolbar.AddSeparator();
		toolbar.AddOption( "Clear", "delete", ClearAll );

		return toolbar;
	}

	private Widget BuildInspector( Widget parent )
	{
		mapButtons.Clear();

		var inspector = new Widget( parent );
		inspector.MinimumWidth = 340;
		inspector.Layout = Layout.Column();
		inspector.Layout.Margin = 10;
		inspector.Layout.Spacing = 3;
		inspector.SetStyles( "background-color: #303030; border-left: 1px solid #202020;" );

		AddSectionLabel( inspector, "Source" );
		currentFileLabel = AddReadOnlyValueRow( inspector, "Texture", GetSourceFileText(), "The color/albedo texture used to estimate starter PBR maps." );
		imageInfoLabel = AddReadOnlyValueRow( inspector, "Info", GetSourceInfoText(), "Shows the loaded texture size and source type." );
		noteLabel = AddReadOnlyValueRow( inspector, "Note", GetSourceNoteText(), "PBR maps generated from color are starter estimates." );

		AddSectionLabel( inspector, "Preview" );
		AddMapButtons( inspector );
		AddPreviewLayoutControl( inspector );
		if ( experimentalMaterialPreviewEnabled )
			AddPreviewShapeControl( inspector );
		AddPreviewButtonRow( inspector );
		AddMaterialPreviewFeatureControls( inspector );

		AddHeightControls( inspector );
		AddSectionLabel( inspector, "Normal" );
		AddSliderControl( inspector, "Strength", 0f, 12f, 0.1f, settings.NormalStrength, value =>
		{
			settings.NormalStrength = value;
			QueueGenerate();
		}, "Controls how strongly height slopes affect the normal map." );
		AddSliderControl( inspector, "Smoothing", 0f, 8f, 0.1f, settings.NormalSmoothing, value =>
		{
			settings.NormalSmoothing = value;
			QueueGenerate();
		}, "Smooths the height source before normal calculation." );
		AddCheckboxRow( inspector, "Flip Green", settings.FlipNormalY, state =>
		{
			settings.FlipNormalY = state == CheckState.On;
			QueueGenerate();
		}, "Flips the normal map Y/green channel for workflows that expect the opposite direction." );

		AddSectionLabel( inspector, "Roughness" );
		AddSliderControl( inspector, "Base", 0f, 1f, 0.01f, settings.RoughnessBase, value =>
		{
			settings.RoughnessBase = value;
			QueueGenerate();
		}, "Sets the average roughness value before detail variation." );
		AddSliderControl( inspector, "Contrast", 0f, 1f, 0.01f, settings.RoughnessContrast, value =>
		{
			settings.RoughnessContrast = value;
			QueueGenerate();
		}, "Controls how much fine image detail changes roughness." );
		AddSliderControl( inspector, "Black Point", 0f, 1f, 0.01f, settings.RoughnessBlackPoint, value =>
		{
			settings.RoughnessBlackPoint = value;
			QueueGenerate();
		}, "Pushes darker roughness values darker so glossy areas can read more clearly." );
		AddSliderControl( inspector, "White Point", 0f, 1f, 0.01f, settings.RoughnessWhitePoint, value =>
		{
			settings.RoughnessWhitePoint = value;
			QueueGenerate();
		}, "Pushes brighter roughness values brighter so matte areas can read more clearly." );
		AddSliderControl( inspector, "Cavity Roughness", 0f, 1f, 0.01f, settings.RoughnessCavity, value =>
		{
			settings.RoughnessCavity = value;
			QueueGenerate();
		}, "Pushes darker height cavities toward white/rougher roughness." );
		AddSliderControl( inspector, "Variation", 0f, 1f, 0.01f, settings.RoughnessVariation, value =>
		{
			settings.RoughnessVariation = value;
			QueueGenerate();
		}, "Adds subtle grunge/noise breakup so roughness does not feel too flat or plastic." );
		AddCheckboxRow( inspector, "Invert", settings.InvertRoughness, state =>
		{
			settings.InvertRoughness = state == CheckState.On;
			QueueGenerate();
		}, "Inverts the roughness result." );

		AddSectionLabel( inspector, "Ambient Occlusion" );
		AddSliderControl( inspector, "Strength", 0f, 2f, 0.05f, settings.AoStrength, value =>
		{
			settings.AoStrength = value;
			QueueGenerate();
		}, "Controls how much nearby height difference darkens cavities." );
		AddSliderControl( inspector, "Radius", 1f, 32f, 1f, settings.AoRadius, value =>
		{
			settings.AoRadius = value;
			QueueGenerate();
		}, "Controls how far the fake cavity AO samples from each pixel." );
		AddSliderControl( inspector, "Minimum", 0f, 1f, 0.01f, settings.AoMinimum, value =>
		{
			settings.AoMinimum = value;
			QueueGenerate();
		}, "Prevents AO from becoming too dark." );

		AddSectionLabel( inspector, "Metallic" );
		AddSliderControl( inspector, "Value", 0f, 1f, 0.01f, settings.MetallicValue, value =>
		{
			settings.MetallicValue = value;
			QueueGenerate();
		}, "Sets the flat metallic map value. Default is 0 for non-metal starter materials." );

		AddSectionLabel( inspector, "Export" );
		AddOutputFolderControl( inspector, "Folder where generated PBR maps will be written." );
		AddCheckboxRow( inspector, "Overwrite Existing", settings.OverwriteExisting, state =>
		{
			settings.OverwriteExisting = state == CheckState.On;
		}, "Allows export to replace existing files with the same names." );
		AddExportButtonRows( inspector );

		inspector.Layout.AddStretchCell();

		return inspector;
	}

	private void AddHeightControls( Widget inspector )
	{
		AddSectionLabel( inspector, "Height" );
		AddCheckboxRow( inspector, "Advanced Processing", settings.AdvancedHeightEnabled, state =>
		{
			settings.AdvancedHeightEnabled = state == CheckState.On;
			RebuildInspector();
			QueueGenerate();
		}, "Enables more advanced height map generation algorithm with fine detail control. Allows more control over the height map details." );

		if ( settings.AdvancedHeightEnabled )
		{
			AddSliderControl( inspector, "Fine Detail", 0f, 4f, 0.05f, settings.AdvancedHeightFineStrength, value =>
			{
				settings.AdvancedHeightFineStrength = value;
				QueueGenerate();
			}, "Affects tiny scratches, pores, grit, and sharp little edges." );
			AddSliderControl( inspector, "Medium Detail", 0f, 4f, 0.05f, settings.AdvancedHeightMediumStrength, value =>
			{
				settings.AdvancedHeightMediumStrength = value;
				QueueGenerate();
			}, "Affects cracks, chips, seams, and other mid-size marks." );
			AddSliderControl( inspector, "Large Shape", 0f, 4f, 0.05f, settings.AdvancedHeightLargeStrength, value =>
			{
				settings.AdvancedHeightLargeStrength = value;
				QueueGenerate();
			}, "Affects bigger stones, tiles, boards, folds, or large surface shapes." );
			AddSliderControl( inspector, "Broad Shape", 0f, 4f, 0.05f, settings.AdvancedHeightBroadStrength, value =>
			{
				settings.AdvancedHeightBroadStrength = value;
				QueueGenerate();
			}, "Affects the widest raised and recessed areas across the texture." );
			AddSliderControl( inspector, "Scale Radius", 1f, 8f, 0.1f, settings.AdvancedHeightScaleRadius, value =>
			{
				settings.AdvancedHeightScaleRadius = value;
				QueueGenerate();
			}, "Changes what size of texture feature counts as fine, medium, large, or broad." );
			AddSliderControl( inspector, "Final Contrast", 0f, 3f, 0.05f, settings.AdvancedHeightFinalContrast, value =>
			{
				settings.AdvancedHeightFinalContrast = value;
				QueueGenerate();
			}, "Makes raised and recessed parts stand farther apart or closer together." );
			AddSliderControl( inspector, "Bias", -1f, 1f, 0.01f, settings.AdvancedHeightBias, value =>
			{
				settings.AdvancedHeightBias = value;
				QueueGenerate();
			}, "Moves the whole height map up or down, making the surface overall brighter or darker." );
			AddSliderControl( inspector, "Gain", 0f, 4f, 0.05f, settings.AdvancedHeightGain, value =>
			{
				settings.AdvancedHeightGain = value;
				QueueGenerate();
			}, "Strengthens or weakens the overall raised and recessed effect." );
			AddSliderControl( inspector, "Smoothing", 0f, 16f, 0.1f, settings.AdvancedHeightSmoothing, value =>
			{
				settings.AdvancedHeightSmoothing = value;
				QueueGenerate();
			}, "Softens/blurs the height map." );
			AddCheckboxRow( inspector, "Invert Height", settings.InvertHeight, state =>
			{
				settings.InvertHeight = state == CheckState.On;
				QueueGenerate();
			}, "Inverts the height map, dark pixels as higher and bright pixels as lower." );
			return;
		}

		AddSliderControl( inspector, "Strength", 0f, 4f, 0.05f, settings.HeightStrength, value =>
		{
			settings.HeightStrength = value;
			QueueGenerate();
		}, "Controls how strongly luminance is pushed into height variation." );
		AddSliderControl( inspector, "Contrast", 0f, 3f, 0.05f, settings.HeightContrast, value =>
		{
			settings.HeightContrast = value;
			QueueGenerate();
		}, "Raises or lowers height separation before normal and AO generation." );
		AddSliderControl( inspector, "Black Point", 0f, 1f, 0.01f, settings.HeightBlackPoint, value =>
		{
			settings.HeightBlackPoint = value;
			QueueGenerate();
		}, "Remaps darker pixels to the bottom of the height range." );
		AddSliderControl( inspector, "White Point", 0f, 1f, 0.01f, settings.HeightWhitePoint, value =>
		{
			settings.HeightWhitePoint = value;
			QueueGenerate();
		}, "Remaps brighter pixels to the top of the height range." );
		AddSliderControl( inspector, "Midpoint", 0f, 1f, 0.01f, settings.HeightMidpoint, value =>
		{
			settings.HeightMidpoint = value;
			QueueGenerate();
		}, "Shifts which height value behaves like the neutral middle." );
		AddSliderControl( inspector, "Cavity Emphasis", 0f, 1f, 0.01f, settings.HeightCavityEmphasis, value =>
		{
			settings.HeightCavityEmphasis = value;
			QueueGenerate();
		}, "Deepens recessed dark height areas before normal and AO generation." );
		AddSliderControl( inspector, "Blur", 0f, 16f, 0.1f, settings.HeightBlur, value =>
		{
			settings.HeightBlur = value;
			QueueGenerate();
		}, "Softens noisy height detail before dependent maps are generated." );
		AddCheckboxRow( inspector, "Invert Height", settings.InvertHeight, state =>
		{
			settings.InvertHeight = state == CheckState.On;
			QueueGenerate();
		}, "Treats dark pixels as higher and bright pixels as lower." );
	}

	private Widget BuildConsole( Widget parent )
	{
		var pane = new Widget( parent );
		pane.Name = "Console";
		pane.WindowTitle = "Console";
		pane.SetWindowIcon( "terminal" );
		pane.MinimumSize = new Vector2( 520, 140 );
		pane.Layout = Layout.Column();
		pane.Layout.Margin = 8;
		pane.Layout.Spacing = 6;
		pane.SetStyles( "background-color: #101215; border: 1px solid #2a3038; border-radius: 6px;" );

		var title = pane.Layout.Add( new Label( "Console", pane ) );
		title.SetStyles( "font-weight: bold; color: #d9dee7;" );

		consoleOutput = pane.Layout.Add( new TextEdit( pane ), 1 );
		consoleOutput.ReadOnly = true;
		consoleOutput.TextSelectable = true;
		consoleOutput.BackgroundVisible = false;
		consoleOutput.MaximumBlockCount = 200;
		consoleOutput.SetStyles( "font-family: monospace; font-size: 11px; color: #b8c0cc;" );

		return pane;
	}

	private void AddSectionLabel( Widget inspector, string text )
	{
		var row = new Widget( inspector );
		row.MinimumHeight = 24;
		row.Layout = Layout.Row();
		row.Layout.Spacing = 6;
		row.SetStyles( "background-color: #2b2b2b; border-top: 1px solid #202020; border-bottom: 1px solid #1f1f1f; margin-top: 6px;" );

		var marker = row.Layout.Add( new Label( "-", row ) );
		marker.FixedWidth = 20;
		marker.SetStyles( "color: #8b8b8b; font-weight: bold; font-size: 12px;" );

		var label = row.Layout.Add( new Label( text, row ), 1 );
		label.SetStyles( "font-size: 11px; font-weight: bold; color: #ffffff; padding-left: 2px;" );

		inspector.Layout.Add( row );
	}

	private void AddMapButtons( Widget inspector )
	{
		AddMapButtonRow( inspector, PbrMapType.Albedo, PbrMapType.Height, PbrMapType.Normal, PbrMapType.Roughness );
		AddMapButtonRow( inspector, PbrMapType.AmbientOcclusion, PbrMapType.Metallic, PbrMapType.Orm );
	}

	private void AddMapButtonRow( Widget inspector, params PbrMapType[] mapTypes )
	{
		var row = new Widget( inspector );
		row.Layout = Layout.Row();
		row.Layout.Spacing = 5;
		row.MinimumHeight = 28;

		foreach ( var mapType in mapTypes )
		{
			var button = row.Layout.Add( new Button( PbrGenerator.GetMapLabel( mapType ), "", row ), 1 );
			button.Clicked = () =>
			{
				selectedMap = mapType;
				previewLayout = PbrPreviewLayout.Single;
				UpdateMapButtons();
				UpdatePreviewSettings();
				SavePbrPreferences();
				RebuildInspector();
			};

			mapButtons[mapType] = button;
			StyleMapButton( button, mapType == selectedMap );
		}

		inspector.Layout.Add( row );
	}

	private void AddPreviewLayoutControl( Widget inspector )
	{
		var combo = AddComboRow( inspector, "View", "Chooses single-map, tiled repeat, or atlas preview." );
		foreach ( var layout in PreviewLayoutComboOrder )
		{
			combo.AddItem( GetPreviewLayoutLabel( layout ) );
		}

		combo.CurrentIndex = GetPreviewLayoutComboIndex( previewLayout );
		combo.ItemChanged += () =>
		{
			var comboIndex = Math.Clamp( combo.CurrentIndex, 0, PreviewLayoutComboOrder.Length - 1 );
			previewLayout = PreviewLayoutComboOrder[comboIndex];
			UpdatePreviewSettings();
			SavePbrPreferences();
		};
	}

	private static string GetPreviewLayoutLabel( PbrPreviewLayout layout )
	{
		return layout switch
		{
			PbrPreviewLayout.Atlas => "Atlas",
			PbrPreviewLayout.Single => "Single",
			PbrPreviewLayout.Tiled2X2 => "2 x 2 Tiled",
			PbrPreviewLayout.Tiled3X3 => "3 x 3 Tiled",
			_ => layout.ToString()
		};
	}

	private static int GetPreviewLayoutComboIndex( PbrPreviewLayout layout )
	{
		for ( var i = 0; i < PreviewLayoutComboOrder.Length; i++ )
		{
			if ( PreviewLayoutComboOrder[i] == layout )
			{
				return i;
			}
		}

		return 0;
	}

	private void AddPreviewShapeControl( Widget inspector )
	{
		var combo = AddComboRow( inspector, "Shape", "Chooses the built-in model used by the live material preview." );
		combo.AddItem( "Sphere" );
		combo.AddItem( "Cube" );
		combo.AddItem( "Plane" );
		combo.CurrentIndex = (int)previewShape;
		combo.ItemChanged += () =>
		{
			previewShape = (PbrPreviewShape)Math.Clamp( combo.CurrentIndex, 0, 2 );
			if ( experimentalMaterialPreviewEnabled )
				materialPreviewWidget?.SetPreviewShape( previewShape );

			SavePbrPreferences();
		};
	}

	private void AddMaterialPreviewFeatureControls( Widget inspector )
	{
		AddExperimentalCheckboxRow( inspector, "3D Preview", experimentalMaterialPreviewEnabled, state =>
		{
			var enabled = state == CheckState.On;

			if ( experimentalMaterialPreviewEnabled == enabled )
				return;

			experimentalMaterialPreviewEnabled = enabled;
			materialPreviewWidget?.SetPreviewEnabled( experimentalMaterialPreviewEnabled );
			ApplyMaterialPreviewPanelState();

			if ( experimentalMaterialPreviewEnabled )
			{
				LogMessage( "Experimental 3D preview enabled. Preview material generation can be slow, unstable and error-prone." );
				UpdateMaterialPreview( true );
			}
			else
			{
				LogMessage( "Experimental 3D preview disabled. Preview scene and temporary material cache were cleared." );
			}

			SavePbrPreferences();
			RebuildInspector();
		}, "Experimental: enables live 3D material preview. This can write temporary preview assets and may be slow, unstable and error-prone. USE AT YOUR OWN RISK" );

		if ( !experimentalMaterialPreviewEnabled )
			return;

		AddExperimentalCheckboxRow( inspector, "Auto Refresh 3D", autoRefreshMaterialPreview, state =>
		{
			autoRefreshMaterialPreview = state == CheckState.On;

			if ( autoRefreshMaterialPreview )
				UpdateMaterialPreview( true );

			SavePbrPreferences();
		}, "Experimental: automatically rebuilds the 3D preview when generated maps change." );

		AddExperimentalCheckboxRow( inspector, "Parallax Preview", parallaxMaterialPreviewEnabled, state =>
		{
			parallaxMaterialPreviewEnabled = state == CheckState.On;
			materialPreviewWidget?.SetParallaxEnabled( parallaxMaterialPreviewEnabled );
			UpdateMaterialPreview( true );
			SavePbrPreferences();
		}, "Experimental: enables complex.shader parallax occlusion in the 3D preview using the generated height map." );
	}

	private void AddPreviewButtonRow( Widget inspector )
	{
		var row = new Widget( inspector );
		row.Layout = Layout.Row();
		row.Layout.Spacing = 10;
		row.MinimumHeight = 30;
		row.SetStyles( "padding-top: 4px; padding-bottom: 2px;" );

		var spacer = row.Layout.Add( new Label( "", row ) );
		spacer.FixedWidth = InspectorLabelWidth;

		var fitButton = row.Layout.Add( new Button( "Fit", "fit_screen", row ) );
		fitButton.MinimumWidth = 86;
		fitButton.Clicked = () => previewWidget.FitToView();
		StyleInspectorButton( fitButton );

		var oneToOneButton = row.Layout.Add( new Button( "1:1", "center_focus_strong", row ) );
		oneToOneButton.MinimumWidth = 86;
		oneToOneButton.Clicked = () => previewWidget.SetOneToOne();
		StyleInspectorButton( oneToOneButton );

		row.Layout.AddStretchCell();
		inspector.Layout.Add( row );

		if ( !experimentalMaterialPreviewEnabled )
			return;

		var refreshRow = new Widget( inspector );
		refreshRow.Layout = Layout.Row();
		refreshRow.Layout.Spacing = 10;

		var refreshSpacer = refreshRow.Layout.Add( new Label( "", refreshRow ) );
		refreshSpacer.FixedWidth = InspectorLabelWidth;

		var refreshButton = refreshRow.Layout.Add( new Button( "Refresh 3D Preview", "refresh", refreshRow ), 1 );
		refreshButton.Clicked = () => UpdateMaterialPreview( true );
		StyleInspectorButton( refreshButton );
		SetInspectorToolTip( "Manually rebuilds the experimental 3D preview material from the current generated maps.", refreshButton );

		inspector.Layout.Add( refreshRow );

		var resetRow = new Widget( inspector );
		resetRow.Layout = Layout.Row();
		resetRow.Layout.Spacing = 10;
		resetRow.MinimumHeight = 30;
		resetRow.SetStyles( "padding-top: 0px; padding-bottom: 2px;" );

		var resetSpacer = resetRow.Layout.Add( new Label( "", resetRow ) );
		resetSpacer.FixedWidth = InspectorLabelWidth;

		var resetButton = resetRow.Layout.Add( new Button( "Reset View", "restart_alt", resetRow ), 1 );
		resetButton.MinimumWidth = 178;
		resetButton.Clicked = () => materialPreviewWidget?.ResetView();
		StyleInspectorButton( resetButton );

		resetRow.Layout.AddStretchCell();
		inspector.Layout.Add( resetRow );
	}

	private ComboBox AddComboRow( Widget inspector, string labelText, string description = null )
	{
		var row = new Widget( inspector );
		row.Layout = Layout.Row();
		row.Layout.Spacing = 6;
		row.MinimumHeight = 24;

		var label = row.Layout.Add( new Label( labelText, row ) );
		StylePropertyLabel( label );

		var combo = row.Layout.Add( new ComboBox( row ), 1 );
		StyleInputWidget( combo );
		SetInspectorToolTip( description, label );
		inspector.Layout.Add( row );

		return combo;
	}

	private Widget AddSliderControl( Widget inspector, string labelText, float min, float max, float step, float initialValue, Action<float> changed, string description = null )
	{
		var row = new Widget( inspector );
		row.Layout = Layout.Row();
		row.Layout.Spacing = 6;
		row.MinimumHeight = 24;

		var label = row.Layout.Add( new Label( labelText, row ) );
		StylePropertyLabel( label );

		var valueBox = row.Layout.Add( new Widget( row ), 1 );
		valueBox.Layout = Layout.Row();
		valueBox.Layout.Spacing = 5;
		valueBox.SetStyles( "background-color: #171717; border: 1px solid #252525; border-radius: 3px;" );

		var typeBadge = valueBox.Layout.Add( new Label( "f", valueBox ) );
		typeBadge.FixedWidth = 20;
		typeBadge.SetStyles( "background-color: #26361d; color: #91c65c; font-weight: bold; font-size: 11px; padding-left: 6px;" );

		var slider = valueBox.Layout.Add( new FloatSlider( valueBox ), 1 );
		slider.Minimum = min;
		slider.Maximum = max;
		slider.Step = step;
		slider.Value = initialValue;
		slider.HighlightColor = new Color( 0.55f, 0.78f, 0.28f, 1f );
		slider.MinimumHeight = 22;

		var valueLabel = valueBox.Layout.Add( new Label( FormatSliderValue( initialValue, step ), valueBox ) );
		valueLabel.FixedWidth = 48;
		valueLabel.SetStyles( "color: #f0f0f0; font-size: 11px; padding-left: 4px;" );

		slider.OnValueEdited = () =>
		{
			var snapped = SnapSliderValue( slider.Value, step, min, max );
			slider.Value = snapped;
			valueLabel.Text = FormatSliderValue( snapped, step );
			changed?.Invoke( snapped );
		};

		SetInspectorToolTip( description, label );
		inspector.Layout.Add( row );
		return row;
	}

	private void AddCheckboxRow( Widget inspector, string labelText, bool initialValue, Action<CheckState> changed, string description = null )
	{
		var row = new Widget( inspector );
		row.Layout = Layout.Row();
		row.Layout.Spacing = 6;
		row.MinimumHeight = 24;

		var label = row.Layout.Add( new Label( labelText, row ) );
		StylePropertyLabel( label );

		var checkbox = row.Layout.Add( new Checkbox( row ) );
		checkbox.Value = initialValue;
		checkbox.MinimumWidth = 24;
		checkbox.StateChanged += changed;

		SetInspectorToolTip( description, label );
		row.Layout.AddStretchCell();
		inspector.Layout.Add( row );
	}

	private void AddExperimentalCheckboxRow( Widget inspector, string labelText, bool initialValue, Action<CheckState> changed, string description = null )
	{
		var row = new Widget( inspector );
		row.Layout = Layout.Row();
		row.Layout.Spacing = 4;
		row.MinimumHeight = 26;

		var label = row.Layout.Add( new Label( labelText, row ) );
		StylePropertyLabel( label );

		var checkbox = row.Layout.Add( new Checkbox( row ) );
		checkbox.Value = initialValue;
		checkbox.FixedWidth = 18;
		checkbox.StateChanged += changed;

		var badge = row.Layout.Add( new ExperimentalBadgeWidget( row ) );
		SetExperimentalToolTip( description, badge );

		row.Layout.AddStretchCell();
		inspector.Layout.Add( row );
	}

	private void AddOutputFolderControl( Widget inspector, string description = null )
	{
		var row = new Widget( inspector );
		row.Layout = Layout.Row();
		row.Layout.Spacing = 6;
		row.MinimumHeight = 24;

		var label = row.Layout.Add( new Label( "Output Folder", row ) );
		StylePropertyLabel( label );

		outputFolderEdit = row.Layout.Add( new LineEdit( row ), 1 );
		outputFolderEdit.Text = outputFolder;
		outputFolderEdit.PlaceholderText = "Uses the source texture folder";
		outputFolderEdit.ClearButtonEnabled = true;
		StyleInputWidget( outputFolderEdit );
		outputFolderEdit.TextEdited += text =>
		{
			outputFolder = text;
			outputFolderFollowsSource = string.IsNullOrWhiteSpace( outputFolder );
			SavePbrPreferences();
		};

		SetInspectorToolTip( description, label );
		inspector.Layout.Add( row );

		var buttonRow = new Widget( inspector );
		buttonRow.Layout = Layout.Row();
		buttonRow.Layout.Spacing = 10;
		buttonRow.MinimumHeight = 30;
		buttonRow.SetStyles( "padding-top: 4px; padding-bottom: 2px;" );

		var spacer = buttonRow.Layout.Add( new Label( "", buttonRow ) );
		spacer.FixedWidth = InspectorLabelWidth;

		var sourceButton = buttonRow.Layout.Add( new Button( "Use Source", "source", buttonRow ) );
		sourceButton.MinimumWidth = 104;
		sourceButton.Clicked = UseSourceFolder;
		StyleInspectorButton( sourceButton );

		var browseButton = buttonRow.Layout.Add( new Button( "Browse", "folder_open", buttonRow ) );
		browseButton.MinimumWidth = 88;
		browseButton.Clicked = BrowseOutputFolder;
		StyleInspectorButton( browseButton );

		buttonRow.Layout.AddStretchCell();
		inspector.Layout.Add( buttonRow );
	}

	private void AddExportButtonRows( Widget inspector )
	{
		AddExportButtonRow( inspector, "Export Current Map", "save", ExportCurrentMap, "Exports only the map you are previewing right now." );
		AddExportButtonRow( inspector, "Export ORM Only", "save", ExportOrmOnly, "Exports the packed AO, Roughness, and Metallic texture for material workflows that use one ORM map." );
		AddExportButtonRow( inspector, "Export All Maps", "save", () => ExportAllMaps( false ), "Exports the full starter stack: albedo, height, normal, roughness, AO, metallic, and ORM." );
		AddExportButtonRow( inspector, "Export All + VMAT", "inventory_2", () => ExportAllMaps( true ), "Exports the full stack and creates a VMAT using the individual material texture slots." );
	}

	private void AddExportButtonRow( Widget inspector, string text, string icon, Action clicked, string description )
	{
		var row = new Widget( inspector );
		row.Layout = Layout.Row();
		row.Layout.Spacing = 8;
		row.MinimumHeight = 34;
		row.SetStyles( "padding-top: 4px;" );

		row.Layout.AddStretchCell();

		var button = row.Layout.Add( new Button( text, icon, row ) );
		button.MinimumWidth = 250;
		button.MinimumHeight = 28;
		button.Clicked = clicked;
		StyleExportButton( button );
		button.ToolTip = $"<html><body style=\"background-color:#252525; color:#ffffff; white-space: nowrap;\">{description}</body></html>";

		row.Layout.AddStretchCell();
		inspector.Layout.Add( row );
	}

	private Label AddReadOnlyValueRow( Widget inspector, string labelText, string value, string description = null )
	{
		var row = new Widget( inspector );
		row.Layout = Layout.Row();
		row.Layout.Spacing = 6;
		row.MinimumHeight = 24;

		var label = row.Layout.Add( new Label( labelText, row ) );
		StylePropertyLabel( label );

		var valueLabel = row.Layout.Add( new Label( value, row ), 1 );
		valueLabel.WordWrap = true;
		valueLabel.SetStyles( "background-color: #171717; border: 1px solid #252525; border-radius: 3px; color: #f0f0f0; font-size: 11px; padding-left: 8px; padding-top: 3px;" );

		SetInspectorToolTip( description, label );
		inspector.Layout.Add( row );
		return valueLabel;
	}

	private void SetInspectorToolTip( string description, params Widget[] widgets )
	{
		if ( string.IsNullOrWhiteSpace( description ) )
			return;

		foreach ( var widget in widgets )
		{
			if ( widget != null )
				widget.ToolTip = description;
		}
	}

	private void SetExperimentalToolTip( string description, Widget widget )
	{
		if ( string.IsNullOrWhiteSpace( description ) || widget == null )
			return;

		widget.ToolTip = $"<span style=\"color:#ff5f5f; font-weight:bold;\">{description}</span>";
	}

	private void StylePropertyLabel( Label label )
	{
		label.FixedWidth = InspectorLabelWidth;
		label.SetStyles( "color: #d6d6d6; font-size: 11px; padding-top: 4px; padding-left: 10px;" );
	}

	private void StyleInputWidget( Widget widget )
	{
		widget.MinimumHeight = 22;
		widget.SetStyles( "background-color: #171717; border: 1px solid #252525; border-radius: 3px; color: #f0f0f0; font-size: 11px;" );
	}

	private void StyleInspectorButton( Button button )
	{
		button.MinimumHeight = 22;
		button.SetStyles( "background-color: #1d1d1d; border: 1px solid #292929; border-radius: 3px; color: #e6e6e6; font-size: 11px;" );
	}

	private void StyleExportButton( Button button )
	{
		button.MinimumHeight = 28;
		button.Tint = new Color( 0.29f, 0.30f, 0.32f );
		button.SetStyles( "color: #ffffff; font-size: 12px; font-weight: bold;" );
	}

	private void StyleMapButton( Button button, bool selected )
	{
		if ( selected )
		{
			button.SetStyles( "background-color: #467022; border: 1px solid #6d9d36; border-radius: 4px; color: #ffffff; font-size: 11px; font-weight: bold;" );
			return;
		}

		button.SetStyles( "background-color: #1d1d1d; border: 1px solid #292929; border-radius: 4px; color: #d6d6d6; font-size: 11px;" );
	}

	private sealed class ExperimentalBadgeWidget : Widget
	{
		public ExperimentalBadgeWidget( Widget parent ) : base( parent )
		{
			FixedWidth = 96f;
			MinimumHeight = 22f;
		}

		protected override void OnPaint()
		{
			var center = new Vector2( 6f, LocalRect.Center.y );

			Paint.ClearPen();
			Paint.SetBrush( new Color( 0.42f, 0.11f, 0.11f ) );
			Paint.DrawCircle( new Rect( center.x - 5f, center.y - 5f, 10f, 10f ) );

			Paint.SetDefaultFont( 8, 700, false, false );
			Paint.SetPen( new Color( 1f, 0.62f, 0.62f ) );
			Paint.DrawText( new Rect( 0f, 1f, 12f, Height ), "i", TextFlag.Center );

			Paint.SetDefaultFont( 9, 700, false, false );
			Paint.SetPen( new Color( 1f, 0.36f, 0.36f ) );
			Paint.DrawText( new Rect( 14f, 0f, Width - 14f, Height ), "Experimental", TextFlag.LeftCenter );
		}
	}

	private void UpdateMapButtons()
	{
		foreach ( var pair in mapButtons )
		{
			StyleMapButton( pair.Value, pair.Key == selectedMap );
		}
	}

	private void OpenTextureDialog()
	{
		var dialog = new FileDialog( this )
		{
			Title = "Open Albedo Texture",
			DefaultSuffix = ".png"
		};

		dialog.SetModeOpen();
		dialog.SetFindExistingFile();
		dialog.SetNameFilter( "Images (*.png *.jpg *.jpeg *.webp *.bmp *.tga *.tif *.tiff *.psd *.svg);;All Files (*.*)" );

		if ( !dialog.Execute() )
			return;

		LoadTexture( dialog.SelectedFile );
	}

	private void LoadDroppedFiles( string[] droppedFiles )
	{
		var files = new List<string>();

		foreach ( var file in droppedFiles )
		{
			files.Add( file );
		}

		var supportedFile = SeamlessSuiteImageUtility.FindFirstSupportedFile( files );

		if ( supportedFile == null )
		{
			LogMessage( "Drop ignored. No supported image file was found." );
			ShowStatus( "No supported image file found", 4f );
			return;
		}

		LoadTexture( supportedFile );

		var skippedCount = Math.Max( 0, files.Count - 1 );
		if ( skippedCount > 0 )
			LogMessage( $"One-texture mode loaded the first supported image and skipped {skippedCount} other file(s)." );
	}

	private void LoadTexture( string path )
	{
		var resolvedPath = SeamlessSuiteImageUtility.ResolveFilePath( path );

		if ( string.IsNullOrWhiteSpace( resolvedPath ) || !File.Exists( resolvedPath ) )
		{
			LogMessage( $"Could not find texture: {path}" );
			ShowStatus( "Texture file not found", 4f );
			return;
		}

		if ( !SeamlessSuiteImageUtility.IsSupportedImageFile( resolvedPath ) )
		{
			LogMessage( $"Unsupported image type: {Path.GetExtension( resolvedPath )}" );
			ShowStatus( "Unsupported image type", 4f );
			return;
		}

		try
		{
			var bitmap = SeamlessSuiteImageUtility.LoadBitmapFromFile( resolvedPath );

			if ( bitmap == null || !bitmap.IsValid )
			{
				bitmap?.Dispose();
				LogMessage( $"S&box could not decode texture: {Path.GetFileName( resolvedPath )}" );
				ShowStatus( "Texture decode failed", 4f );
				return;
			}

			SetSourceTexture( bitmap, resolvedPath, Path.GetFileNameWithoutExtension( resolvedPath ) );
			LogMessage( $"Loaded {resolvedPath}" );
			ShowStatus( "Texture loaded", 4f );
			GenerateMaps( false );
		}
		catch ( Exception ex )
		{
			LogMessage( $"Load failed: {ex.Message}" );
			ShowStatus( "Load failed", 4f );
		}
	}

	private void UseCurrentSuiteTexture()
	{
		var bitmap = SeamlessSuiteActiveTexture.GetClone();

		if ( bitmap == null || !bitmap.IsValid )
		{
			bitmap?.Dispose();
			LogMessage( "No current suite texture is available. Process a texture in the Seam-Less™ tab first." );
			ShowStatus( "No current suite texture", 4f );
			return;
		}

		var displayName = SeamlessSuiteActiveTexture.DisplayName ?? "Current Suite Texture";
		SetSourceTexture( bitmap, SeamlessSuiteActiveTexture.SourcePath, displayName );
		LogMessage( $"Using current suite texture: {displayName}" );
		ShowStatus( "Current suite texture loaded", 4f );
		GenerateMaps( false );
	}

	private void SetSourceTexture( Bitmap bitmap, string path, string displayName )
	{
		sourceBitmap?.Dispose();
		generatedResult?.Dispose();
		sourceBitmap = bitmap;
		generatedResult = null;
		sourceImagePath = path;
		sourceDisplayName = displayName;

		previewWidget?.SetResult( null );
		previewWidget?.SetBitmap( null );
		if ( experimentalMaterialPreviewEnabled )
			materialPreviewWidget?.SetResult( null );

		if ( outputFolderFollowsSource || string.IsNullOrWhiteSpace( outputFolder ) )
			SetOutputFolderToSource();

		UpdateSourceLabels();
		previewWidget.SetCaption( GetPreviewCaption() );
	}

	private void GenerateMaps( bool manual )
	{
		if ( sourceBitmap == null || !sourceBitmap.IsValid )
		{
			if ( manual )
				LogMessage( "No source texture loaded. Open, drop, or use the current suite texture first." );

			ShowStatus( "No source texture loaded", 4f );
			return;
		}

		latestGenerationRequestId++;

		if ( generationRunning )
		{
			generationQueued = true;
			queuedGenerationWasManual |= manual;
			queuedGenerationTime = DateTime.Now.AddMilliseconds( 140 );
			previewWidget?.SetLoading( true );
			return;
		}

		StartGenerateMaps( latestGenerationRequestId, manual );
	}

	private void StartGenerateMaps( int requestId, bool manual )
	{
		if ( sourceBitmap == null || !sourceBitmap.IsValid )
			return;

		var settingsSnapshot = CreateSettingsSnapshot();
		var processStarted = DateTime.Now;

		generationRunning = true;
		runningGenerationRequestId = requestId;
		previewWidget?.SetLoading( true );

		Color[] sourcePixels = null;
		var width = 0;
		var height = 0;
		var albedoIsFloatingPoint = false;

		try
		{
			width = sourceBitmap.Width;
			height = sourceBitmap.Height;
			albedoIsFloatingPoint = sourceBitmap.IsFloatingPoint;
			sourcePixels = sourceBitmap.GetPixels();
		}
		catch ( Exception ex )
		{
			generationRunning = false;
			runningGenerationRequestId = 0;
			LogMessage( $"Generation failed before it could start: {ex.Message}" );
			ShowStatus( "Generation failed", 4f );
			previewWidget?.SetLoading( false );
			return;
		}

		_ = Task.Run( () =>
		{
			PbrGeneratorPixelResult pixelResult = null;
			Exception error = null;

			try
			{
				pixelResult = PbrGenerator.GeneratePixels( sourcePixels, width, height, albedoIsFloatingPoint, settingsSnapshot );
			}
			catch ( Exception ex )
			{
				error = ex;
			}

			MainThread.Queue( () => FinishGenerateMaps( requestId, manual, processStarted, pixelResult, error ) );
		} );
	}

	private void FinishGenerateMaps( int requestId, bool manual, DateTime processStarted, PbrGeneratorPixelResult pixelResult, Exception error )
	{
		if ( error != null )
		{
			FinishGenerateMaps( requestId, manual, processStarted, (PbrGeneratorResult)null, error );
			return;
		}

		if ( !CanAcceptGenerationResult( requestId ) )
		{
			generationRunning = false;
			runningGenerationRequestId = 0;
			StartQueuedGenerationIfReady();
			return;
		}

		var result = CreateGeneratedBitmapResult( pixelResult );
		FinishGenerateMaps( requestId, manual, processStarted, result, null );
	}

	private void FinishGenerateMaps( int requestId, bool manual, DateTime processStarted, PbrGeneratorResult result, Exception error )
	{
		var wasRunningRequest = requestId == runningGenerationRequestId;
		generationRunning = false;
		runningGenerationRequestId = 0;

		if ( windowDestroyed || !wasRunningRequest || requestId != latestGenerationRequestId || generationQueued )
		{
			result?.Dispose();
			StartQueuedGenerationIfReady();
			return;
		}

		if ( error != null )
		{
			UpdatePreviewSettings();
			LogMessage( $"Generation failed: {error.Message}" );
			ShowStatus( "Generation failed", 4f );
			previewWidget?.SetLoading( false );
			return;
		}

		if ( result == null || result.Albedo == null || !result.Albedo.IsValid )
		{
			result?.Dispose();
			UpdatePreviewSettings();
			LogMessage( "PBR generation failed. The bitmap result was invalid." );
			ShowStatus( "Generation failed", 4f );
			previewWidget?.SetLoading( false );
			return;
		}

		var oldResult = generatedResult;
		generatedResult = result;
		oldResult?.Dispose();

		UpdatePreviewSettings();
		UpdateMaterialPreview( manual );
		UpdateSourceLabels();
		previewWidget?.SetLoading( false );

		if ( manual )
			LogMessage( "Generated starter PBR maps with current settings." );

		var elapsed = DateTime.Now - processStarted;
		if ( elapsed.TotalMilliseconds > 1500 )
			LogMessage( $"PBR generation took {elapsed.TotalSeconds:0.0}s. Large textures can take a moment." );

		ShowStatus( "PBR maps updated", 2f );
	}

	private bool CanAcceptGenerationResult( int requestId )
	{
		return !windowDestroyed
			&& requestId == runningGenerationRequestId
			&& requestId == latestGenerationRequestId
			&& !generationQueued;
	}

	private PbrGeneratorResult CreateGeneratedBitmapResult( PbrGeneratorPixelResult pixelResult )
	{
		try
		{
			return PbrGenerator.CreateBitmapResult( pixelResult );
		}
		catch ( Exception ex )
		{
			LogMessage( $"Generation failed while building preview bitmaps: {ex.Message}" );
			return null;
		}
	}

	private void StartQueuedGenerationIfReady()
	{
		if ( windowDestroyed || generationRunning || !generationQueued )
		{
			if ( !generationRunning && !generationQueued )
				previewWidget?.SetLoading( false );

			return;
		}

		if ( sourceBitmap == null || !sourceBitmap.IsValid )
		{
			generationQueued = false;
			queuedGenerationWasManual = false;
			previewWidget?.SetLoading( false );
			return;
		}

		generationQueued = false;
		var manual = queuedGenerationWasManual;
		queuedGenerationWasManual = false;
		StartGenerateMaps( latestGenerationRequestId, manual );
	}

	private void ResetSettings()
	{
		settings = new PbrGeneratorSettings();
		selectedMap = PbrMapType.Albedo;
		previewLayout = PbrPreviewLayout.Atlas;
		previewShape = PbrPreviewShape.Sphere;
		experimentalMaterialPreviewEnabled = false;
		autoRefreshMaterialPreview = false;
		parallaxMaterialPreviewEnabled = false;
		materialPreviewWidget?.SetParallaxEnabled( parallaxMaterialPreviewEnabled );
		materialPreviewWidget?.SetPreviewShape( previewShape );
		materialPreviewWidget?.SetPreviewEnabled( experimentalMaterialPreviewEnabled );
		ApplyMaterialPreviewPanelState();
		SavePbrPreferences();
		RebuildInspector();
		previewWidget.SetPreviewLayout( previewLayout );
		previewWidget.FitToView();

		if ( sourceBitmap != null && sourceBitmap.IsValid )
			GenerateMaps( false );
		else
			UpdateMaterialPreview( true );

		LogMessage( "PBR settings reset to defaults." );
		ShowStatus( "Settings reset", 3f );
	}

	private void RebuildInspector()
	{
		if ( inspectorScroll == null )
			return;

		var oldCanvas = inspectorScroll.Canvas;
		inspectorScroll.Canvas = BuildInspector( inspectorScroll );
		inspectorScroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
		inspectorScroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;

		if ( oldCanvas != null && oldCanvas.IsValid )
		{
			oldCanvas.DestroyChildren();
			oldCanvas.Hidden = true;
		}
	}

	private void QueueGenerate()
	{
		if ( sourceBitmap == null || !sourceBitmap.IsValid )
			return;

		latestGenerationRequestId++;
		generationQueued = true;
		queuedGenerationWasManual = false;
		queuedGenerationTime = DateTime.Now.AddMilliseconds( 140 );
		previewWidget?.SetLoading( true );
	}

	private PbrGeneratorSettings CreateSettingsSnapshot()
	{
		return new PbrGeneratorSettings
		{
			HeightStrength = settings.HeightStrength,
			HeightContrast = settings.HeightContrast,
			HeightBlackPoint = settings.HeightBlackPoint,
			HeightWhitePoint = settings.HeightWhitePoint,
			HeightMidpoint = settings.HeightMidpoint,
			HeightCavityEmphasis = settings.HeightCavityEmphasis,
			HeightBlur = settings.HeightBlur,
			InvertHeight = settings.InvertHeight,
			AdvancedHeightEnabled = settings.AdvancedHeightEnabled,
			AdvancedHeightFineStrength = settings.AdvancedHeightFineStrength,
			AdvancedHeightMediumStrength = settings.AdvancedHeightMediumStrength,
			AdvancedHeightLargeStrength = settings.AdvancedHeightLargeStrength,
			AdvancedHeightBroadStrength = settings.AdvancedHeightBroadStrength,
			AdvancedHeightScaleRadius = settings.AdvancedHeightScaleRadius,
			AdvancedHeightFinalContrast = settings.AdvancedHeightFinalContrast,
			AdvancedHeightBias = settings.AdvancedHeightBias,
			AdvancedHeightGain = settings.AdvancedHeightGain,
			AdvancedHeightSmoothing = settings.AdvancedHeightSmoothing,
			NormalStrength = settings.NormalStrength,
			NormalSmoothing = settings.NormalSmoothing,
			FlipNormalY = settings.FlipNormalY,
			RoughnessBase = settings.RoughnessBase,
			RoughnessContrast = settings.RoughnessContrast,
			RoughnessBlackPoint = settings.RoughnessBlackPoint,
			RoughnessWhitePoint = settings.RoughnessWhitePoint,
			RoughnessCavity = settings.RoughnessCavity,
			RoughnessVariation = settings.RoughnessVariation,
			InvertRoughness = settings.InvertRoughness,
			AoStrength = settings.AoStrength,
			AoRadius = settings.AoRadius,
			AoMinimum = settings.AoMinimum,
			MetallicValue = settings.MetallicValue,
			OverwriteExisting = settings.OverwriteExisting
		};
	}

	private void UpdatePreviewSettings()
	{
		var map = generatedResult?.GetMap( selectedMap );
		previewWidget.SetPreviewLayout( previewLayout );
		previewWidget.SetResult( generatedResult );
		previewWidget.SetBitmap( map );
		previewWidget.SetCaption( GetPreviewCaption() );
	}

	private void UpdateMaterialPreview( bool force = false )
	{
		if ( materialPreviewWidget == null )
			return;

		ApplyMaterialPreviewPanelState();
		materialPreviewWidget.SetPreviewEnabled( experimentalMaterialPreviewEnabled );

		if ( !experimentalMaterialPreviewEnabled )
			return;

		materialPreviewWidget.SetParallaxEnabled( parallaxMaterialPreviewEnabled );
		materialPreviewWidget.SetPreviewShape( previewShape );

		if ( !force && !autoRefreshMaterialPreview )
			return;

		materialPreviewWidget.SetResult( generatedResult );
	}

	private void ApplyMaterialPreviewPanelState()
	{
		if ( materialPreviewWidget == null )
			return;

		materialPreviewWidget.Hidden = !experimentalMaterialPreviewEnabled;
		materialPreviewWidget.MinimumHeight = experimentalMaterialPreviewEnabled ? 220f : 0f;
		previewSplitter?.SetStretch( 0, experimentalMaterialPreviewEnabled ? 3 : 1 );
		previewSplitter?.SetStretch( 1, experimentalMaterialPreviewEnabled ? 2 : 0 );
		previewSplitter?.UpdateGeometry();
	}

	private void UpdateSourceLabels()
	{
		if ( currentFileLabel != null )
			currentFileLabel.Text = GetSourceFileText();

		if ( imageInfoLabel != null )
			imageInfoLabel.Text = GetSourceInfoText();

		if ( noteLabel != null )
			noteLabel.Text = GetSourceNoteText();
	}

	private string GetPreviewCaption()
	{
		var mapLabel = previewLayout == PbrPreviewLayout.Atlas
			? "Atlas"
			: PbrGenerator.GetMapLabel( selectedMap );

		if ( sourceBitmap == null || !sourceBitmap.IsValid )
			return $"{mapLabel} | Drop an albedo texture here or use Load Texture";

		return $"{mapLabel} | {GetSourceFileText()} | {sourceBitmap.Width} x {sourceBitmap.Height}";
	}

	private string GetSourceFileText()
	{
		if ( !string.IsNullOrWhiteSpace( sourceImagePath ) )
			return Path.GetFileName( sourceImagePath );

		if ( !string.IsNullOrWhiteSpace( sourceDisplayName ) )
			return sourceDisplayName;

		return "No texture loaded";
	}

	private string GetSourceInfoText()
	{
		if ( sourceBitmap == null || !sourceBitmap.IsValid )
			return "Drop an albedo texture into the preview, or use Load Texture.";

		var sourceType = !string.IsNullOrWhiteSpace( sourceImagePath )
			? Path.GetExtension( sourceImagePath ).TrimStart( '.' ).ToUpperInvariant()
			: "SUITE";

		return $"{sourceBitmap.Width} x {sourceBitmap.Height}  |  {sourceType}";
	}

	private string GetSourceNoteText()
	{
		if ( sourceBitmap == null || !sourceBitmap.IsValid )
			return "PBR maps generated from color are starter estimates.";

		var note = "Generated maps are starter estimates from color only.";

		if ( sourceBitmap.Width != sourceBitmap.Height )
			note += " Input is not square.";

		if ( sourceBitmap.Width < 256 || sourceBitmap.Height < 256 )
			note += " Input is low resolution.";

		return note;
	}

	private void ExportCurrentMap()
	{
		if ( !EnsureMapsGenerated() )
			return;

		try
		{
			var outputDirectory = GetOutputDirectory();
			Directory.CreateDirectory( outputDirectory );
			var paths = new PbrExportedMapPaths();
			var path = ExportMap( selectedMap, outputDirectory, paths );

			if ( string.IsNullOrWhiteSpace( path ) )
				return;

			lastExportPath = path;
			ShowStatus( "Current map export complete", 5f );
		}
		catch ( Exception ex )
		{
			LogMessage( $"Export failed: {ex.Message}" );
			ShowStatus( "Export failed", 5f );
		}
	}

	private void ExportOrmOnly()
	{
		if ( !EnsureMapsGenerated() )
			return;

		try
		{
			var outputDirectory = GetOutputDirectory();
			Directory.CreateDirectory( outputDirectory );
			var paths = new PbrExportedMapPaths();
			var path = ExportMap( PbrMapType.Orm, outputDirectory, paths );

			if ( string.IsNullOrWhiteSpace( path ) )
				return;

			lastExportPath = path;
			ShowStatus( "ORM export complete", 5f );
		}
		catch ( Exception ex )
		{
			LogMessage( $"Export failed: {ex.Message}" );
			ShowStatus( "Export failed", 5f );
		}
	}

	private void ExportAllMaps( bool includeVmat )
	{
		if ( !EnsureMapsGenerated() )
			return;

		try
		{
			var outputDirectory = GetOutputDirectory();
			Directory.CreateDirectory( outputDirectory );
			var paths = new PbrExportedMapPaths();

			ExportMap( PbrMapType.Albedo, outputDirectory, paths );
			ExportMap( PbrMapType.Height, outputDirectory, paths );
			ExportMap( PbrMapType.Normal, outputDirectory, paths );
			ExportMap( PbrMapType.Roughness, outputDirectory, paths );
			ExportMap( PbrMapType.AmbientOcclusion, outputDirectory, paths );
			ExportMap( PbrMapType.Metallic, outputDirectory, paths );
			ExportMap( PbrMapType.Orm, outputDirectory, paths );

			lastExportPath = paths.OrmPath ?? paths.AlbedoPath;

			if ( includeVmat )
			{
				var vmatPath = SeamlessSuiteImageUtility.BuildUniquePath( outputDirectory, GetSourceBaseName(), "_pbr", ".vmat", settings.OverwriteExisting );
				var materialResult = PbrMaterialExporter.Export( vmatPath, paths, settings.OverwriteExisting );

				if ( !string.IsNullOrWhiteSpace( materialResult.Warning ) )
					LogMessage( $"VMAT warning: {materialResult.Warning}" );

				if ( materialResult.Success )
				{
					lastExportPath = materialResult.VmatPath;
					LogMessage( $"Exported VMAT {materialResult.VmatPath}" );
					ShowStatus( "PBR stack and VMAT export complete", 5f );
					return;
				}

				var error = string.IsNullOrWhiteSpace( materialResult.Error )
					? "Unknown error."
					: materialResult.Error;
				LogMessage( $"VMAT export failed: {error}" );
				ShowStatus( "Maps exported; VMAT failed", 5f );
				return;
			}

			ShowStatus( "PBR stack export complete", 5f );
		}
		catch ( Exception ex )
		{
			LogMessage( $"Export failed: {ex.Message}" );
			ShowStatus( "Export failed", 5f );
		}
	}

	private bool EnsureMapsGenerated()
	{
		if ( generationQueued || generationRunning )
		{
			LogMessage( "Maps are still building. Try exporting again when the preview finishes." );
			ShowStatus( "Maps are still building", 4f );
			return false;
		}

		if ( generatedResult == null || generatedResult.Albedo == null || !generatedResult.Albedo.IsValid )
		{
			GenerateMaps( true );
			LogMessage( "Maps are generating. Try exporting again when the preview finishes." );
			ShowStatus( "Maps are generating", 4f );
		}

		return generatedResult != null && generatedResult.Albedo != null && generatedResult.Albedo.IsValid;
	}

	private string ExportMap( PbrMapType mapType, string outputDirectory, PbrExportedMapPaths paths )
	{
		var bitmap = generatedResult?.GetMap( mapType );

		if ( bitmap == null || !bitmap.IsValid )
		{
			LogMessage( $"Skipped {PbrGenerator.GetMapLabel( mapType )}; generated bitmap was invalid." );
			return null;
		}

		var outputPath = BuildMapOutputPath( outputDirectory, mapType );
		File.WriteAllBytes( outputPath, bitmap.ToPng() );
		SeamlessSuiteImageUtility.RegisterFile( outputPath, LogMessage );
		SetExportedPath( paths, mapType, outputPath );
		LogMessage( $"Exported {outputPath}" );
		return outputPath;
	}

	private void SetExportedPath( PbrExportedMapPaths paths, PbrMapType mapType, string outputPath )
	{
		switch ( mapType )
		{
			case PbrMapType.Albedo:
				paths.AlbedoPath = outputPath;
				return;
			case PbrMapType.Height:
				paths.HeightPath = outputPath;
				return;
			case PbrMapType.Normal:
				paths.NormalPath = outputPath;
				return;
			case PbrMapType.Roughness:
				paths.RoughnessPath = outputPath;
				return;
			case PbrMapType.AmbientOcclusion:
				paths.AoPath = outputPath;
				return;
			case PbrMapType.Metallic:
				paths.MetallicPath = outputPath;
				return;
			case PbrMapType.Orm:
				paths.OrmPath = outputPath;
				return;
		}
	}

	private string BuildMapOutputPath( string outputDirectory, PbrMapType mapType )
	{
		return SeamlessSuiteImageUtility.BuildUniquePath(
			outputDirectory,
			GetSourceBaseName(),
			PbrGenerator.GetMapSuffix( mapType ),
			".png",
			settings.OverwriteExisting
		);
	}

	private string GetSourceBaseName()
	{
		if ( !string.IsNullOrWhiteSpace( sourceImagePath ) )
			return Path.GetFileNameWithoutExtension( sourceImagePath );

		if ( !string.IsNullOrWhiteSpace( sourceDisplayName ) )
			return sourceDisplayName;

		return "pbr_texture";
	}

	private string GetOutputDirectory()
	{
		if ( !string.IsNullOrWhiteSpace( outputFolder ) )
			return outputFolder;

		if ( !string.IsNullOrWhiteSpace( sourceImagePath ) )
			return Path.GetDirectoryName( sourceImagePath ) ?? Directory.GetCurrentDirectory();

		return Directory.GetCurrentDirectory();
	}

	private void RevealOutput()
	{
		if ( !string.IsNullOrWhiteSpace( lastExportPath ) && File.Exists( lastExportPath ) )
		{
			EditorUtility.OpenFileFolder( lastExportPath );
			return;
		}

		var outputDirectory = GetOutputDirectory();
		if ( Directory.Exists( outputDirectory ) )
		{
			EditorUtility.OpenFolder( outputDirectory );
			return;
		}

		LogMessage( "No output folder exists yet." );
		ShowStatus( "No output folder exists yet", 4f );
	}

	private void BrowseOutputFolder()
	{
		var dialog = new FileDialog( this )
		{
			Title = "Choose Output Folder"
		};

		dialog.SetModeOpen();
		dialog.SetFindDirectory();

		if ( !string.IsNullOrWhiteSpace( outputFolder ) )
			dialog.Directory = outputFolder;

		if ( !dialog.Execute() )
			return;

		var folder = !string.IsNullOrWhiteSpace( dialog.SelectedFile )
			? dialog.SelectedFile
			: dialog.Directory;

		if ( string.IsNullOrWhiteSpace( folder ) )
			return;

		outputFolderFollowsSource = false;
		outputFolder = folder;
		outputFolderEdit.Text = folder;
		SavePbrPreferences();
		LogMessage( $"Output folder set to {folder}" );
	}

	private void UseSourceFolder()
	{
		outputFolderFollowsSource = true;
		SetOutputFolderToSource();
		SavePbrPreferences();
		LogMessage( "Output folder now follows the source texture folder." );
	}

	private void SetOutputFolderToSource()
	{
		var sourceFolder = !string.IsNullOrWhiteSpace( sourceImagePath )
			? Path.GetDirectoryName( sourceImagePath )
			: "";

		outputFolder = sourceFolder ?? "";

		if ( outputFolderEdit != null )
			outputFolderEdit.Text = outputFolder;
	}

	private void ApplySavedPreferences( PbrGeneratorPreferences preferences )
	{
		if ( preferences == null )
			return;

		outputFolderFollowsSource = preferences.OutputFolderFollowsSource;
		outputFolder = outputFolderFollowsSource ? "" : preferences.OutputFolder ?? "";
		selectedMap = preferences.SelectedMap;
		previewLayout = preferences.PreviewLayout;
		previewShape = preferences.PreviewShape;
		experimentalMaterialPreviewEnabled = preferences.ExperimentalMaterialPreviewEnabled;
		autoRefreshMaterialPreview = preferences.AutoRefreshMaterialPreview;
		parallaxMaterialPreviewEnabled = preferences.ParallaxMaterialPreviewEnabled;
	}

	private void SavePbrPreferences()
	{
		PbrGeneratorPreferences.Save( new PbrGeneratorPreferences
		{
			OutputFolder = outputFolderFollowsSource ? "" : outputFolder ?? "",
			OutputFolderFollowsSource = outputFolderFollowsSource,
			SelectedMap = selectedMap,
			PreviewLayout = previewLayout,
			PreviewShape = previewShape,
			ExperimentalMaterialPreviewEnabled = experimentalMaterialPreviewEnabled,
			AutoRefreshMaterialPreview = autoRefreshMaterialPreview,
			ParallaxMaterialPreviewEnabled = parallaxMaterialPreviewEnabled
		} );
	}

	private void RestoreSplitterState()
	{
		try
		{
			if ( EditorCookie.TryGetString( ContentSplitterCookie, out var contentState ) && !string.IsNullOrWhiteSpace( contentState ) )
				contentSplitter?.RestoreState( contentState );

			if ( EditorCookie.TryGetString( PreviewSplitterCookie, out var previewState ) && !string.IsNullOrWhiteSpace( previewState ) )
				previewSplitter?.RestoreState( previewState );

			if ( EditorCookie.TryGetString( MainSplitterCookie, out var mainState ) && !string.IsNullOrWhiteSpace( mainState ) )
				mainSplitter?.RestoreState( mainState );
		}
		catch ( Exception ex )
		{
			LogMessage( $"Could not restore panel sizes, using defaults: {ex.Message}" );
		}
	}

	private void SaveSplitterState()
	{
		try
		{
			if ( contentSplitter != null )
				EditorCookie.SetString( ContentSplitterCookie, contentSplitter.SaveState() );

			if ( previewSplitter != null )
				EditorCookie.SetString( PreviewSplitterCookie, previewSplitter.SaveState() );

			if ( mainSplitter != null )
				EditorCookie.SetString( MainSplitterCookie, mainSplitter.SaveState() );
		}
		catch ( Exception ex )
		{
			Log.Info( $"Seam-Less PBR: Could not save panel sizes: {ex.Message}" );
		}
	}

	private float SnapSliderValue( float value, float step, float min, float max )
	{
		var clamped = Math.Clamp( value, min, max );

		if ( step <= 0f )
			return clamped;

		return Math.Clamp( MathF.Round( clamped / step ) * step, min, max );
	}

	private string FormatSliderValue( float value, float step )
	{
		if ( step >= 1f )
			return MathF.Round( value ).ToString();

		if ( step >= 0.05f )
			return value.ToString( "0.0" );

		return value.ToString( "0.00" );
	}

	private void ShowStatus( string message, float seconds )
	{
		var line = $"[{DateTime.Now:HH:mm:ss}] Status: {message}";
		consoleOutput?.AppendPlainText( line );
		consoleOutput?.ScrollToBottom();
	}

	private void LogMessage( string message )
	{
		var line = $"[{DateTime.Now:HH:mm:ss}] {message}";
		consoleOutput?.AppendPlainText( line );
		consoleOutput?.ScrollToBottom();
		Log.Info( $"Seam-Less PBR: {message}" );
	}

	private void ClearAll()
	{
		latestGenerationRequestId++;
		generationQueued = false;
		queuedGenerationWasManual = false;
		previewWidget?.SetLoading( false );
		DisposeBitmaps();
		sourceImagePath = null;
		sourceDisplayName = null;
		lastExportPath = null;

		if ( outputFolderFollowsSource )
			SetOutputFolderToSource();

		UpdateSourceLabels();
		previewWidget?.SetCaption( "Drop an albedo texture here or use Load Texture" );
		previewWidget?.SetResult( null );
		previewWidget?.SetBitmap( null );
		if ( experimentalMaterialPreviewEnabled )
			materialPreviewWidget?.SetResult( null );
		consoleOutput?.Clear();
		Log.Info( "Seam-Less PBR: Cleared texture and log." );
	}

	private void DisposeBitmaps()
	{
		if ( experimentalMaterialPreviewEnabled )
			materialPreviewWidget?.SetResult( null );
		previewWidget?.SetLoading( false );
		previewWidget?.SetResult( null );
		sourceBitmap?.Dispose();
		generatedResult?.Dispose();
		sourceBitmap = null;
		generatedResult = null;
	}
}